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