001/* 002 * Copyright 2002-2019 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 * https://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.context.support; 018 019import java.io.IOException; 020import java.io.InputStream; 021import java.io.InputStreamReader; 022import java.text.MessageFormat; 023import java.util.ArrayList; 024import java.util.List; 025import java.util.Locale; 026import java.util.Map; 027import java.util.Properties; 028import java.util.concurrent.ConcurrentHashMap; 029import java.util.concurrent.ConcurrentMap; 030import java.util.concurrent.locks.ReentrantLock; 031 032import org.springframework.context.ResourceLoaderAware; 033import org.springframework.core.io.DefaultResourceLoader; 034import org.springframework.core.io.Resource; 035import org.springframework.core.io.ResourceLoader; 036import org.springframework.lang.Nullable; 037import org.springframework.util.DefaultPropertiesPersister; 038import org.springframework.util.PropertiesPersister; 039import org.springframework.util.StringUtils; 040 041/** 042 * Spring-specific {@link org.springframework.context.MessageSource} implementation 043 * that accesses resource bundles using specified basenames, participating in the 044 * Spring {@link org.springframework.context.ApplicationContext}'s resource loading. 045 * 046 * <p>In contrast to the JDK-based {@link ResourceBundleMessageSource}, this class uses 047 * {@link java.util.Properties} instances as its custom data structure for messages, 048 * loading them via a {@link org.springframework.util.PropertiesPersister} strategy 049 * from Spring {@link Resource} handles. This strategy is not only capable of 050 * reloading files based on timestamp changes, but also of loading properties files 051 * with a specific character encoding. It will detect XML property files as well. 052 * 053 * <p>Note that the basenames set as {@link #setBasenames "basenames"} property 054 * are treated in a slightly different fashion than the "basenames" property of 055 * {@link ResourceBundleMessageSource}. It follows the basic ResourceBundle rule of not 056 * specifying file extension or language codes, but can refer to any Spring resource 057 * location (instead of being restricted to classpath resources). With a "classpath:" 058 * prefix, resources can still be loaded from the classpath, but "cacheSeconds" values 059 * other than "-1" (caching forever) might not work reliably in this case. 060 * 061 * <p>For a typical web application, message files could be placed in {@code WEB-INF}: 062 * e.g. a "WEB-INF/messages" basename would find a "WEB-INF/messages.properties", 063 * "WEB-INF/messages_en.properties" etc arrangement as well as "WEB-INF/messages.xml", 064 * "WEB-INF/messages_en.xml" etc. Note that message definitions in a <i>previous</i> 065 * resource bundle will override ones in a later bundle, due to sequential lookup. 066 067 * <p>This MessageSource can easily be used outside of an 068 * {@link org.springframework.context.ApplicationContext}: it will use a 069 * {@link org.springframework.core.io.DefaultResourceLoader} as default, 070 * simply getting overridden with the ApplicationContext's resource loader 071 * if running in a context. It does not have any other specific dependencies. 072 * 073 * <p>Thanks to Thomas Achleitner for providing the initial implementation of 074 * this message source! 075 * 076 * @author Juergen Hoeller 077 * @see #setCacheSeconds 078 * @see #setBasenames 079 * @see #setDefaultEncoding 080 * @see #setFileEncodings 081 * @see #setPropertiesPersister 082 * @see #setResourceLoader 083 * @see org.springframework.util.DefaultPropertiesPersister 084 * @see org.springframework.core.io.DefaultResourceLoader 085 * @see ResourceBundleMessageSource 086 * @see java.util.ResourceBundle 087 */ 088public class ReloadableResourceBundleMessageSource extends AbstractResourceBasedMessageSource 089 implements ResourceLoaderAware { 090 091 private static final String PROPERTIES_SUFFIX = ".properties"; 092 093 private static final String XML_SUFFIX = ".xml"; 094 095 096 @Nullable 097 private Properties fileEncodings; 098 099 private boolean concurrentRefresh = true; 100 101 private PropertiesPersister propertiesPersister = new DefaultPropertiesPersister(); 102 103 private ResourceLoader resourceLoader = new DefaultResourceLoader(); 104 105 // Cache to hold filename lists per Locale 106 private final ConcurrentMap<String, Map<Locale, List<String>>> cachedFilenames = new ConcurrentHashMap<>(); 107 108 // Cache to hold already loaded properties per filename 109 private final ConcurrentMap<String, PropertiesHolder> cachedProperties = new ConcurrentHashMap<>(); 110 111 // Cache to hold already loaded properties per filename 112 private final ConcurrentMap<Locale, PropertiesHolder> cachedMergedProperties = new ConcurrentHashMap<>(); 113 114 115 /** 116 * Set per-file charsets to use for parsing properties files. 117 * <p>Only applies to classic properties files, not to XML files. 118 * @param fileEncodings a Properties with filenames as keys and charset 119 * names as values. Filenames have to match the basename syntax, 120 * with optional locale-specific components: e.g. "WEB-INF/messages" 121 * or "WEB-INF/messages_en". 122 * @see #setBasenames 123 * @see org.springframework.util.PropertiesPersister#load 124 */ 125 public void setFileEncodings(Properties fileEncodings) { 126 this.fileEncodings = fileEncodings; 127 } 128 129 /** 130 * Specify whether to allow for concurrent refresh behavior, i.e. one thread 131 * locked in a refresh attempt for a specific cached properties file whereas 132 * other threads keep returning the old properties for the time being, until 133 * the refresh attempt has completed. 134 * <p>Default is "true": this behavior is new as of Spring Framework 4.1, 135 * minimizing contention between threads. If you prefer the old behavior, 136 * i.e. to fully block on refresh, switch this flag to "false". 137 * @since 4.1 138 * @see #setCacheSeconds 139 */ 140 public void setConcurrentRefresh(boolean concurrentRefresh) { 141 this.concurrentRefresh = concurrentRefresh; 142 } 143 144 /** 145 * Set the PropertiesPersister to use for parsing properties files. 146 * <p>The default is a DefaultPropertiesPersister. 147 * @see org.springframework.util.DefaultPropertiesPersister 148 */ 149 public void setPropertiesPersister(@Nullable PropertiesPersister propertiesPersister) { 150 this.propertiesPersister = 151 (propertiesPersister != null ? propertiesPersister : new DefaultPropertiesPersister()); 152 } 153 154 /** 155 * Set the ResourceLoader to use for loading bundle properties files. 156 * <p>The default is a DefaultResourceLoader. Will get overridden by the 157 * ApplicationContext if running in a context, as it implements the 158 * ResourceLoaderAware interface. Can be manually overridden when 159 * running outside of an ApplicationContext. 160 * @see org.springframework.core.io.DefaultResourceLoader 161 * @see org.springframework.context.ResourceLoaderAware 162 */ 163 @Override 164 public void setResourceLoader(@Nullable ResourceLoader resourceLoader) { 165 this.resourceLoader = (resourceLoader != null ? resourceLoader : new DefaultResourceLoader()); 166 } 167 168 169 /** 170 * Resolves the given message code as key in the retrieved bundle files, 171 * returning the value found in the bundle as-is (without MessageFormat parsing). 172 */ 173 @Override 174 protected String resolveCodeWithoutArguments(String code, Locale locale) { 175 if (getCacheMillis() < 0) { 176 PropertiesHolder propHolder = getMergedProperties(locale); 177 String result = propHolder.getProperty(code); 178 if (result != null) { 179 return result; 180 } 181 } 182 else { 183 for (String basename : getBasenameSet()) { 184 List<String> filenames = calculateAllFilenames(basename, locale); 185 for (String filename : filenames) { 186 PropertiesHolder propHolder = getProperties(filename); 187 String result = propHolder.getProperty(code); 188 if (result != null) { 189 return result; 190 } 191 } 192 } 193 } 194 return null; 195 } 196 197 /** 198 * Resolves the given message code as key in the retrieved bundle files, 199 * using a cached MessageFormat instance per message code. 200 */ 201 @Override 202 @Nullable 203 protected MessageFormat resolveCode(String code, Locale locale) { 204 if (getCacheMillis() < 0) { 205 PropertiesHolder propHolder = getMergedProperties(locale); 206 MessageFormat result = propHolder.getMessageFormat(code, locale); 207 if (result != null) { 208 return result; 209 } 210 } 211 else { 212 for (String basename : getBasenameSet()) { 213 List<String> filenames = calculateAllFilenames(basename, locale); 214 for (String filename : filenames) { 215 PropertiesHolder propHolder = getProperties(filename); 216 MessageFormat result = propHolder.getMessageFormat(code, locale); 217 if (result != null) { 218 return result; 219 } 220 } 221 } 222 } 223 return null; 224 } 225 226 227 /** 228 * Get a PropertiesHolder that contains the actually visible properties 229 * for a Locale, after merging all specified resource bundles. 230 * Either fetches the holder from the cache or freshly loads it. 231 * <p>Only used when caching resource bundle contents forever, i.e. 232 * with cacheSeconds < 0. Therefore, merged properties are always 233 * cached forever. 234 */ 235 protected PropertiesHolder getMergedProperties(Locale locale) { 236 PropertiesHolder mergedHolder = this.cachedMergedProperties.get(locale); 237 if (mergedHolder != null) { 238 return mergedHolder; 239 } 240 241 Properties mergedProps = newProperties(); 242 long latestTimestamp = -1; 243 String[] basenames = StringUtils.toStringArray(getBasenameSet()); 244 for (int i = basenames.length - 1; i >= 0; i--) { 245 List<String> filenames = calculateAllFilenames(basenames[i], locale); 246 for (int j = filenames.size() - 1; j >= 0; j--) { 247 String filename = filenames.get(j); 248 PropertiesHolder propHolder = getProperties(filename); 249 if (propHolder.getProperties() != null) { 250 mergedProps.putAll(propHolder.getProperties()); 251 if (propHolder.getFileTimestamp() > latestTimestamp) { 252 latestTimestamp = propHolder.getFileTimestamp(); 253 } 254 } 255 } 256 } 257 258 mergedHolder = new PropertiesHolder(mergedProps, latestTimestamp); 259 PropertiesHolder existing = this.cachedMergedProperties.putIfAbsent(locale, mergedHolder); 260 if (existing != null) { 261 mergedHolder = existing; 262 } 263 return mergedHolder; 264 } 265 266 /** 267 * Calculate all filenames for the given bundle basename and Locale. 268 * Will calculate filenames for the given Locale, the system Locale 269 * (if applicable), and the default file. 270 * @param basename the basename of the bundle 271 * @param locale the locale 272 * @return the List of filenames to check 273 * @see #setFallbackToSystemLocale 274 * @see #calculateFilenamesForLocale 275 */ 276 protected List<String> calculateAllFilenames(String basename, Locale locale) { 277 Map<Locale, List<String>> localeMap = this.cachedFilenames.get(basename); 278 if (localeMap != null) { 279 List<String> filenames = localeMap.get(locale); 280 if (filenames != null) { 281 return filenames; 282 } 283 } 284 285 // Filenames for given Locale 286 List<String> filenames = new ArrayList<>(7); 287 filenames.addAll(calculateFilenamesForLocale(basename, locale)); 288 289 // Filenames for default Locale, if any 290 Locale defaultLocale = getDefaultLocale(); 291 if (defaultLocale != null && !defaultLocale.equals(locale)) { 292 List<String> fallbackFilenames = calculateFilenamesForLocale(basename, defaultLocale); 293 for (String fallbackFilename : fallbackFilenames) { 294 if (!filenames.contains(fallbackFilename)) { 295 // Entry for fallback locale that isn't already in filenames list. 296 filenames.add(fallbackFilename); 297 } 298 } 299 } 300 301 // Filename for default bundle file 302 filenames.add(basename); 303 304 if (localeMap == null) { 305 localeMap = new ConcurrentHashMap<>(); 306 Map<Locale, List<String>> existing = this.cachedFilenames.putIfAbsent(basename, localeMap); 307 if (existing != null) { 308 localeMap = existing; 309 } 310 } 311 localeMap.put(locale, filenames); 312 return filenames; 313 } 314 315 /** 316 * Calculate the filenames for the given bundle basename and Locale, 317 * appending language code, country code, and variant code. 318 * E.g.: basename "messages", Locale "de_AT_oo" -> "messages_de_AT_OO", 319 * "messages_de_AT", "messages_de". 320 * <p>Follows the rules defined by {@link java.util.Locale#toString()}. 321 * @param basename the basename of the bundle 322 * @param locale the locale 323 * @return the List of filenames to check 324 */ 325 protected List<String> calculateFilenamesForLocale(String basename, Locale locale) { 326 List<String> result = new ArrayList<>(3); 327 String language = locale.getLanguage(); 328 String country = locale.getCountry(); 329 String variant = locale.getVariant(); 330 StringBuilder temp = new StringBuilder(basename); 331 332 temp.append('_'); 333 if (language.length() > 0) { 334 temp.append(language); 335 result.add(0, temp.toString()); 336 } 337 338 temp.append('_'); 339 if (country.length() > 0) { 340 temp.append(country); 341 result.add(0, temp.toString()); 342 } 343 344 if (variant.length() > 0 && (language.length() > 0 || country.length() > 0)) { 345 temp.append('_').append(variant); 346 result.add(0, temp.toString()); 347 } 348 349 return result; 350 } 351 352 353 /** 354 * Get a PropertiesHolder for the given filename, either from the 355 * cache or freshly loaded. 356 * @param filename the bundle filename (basename + Locale) 357 * @return the current PropertiesHolder for the bundle 358 */ 359 protected PropertiesHolder getProperties(String filename) { 360 PropertiesHolder propHolder = this.cachedProperties.get(filename); 361 long originalTimestamp = -2; 362 363 if (propHolder != null) { 364 originalTimestamp = propHolder.getRefreshTimestamp(); 365 if (originalTimestamp == -1 || originalTimestamp > System.currentTimeMillis() - getCacheMillis()) { 366 // Up to date 367 return propHolder; 368 } 369 } 370 else { 371 propHolder = new PropertiesHolder(); 372 PropertiesHolder existingHolder = this.cachedProperties.putIfAbsent(filename, propHolder); 373 if (existingHolder != null) { 374 propHolder = existingHolder; 375 } 376 } 377 378 // At this point, we need to refresh... 379 if (this.concurrentRefresh && propHolder.getRefreshTimestamp() >= 0) { 380 // A populated but stale holder -> could keep using it. 381 if (!propHolder.refreshLock.tryLock()) { 382 // Getting refreshed by another thread already -> 383 // let's return the existing properties for the time being. 384 return propHolder; 385 } 386 } 387 else { 388 propHolder.refreshLock.lock(); 389 } 390 try { 391 PropertiesHolder existingHolder = this.cachedProperties.get(filename); 392 if (existingHolder != null && existingHolder.getRefreshTimestamp() > originalTimestamp) { 393 return existingHolder; 394 } 395 return refreshProperties(filename, propHolder); 396 } 397 finally { 398 propHolder.refreshLock.unlock(); 399 } 400 } 401 402 /** 403 * Refresh the PropertiesHolder for the given bundle filename. 404 * The holder can be {@code null} if not cached before, or a timed-out cache entry 405 * (potentially getting re-validated against the current last-modified timestamp). 406 * @param filename the bundle filename (basename + Locale) 407 * @param propHolder the current PropertiesHolder for the bundle 408 */ 409 protected PropertiesHolder refreshProperties(String filename, @Nullable PropertiesHolder propHolder) { 410 long refreshTimestamp = (getCacheMillis() < 0 ? -1 : System.currentTimeMillis()); 411 412 Resource resource = this.resourceLoader.getResource(filename + PROPERTIES_SUFFIX); 413 if (!resource.exists()) { 414 resource = this.resourceLoader.getResource(filename + XML_SUFFIX); 415 } 416 417 if (resource.exists()) { 418 long fileTimestamp = -1; 419 if (getCacheMillis() >= 0) { 420 // Last-modified timestamp of file will just be read if caching with timeout. 421 try { 422 fileTimestamp = resource.lastModified(); 423 if (propHolder != null && propHolder.getFileTimestamp() == fileTimestamp) { 424 if (logger.isDebugEnabled()) { 425 logger.debug("Re-caching properties for filename [" + filename + "] - file hasn't been modified"); 426 } 427 propHolder.setRefreshTimestamp(refreshTimestamp); 428 return propHolder; 429 } 430 } 431 catch (IOException ex) { 432 // Probably a class path resource: cache it forever. 433 if (logger.isDebugEnabled()) { 434 logger.debug(resource + " could not be resolved in the file system - assuming that it hasn't changed", ex); 435 } 436 fileTimestamp = -1; 437 } 438 } 439 try { 440 Properties props = loadProperties(resource, filename); 441 propHolder = new PropertiesHolder(props, fileTimestamp); 442 } 443 catch (IOException ex) { 444 if (logger.isWarnEnabled()) { 445 logger.warn("Could not parse properties file [" + resource.getFilename() + "]", ex); 446 } 447 // Empty holder representing "not valid". 448 propHolder = new PropertiesHolder(); 449 } 450 } 451 452 else { 453 // Resource does not exist. 454 if (logger.isDebugEnabled()) { 455 logger.debug("No properties file found for [" + filename + "] - neither plain properties nor XML"); 456 } 457 // Empty holder representing "not found". 458 propHolder = new PropertiesHolder(); 459 } 460 461 propHolder.setRefreshTimestamp(refreshTimestamp); 462 this.cachedProperties.put(filename, propHolder); 463 return propHolder; 464 } 465 466 /** 467 * Load the properties from the given resource. 468 * @param resource the resource to load from 469 * @param filename the original bundle filename (basename + Locale) 470 * @return the populated Properties instance 471 * @throws IOException if properties loading failed 472 */ 473 protected Properties loadProperties(Resource resource, String filename) throws IOException { 474 Properties props = newProperties(); 475 try (InputStream is = resource.getInputStream()) { 476 String resourceFilename = resource.getFilename(); 477 if (resourceFilename != null && resourceFilename.endsWith(XML_SUFFIX)) { 478 if (logger.isDebugEnabled()) { 479 logger.debug("Loading properties [" + resource.getFilename() + "]"); 480 } 481 this.propertiesPersister.loadFromXml(props, is); 482 } 483 else { 484 String encoding = null; 485 if (this.fileEncodings != null) { 486 encoding = this.fileEncodings.getProperty(filename); 487 } 488 if (encoding == null) { 489 encoding = getDefaultEncoding(); 490 } 491 if (encoding != null) { 492 if (logger.isDebugEnabled()) { 493 logger.debug("Loading properties [" + resource.getFilename() + "] with encoding '" + encoding + "'"); 494 } 495 this.propertiesPersister.load(props, new InputStreamReader(is, encoding)); 496 } 497 else { 498 if (logger.isDebugEnabled()) { 499 logger.debug("Loading properties [" + resource.getFilename() + "]"); 500 } 501 this.propertiesPersister.load(props, is); 502 } 503 } 504 return props; 505 } 506 } 507 508 /** 509 * Template method for creating a plain new {@link Properties} instance. 510 * The default implementation simply calls {@link Properties#Properties()}. 511 * <p>Allows for returning a custom {@link Properties} extension in subclasses. 512 * Overriding methods should just instantiate a custom {@link Properties} subclass, 513 * with no further initialization or population to be performed at that point. 514 * @return a plain Properties instance 515 * @since 4.2 516 */ 517 protected Properties newProperties() { 518 return new Properties(); 519 } 520 521 522 /** 523 * Clear the resource bundle cache. 524 * Subsequent resolve calls will lead to reloading of the properties files. 525 */ 526 public void clearCache() { 527 logger.debug("Clearing entire resource bundle cache"); 528 this.cachedProperties.clear(); 529 this.cachedMergedProperties.clear(); 530 } 531 532 /** 533 * Clear the resource bundle caches of this MessageSource and all its ancestors. 534 * @see #clearCache 535 */ 536 public void clearCacheIncludingAncestors() { 537 clearCache(); 538 if (getParentMessageSource() instanceof ReloadableResourceBundleMessageSource) { 539 ((ReloadableResourceBundleMessageSource) getParentMessageSource()).clearCacheIncludingAncestors(); 540 } 541 } 542 543 544 @Override 545 public String toString() { 546 return getClass().getName() + ": basenames=" + getBasenameSet(); 547 } 548 549 550 /** 551 * PropertiesHolder for caching. 552 * Stores the last-modified timestamp of the source file for efficient 553 * change detection, and the timestamp of the last refresh attempt 554 * (updated every time the cache entry gets re-validated). 555 */ 556 protected class PropertiesHolder { 557 558 @Nullable 559 private final Properties properties; 560 561 private final long fileTimestamp; 562 563 private volatile long refreshTimestamp = -2; 564 565 private final ReentrantLock refreshLock = new ReentrantLock(); 566 567 /** Cache to hold already generated MessageFormats per message code. */ 568 private final ConcurrentMap<String, Map<Locale, MessageFormat>> cachedMessageFormats = 569 new ConcurrentHashMap<>(); 570 571 public PropertiesHolder() { 572 this.properties = null; 573 this.fileTimestamp = -1; 574 } 575 576 public PropertiesHolder(Properties properties, long fileTimestamp) { 577 this.properties = properties; 578 this.fileTimestamp = fileTimestamp; 579 } 580 581 @Nullable 582 public Properties getProperties() { 583 return this.properties; 584 } 585 586 public long getFileTimestamp() { 587 return this.fileTimestamp; 588 } 589 590 public void setRefreshTimestamp(long refreshTimestamp) { 591 this.refreshTimestamp = refreshTimestamp; 592 } 593 594 public long getRefreshTimestamp() { 595 return this.refreshTimestamp; 596 } 597 598 @Nullable 599 public String getProperty(String code) { 600 if (this.properties == null) { 601 return null; 602 } 603 return this.properties.getProperty(code); 604 } 605 606 @Nullable 607 public MessageFormat getMessageFormat(String code, Locale locale) { 608 if (this.properties == null) { 609 return null; 610 } 611 Map<Locale, MessageFormat> localeMap = this.cachedMessageFormats.get(code); 612 if (localeMap != null) { 613 MessageFormat result = localeMap.get(locale); 614 if (result != null) { 615 return result; 616 } 617 } 618 String msg = this.properties.getProperty(code); 619 if (msg != null) { 620 if (localeMap == null) { 621 localeMap = new ConcurrentHashMap<>(); 622 Map<Locale, MessageFormat> existing = this.cachedMessageFormats.putIfAbsent(code, localeMap); 623 if (existing != null) { 624 localeMap = existing; 625 } 626 } 627 MessageFormat result = createMessageFormat(msg, locale); 628 localeMap.put(locale, result); 629 return result; 630 } 631 return null; 632 } 633 } 634 635}