001/* 002 * Copyright 2012-2018 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.boot.loader; 018 019import java.io.File; 020import java.io.FileInputStream; 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.UnsupportedEncodingException; 024import java.net.HttpURLConnection; 025import java.net.URL; 026import java.net.URLConnection; 027import java.net.URLDecoder; 028import java.util.ArrayList; 029import java.util.Collections; 030import java.util.LinkedHashSet; 031import java.util.List; 032import java.util.Locale; 033import java.util.Properties; 034import java.util.Set; 035import java.util.jar.Manifest; 036import java.util.regex.Matcher; 037import java.util.regex.Pattern; 038 039import org.springframework.boot.loader.archive.Archive; 040import org.springframework.boot.loader.archive.Archive.Entry; 041import org.springframework.boot.loader.archive.Archive.EntryFilter; 042import org.springframework.boot.loader.archive.ExplodedArchive; 043import org.springframework.boot.loader.archive.JarFileArchive; 044import org.springframework.boot.loader.util.SystemPropertyUtils; 045 046/** 047 * {@link Launcher} for archives with user-configured classpath and main class via a 048 * properties file. This model is often more flexible and more amenable to creating 049 * well-behaved OS-level services than a model based on executable jars. 050 * <p> 051 * Looks in various places for a properties file to extract loader settings, defaulting to 052 * {@code loader.properties} either on the current classpath or in the current working 053 * directory. The name of the properties file can be changed by setting a System property 054 * {@code loader.config.name} (e.g. {@code -Dloader.config.name=foo} will look for 055 * {@code foo.properties}. If that file doesn't exist then tries 056 * {@code loader.config.location} (with allowed prefixes {@code classpath:} and 057 * {@code file:} or any valid URL). Once that file is located turns it into Properties and 058 * extracts optional values (which can also be provided overridden as System properties in 059 * case the file doesn't exist): 060 * <ul> 061 * <li>{@code loader.path}: a comma-separated list of directories (containing file 062 * resources and/or nested archives in *.jar or *.zip or archives) or archives to append 063 * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are 064 * always used</li> 065 * <li>{@code loader.main}: the main method to delegate execution to once the class loader 066 * is set up. No default, but will fall back to looking for a {@code Start-Class} in a 067 * {@code MANIFEST.MF}, if there is one in <code>${loader.home}/META-INF</code>.</li> 068 * </ul> 069 * 070 * @author Dave Syer 071 * @author Janne Valkealahti 072 * @author Andy Wilkinson 073 */ 074public class PropertiesLauncher extends Launcher { 075 076 private static final String DEBUG = "loader.debug"; 077 078 /** 079 * Properties key for main class. As a manifest entry can also be specified as 080 * {@code Start-Class}. 081 */ 082 public static final String MAIN = "loader.main"; 083 084 /** 085 * Properties key for classpath entries (directories possibly containing jars or 086 * jars). Multiple entries can be specified using a comma-separated list. {@code 087 * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used. 088 */ 089 public static final String PATH = "loader.path"; 090 091 /** 092 * Properties key for home directory. This is the location of external configuration 093 * if not on classpath, and also the base path for any relative paths in the 094 * {@link #PATH loader path}. Defaults to current working directory ( 095 * <code>${user.dir}</code>). 096 */ 097 public static final String HOME = "loader.home"; 098 099 /** 100 * Properties key for default command line arguments. These arguments (if present) are 101 * prepended to the main method arguments before launching. 102 */ 103 public static final String ARGS = "loader.args"; 104 105 /** 106 * Properties key for name of external configuration file (excluding suffix). Defaults 107 * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is 108 * provided instead. 109 */ 110 public static final String CONFIG_NAME = "loader.config.name"; 111 112 /** 113 * Properties key for config file location (including optional classpath:, file: or 114 * URL prefix). 115 */ 116 public static final String CONFIG_LOCATION = "loader.config.location"; 117 118 /** 119 * Properties key for boolean flag (default false) which if set will cause the 120 * external configuration properties to be copied to System properties (assuming that 121 * is allowed by Java security). 122 */ 123 public static final String SET_SYSTEM_PROPERTIES = "loader.system"; 124 125 private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+"); 126 127 private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator; 128 129 private final File home; 130 131 private List<String> paths = new ArrayList<>(); 132 133 private final Properties properties = new Properties(); 134 135 private Archive parent; 136 137 public PropertiesLauncher() { 138 try { 139 this.home = getHomeDirectory(); 140 initializeProperties(); 141 initializePaths(); 142 this.parent = createArchive(); 143 } 144 catch (Exception ex) { 145 throw new IllegalStateException(ex); 146 } 147 } 148 149 protected File getHomeDirectory() { 150 try { 151 return new File(getPropertyWithDefault(HOME, "${user.dir}")); 152 } 153 catch (Exception ex) { 154 throw new IllegalStateException(ex); 155 } 156 } 157 158 private void initializeProperties() throws Exception, IOException { 159 List<String> configs = new ArrayList<>(); 160 if (getProperty(CONFIG_LOCATION) != null) { 161 configs.add(getProperty(CONFIG_LOCATION)); 162 } 163 else { 164 String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(","); 165 for (String name : names) { 166 configs.add("file:" + getHomeDirectory() + "/" + name + ".properties"); 167 configs.add("classpath:" + name + ".properties"); 168 configs.add("classpath:BOOT-INF/classes/" + name + ".properties"); 169 } 170 } 171 for (String config : configs) { 172 try (InputStream resource = getResource(config)) { 173 if (resource != null) { 174 debug("Found: " + config); 175 loadResource(resource); 176 // Load the first one we find 177 return; 178 } 179 else { 180 debug("Not found: " + config); 181 } 182 } 183 } 184 } 185 186 private void loadResource(InputStream resource) throws IOException, Exception { 187 this.properties.load(resource); 188 for (Object key : Collections.list(this.properties.propertyNames())) { 189 String text = this.properties.getProperty((String) key); 190 String value = SystemPropertyUtils.resolvePlaceholders(this.properties, text); 191 if (value != null) { 192 this.properties.put(key, value); 193 } 194 } 195 if ("true".equals(getProperty(SET_SYSTEM_PROPERTIES))) { 196 debug("Adding resolved properties to System properties"); 197 for (Object key : Collections.list(this.properties.propertyNames())) { 198 String value = this.properties.getProperty((String) key); 199 System.setProperty((String) key, value); 200 } 201 } 202 } 203 204 private InputStream getResource(String config) throws Exception { 205 if (config.startsWith("classpath:")) { 206 return getClasspathResource(config.substring("classpath:".length())); 207 } 208 config = handleUrl(config); 209 if (isUrl(config)) { 210 return getURLResource(config); 211 } 212 return getFileResource(config); 213 } 214 215 private String handleUrl(String path) throws UnsupportedEncodingException { 216 if (path.startsWith("jar:file:") || path.startsWith("file:")) { 217 path = URLDecoder.decode(path, "UTF-8"); 218 if (path.startsWith("file:")) { 219 path = path.substring("file:".length()); 220 if (path.startsWith("//")) { 221 path = path.substring(2); 222 } 223 } 224 } 225 return path; 226 } 227 228 private boolean isUrl(String config) { 229 return config.contains("://"); 230 } 231 232 private InputStream getClasspathResource(String config) { 233 while (config.startsWith("/")) { 234 config = config.substring(1); 235 } 236 config = "/" + config; 237 debug("Trying classpath: " + config); 238 return getClass().getResourceAsStream(config); 239 } 240 241 private InputStream getFileResource(String config) throws Exception { 242 File file = new File(config); 243 debug("Trying file: " + config); 244 if (file.canRead()) { 245 return new FileInputStream(file); 246 } 247 return null; 248 } 249 250 private InputStream getURLResource(String config) throws Exception { 251 URL url = new URL(config); 252 if (exists(url)) { 253 URLConnection con = url.openConnection(); 254 try { 255 return con.getInputStream(); 256 } 257 catch (IOException ex) { 258 // Close the HTTP connection (if applicable). 259 if (con instanceof HttpURLConnection) { 260 ((HttpURLConnection) con).disconnect(); 261 } 262 throw ex; 263 } 264 } 265 return null; 266 } 267 268 private boolean exists(URL url) throws IOException { 269 // Try a URL connection content-length header... 270 URLConnection connection = url.openConnection(); 271 try { 272 connection.setUseCaches( 273 connection.getClass().getSimpleName().startsWith("JNLP")); 274 if (connection instanceof HttpURLConnection) { 275 HttpURLConnection httpConnection = (HttpURLConnection) connection; 276 httpConnection.setRequestMethod("HEAD"); 277 int responseCode = httpConnection.getResponseCode(); 278 if (responseCode == HttpURLConnection.HTTP_OK) { 279 return true; 280 } 281 else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { 282 return false; 283 } 284 } 285 return (connection.getContentLength() >= 0); 286 } 287 finally { 288 if (connection instanceof HttpURLConnection) { 289 ((HttpURLConnection) connection).disconnect(); 290 } 291 } 292 } 293 294 private void initializePaths() throws Exception { 295 String path = getProperty(PATH); 296 if (path != null) { 297 this.paths = parsePathsProperty(path); 298 } 299 debug("Nested archive paths: " + this.paths); 300 } 301 302 private List<String> parsePathsProperty(String commaSeparatedPaths) { 303 List<String> paths = new ArrayList<>(); 304 for (String path : commaSeparatedPaths.split(",")) { 305 path = cleanupPath(path); 306 // "" means the user wants root of archive but not current directory 307 path = "".equals(path) ? "/" : path; 308 paths.add(path); 309 } 310 if (paths.isEmpty()) { 311 paths.add("lib"); 312 } 313 return paths; 314 } 315 316 protected String[] getArgs(String... args) throws Exception { 317 String loaderArgs = getProperty(ARGS); 318 if (loaderArgs != null) { 319 String[] defaultArgs = loaderArgs.split("\\s+"); 320 String[] additionalArgs = args; 321 args = new String[defaultArgs.length + additionalArgs.length]; 322 System.arraycopy(defaultArgs, 0, args, 0, defaultArgs.length); 323 System.arraycopy(additionalArgs, 0, args, defaultArgs.length, 324 additionalArgs.length); 325 } 326 return args; 327 } 328 329 @Override 330 protected String getMainClass() throws Exception { 331 String mainClass = getProperty(MAIN, "Start-Class"); 332 if (mainClass == null) { 333 throw new IllegalStateException( 334 "No '" + MAIN + "' or 'Start-Class' specified"); 335 } 336 return mainClass; 337 } 338 339 @Override 340 protected ClassLoader createClassLoader(List<Archive> archives) throws Exception { 341 Set<URL> urls = new LinkedHashSet<>(archives.size()); 342 for (Archive archive : archives) { 343 urls.add(archive.getUrl()); 344 } 345 ClassLoader loader = new LaunchedURLClassLoader(urls.toArray(new URL[0]), 346 getClass().getClassLoader()); 347 debug("Classpath: " + urls); 348 String customLoaderClassName = getProperty("loader.classLoader"); 349 if (customLoaderClassName != null) { 350 loader = wrapWithCustomClassLoader(loader, customLoaderClassName); 351 debug("Using custom class loader: " + customLoaderClassName); 352 } 353 return loader; 354 } 355 356 @SuppressWarnings("unchecked") 357 private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, 358 String loaderClassName) throws Exception { 359 Class<ClassLoader> loaderClass = (Class<ClassLoader>) Class 360 .forName(loaderClassName, true, parent); 361 362 try { 363 return loaderClass.getConstructor(ClassLoader.class).newInstance(parent); 364 } 365 catch (NoSuchMethodException ex) { 366 // Ignore and try with URLs 367 } 368 try { 369 return loaderClass.getConstructor(URL[].class, ClassLoader.class) 370 .newInstance(new URL[0], parent); 371 } 372 catch (NoSuchMethodException ex) { 373 // Ignore and try without any arguments 374 } 375 return loaderClass.newInstance(); 376 } 377 378 private String getProperty(String propertyKey) throws Exception { 379 return getProperty(propertyKey, null, null); 380 } 381 382 private String getProperty(String propertyKey, String manifestKey) throws Exception { 383 return getProperty(propertyKey, manifestKey, null); 384 } 385 386 private String getPropertyWithDefault(String propertyKey, String defaultValue) 387 throws Exception { 388 return getProperty(propertyKey, null, defaultValue); 389 } 390 391 private String getProperty(String propertyKey, String manifestKey, 392 String defaultValue) throws Exception { 393 if (manifestKey == null) { 394 manifestKey = propertyKey.replace('.', '-'); 395 manifestKey = toCamelCase(manifestKey); 396 } 397 String property = SystemPropertyUtils.getProperty(propertyKey); 398 if (property != null) { 399 String value = SystemPropertyUtils.resolvePlaceholders(this.properties, 400 property); 401 debug("Property '" + propertyKey + "' from environment: " + value); 402 return value; 403 } 404 if (this.properties.containsKey(propertyKey)) { 405 String value = SystemPropertyUtils.resolvePlaceholders(this.properties, 406 this.properties.getProperty(propertyKey)); 407 debug("Property '" + propertyKey + "' from properties: " + value); 408 return value; 409 } 410 try { 411 if (this.home != null) { 412 // Prefer home dir for MANIFEST if there is one 413 Manifest manifest = new ExplodedArchive(this.home, false).getManifest(); 414 if (manifest != null) { 415 String value = manifest.getMainAttributes().getValue(manifestKey); 416 if (value != null) { 417 debug("Property '" + manifestKey 418 + "' from home directory manifest: " + value); 419 return SystemPropertyUtils.resolvePlaceholders(this.properties, 420 value); 421 } 422 } 423 } 424 } 425 catch (IllegalStateException ex) { 426 // Ignore 427 } 428 // Otherwise try the parent archive 429 Manifest manifest = createArchive().getManifest(); 430 if (manifest != null) { 431 String value = manifest.getMainAttributes().getValue(manifestKey); 432 if (value != null) { 433 debug("Property '" + manifestKey + "' from archive manifest: " + value); 434 return SystemPropertyUtils.resolvePlaceholders(this.properties, value); 435 } 436 } 437 return (defaultValue != null) 438 ? SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue) 439 : defaultValue; 440 } 441 442 @Override 443 protected List<Archive> getClassPathArchives() throws Exception { 444 List<Archive> lib = new ArrayList<>(); 445 for (String path : this.paths) { 446 for (Archive archive : getClassPathArchives(path)) { 447 if (archive instanceof ExplodedArchive) { 448 List<Archive> nested = new ArrayList<>( 449 archive.getNestedArchives(new ArchiveEntryFilter())); 450 nested.add(0, archive); 451 lib.addAll(nested); 452 } 453 else { 454 lib.add(archive); 455 } 456 } 457 } 458 addNestedEntries(lib); 459 return lib; 460 } 461 462 private List<Archive> getClassPathArchives(String path) throws Exception { 463 String root = cleanupPath(handleUrl(path)); 464 List<Archive> lib = new ArrayList<>(); 465 File file = new File(root); 466 if (!"/".equals(root)) { 467 if (!isAbsolutePath(root)) { 468 file = new File(this.home, root); 469 } 470 if (file.isDirectory()) { 471 debug("Adding classpath entries from " + file); 472 Archive archive = new ExplodedArchive(file, false); 473 lib.add(archive); 474 } 475 } 476 Archive archive = getArchive(file); 477 if (archive != null) { 478 debug("Adding classpath entries from archive " + archive.getUrl() + root); 479 lib.add(archive); 480 } 481 List<Archive> nestedArchives = getNestedArchives(root); 482 if (nestedArchives != null) { 483 debug("Adding classpath entries from nested " + root); 484 lib.addAll(nestedArchives); 485 } 486 return lib; 487 } 488 489 private boolean isAbsolutePath(String root) { 490 // Windows contains ":" others start with "/" 491 return root.contains(":") || root.startsWith("/"); 492 } 493 494 private Archive getArchive(File file) throws IOException { 495 if (isNestedArchivePath(file)) { 496 return null; 497 } 498 String name = file.getName().toLowerCase(Locale.ENGLISH); 499 if (name.endsWith(".jar") || name.endsWith(".zip")) { 500 return new JarFileArchive(file); 501 } 502 return null; 503 } 504 505 private boolean isNestedArchivePath(File file) { 506 return file.getPath().contains(NESTED_ARCHIVE_SEPARATOR); 507 } 508 509 private List<Archive> getNestedArchives(String path) throws Exception { 510 Archive parent = this.parent; 511 String root = path; 512 if (!root.equals("/") && root.startsWith("/") 513 || parent.getUrl().equals(this.home.toURI().toURL())) { 514 // If home dir is same as parent archive, no need to add it twice. 515 return null; 516 } 517 int index = root.indexOf('!'); 518 if (index != -1) { 519 File file = new File(this.home, root.substring(0, index)); 520 if (root.startsWith("jar:file:")) { 521 file = new File(root.substring("jar:file:".length(), index)); 522 } 523 parent = new JarFileArchive(file); 524 root = root.substring(index + 1); 525 while (root.startsWith("/")) { 526 root = root.substring(1); 527 } 528 } 529 if (root.endsWith(".jar")) { 530 File file = new File(this.home, root); 531 if (file.exists()) { 532 parent = new JarFileArchive(file); 533 root = ""; 534 } 535 } 536 if (root.equals("/") || root.equals("./") || root.equals(".")) { 537 // The prefix for nested jars is actually empty if it's at the root 538 root = ""; 539 } 540 EntryFilter filter = new PrefixMatchingArchiveFilter(root); 541 List<Archive> archives = new ArrayList<>(parent.getNestedArchives(filter)); 542 if (("".equals(root) || ".".equals(root)) && !path.endsWith(".jar") 543 && parent != this.parent) { 544 // You can't find the root with an entry filter so it has to be added 545 // explicitly. But don't add the root of the parent archive. 546 archives.add(parent); 547 } 548 return archives; 549 } 550 551 private void addNestedEntries(List<Archive> lib) { 552 // The parent archive might have "BOOT-INF/lib/" and "BOOT-INF/classes/" 553 // directories, meaning we are running from an executable JAR. We add nested 554 // entries from there with low priority (i.e. at end). 555 try { 556 lib.addAll(this.parent.getNestedArchives((entry) -> { 557 if (entry.isDirectory()) { 558 return entry.getName().equals(JarLauncher.BOOT_INF_CLASSES); 559 } 560 return entry.getName().startsWith(JarLauncher.BOOT_INF_LIB); 561 })); 562 } 563 catch (IOException ex) { 564 // Ignore 565 } 566 } 567 568 private String cleanupPath(String path) { 569 path = path.trim(); 570 // No need for current dir path 571 if (path.startsWith("./")) { 572 path = path.substring(2); 573 } 574 String lowerCasePath = path.toLowerCase(Locale.ENGLISH); 575 if (lowerCasePath.endsWith(".jar") || lowerCasePath.endsWith(".zip")) { 576 return path; 577 } 578 if (path.endsWith("/*")) { 579 path = path.substring(0, path.length() - 1); 580 } 581 else { 582 // It's a directory 583 if (!path.endsWith("/") && !path.equals(".")) { 584 path = path + "/"; 585 } 586 } 587 return path; 588 } 589 590 public static void main(String[] args) throws Exception { 591 PropertiesLauncher launcher = new PropertiesLauncher(); 592 args = launcher.getArgs(args); 593 launcher.launch(args); 594 } 595 596 public static String toCamelCase(CharSequence string) { 597 if (string == null) { 598 return null; 599 } 600 StringBuilder builder = new StringBuilder(); 601 Matcher matcher = WORD_SEPARATOR.matcher(string); 602 int pos = 0; 603 while (matcher.find()) { 604 builder.append(capitalize(string.subSequence(pos, matcher.end()).toString())); 605 pos = matcher.end(); 606 } 607 builder.append(capitalize(string.subSequence(pos, string.length()).toString())); 608 return builder.toString(); 609 } 610 611 private static String capitalize(String str) { 612 return Character.toUpperCase(str.charAt(0)) + str.substring(1); 613 } 614 615 private void debug(String message) { 616 if (Boolean.getBoolean(DEBUG)) { 617 System.out.println(message); 618 } 619 } 620 621 /** 622 * Convenience class for finding nested archives that have a prefix in their file path 623 * (e.g. "lib/"). 624 */ 625 private static final class PrefixMatchingArchiveFilter implements EntryFilter { 626 627 private final String prefix; 628 629 private final ArchiveEntryFilter filter = new ArchiveEntryFilter(); 630 631 private PrefixMatchingArchiveFilter(String prefix) { 632 this.prefix = prefix; 633 } 634 635 @Override 636 public boolean matches(Entry entry) { 637 if (entry.isDirectory()) { 638 return entry.getName().equals(this.prefix); 639 } 640 return entry.getName().startsWith(this.prefix) && this.filter.matches(entry); 641 } 642 643 } 644 645 /** 646 * Convenience class for finding nested archives (archive entries that can be 647 * classpath entries). 648 */ 649 private static final class ArchiveEntryFilter implements EntryFilter { 650 651 private static final String DOT_JAR = ".jar"; 652 653 private static final String DOT_ZIP = ".zip"; 654 655 @Override 656 public boolean matches(Entry entry) { 657 return entry.getName().endsWith(DOT_JAR) || entry.getName().endsWith(DOT_ZIP); 658 } 659 660 } 661 662}