1 /*
2 * Copyright 2002-2008 the original author or authors.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package org.springframework.context.support;
18
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.InputStreamReader;
22 import java.text.MessageFormat;
23 import java.util.ArrayList;
24 import java.util.HashMap;
25 import java.util.Iterator;
26 import java.util.List;
27 import java.util.Locale;
28 import java.util.Map;
29 import java.util.Properties;
30
31 import org.springframework.context.ResourceLoaderAware;
32 import org.springframework.core.io.DefaultResourceLoader;
33 import org.springframework.core.io.Resource;
34 import org.springframework.core.io.ResourceLoader;
35 import org.springframework.util.Assert;
36 import org.springframework.util.DefaultPropertiesPersister;
37 import org.springframework.util.PropertiesPersister;
38 import org.springframework.util.StringUtils;
39
40 /**
41 * {@link org.springframework.context.MessageSource} implementation that
42 * accesses resource bundles using specified basenames. This class uses
43 * {@link java.util.Properties} instances as its custom data structure for
44 * messages, loading them via a {@link org.springframework.util.PropertiesPersister}
45 * strategy: The default strategy is capable of loading properties files
46 * with a specific character encoding, if desired.
47 *
48 * <p>In contrast to {@link ResourceBundleMessageSource}, this class supports
49 * reloading of properties files through the {@link #setCacheSeconds "cacheSeconds"}
50 * setting, and also through programmatically clearing the properties cache.
51 * Since application servers typically cache all files loaded from the classpath,
52 * it is necessary to store resources somewhere else (for example, in the
53 * "WEB-INF" directory of a web app). Otherwise changes of files in the
54 * classpath will <i>not</i> be reflected in the application.
55 *
56 * <p>Note that the base names set as {@link #setBasenames "basenames"} property
57 * are treated in a slightly different fashion than the "basenames" property of
58 * {@link ResourceBundleMessageSource}. It follows the basic ResourceBundle rule of not
59 * specifying file extension or language codes, but can refer to any Spring resource
60 * location (instead of being restricted to classpath resources). With a "classpath:"
61 * prefix, resources can still be loaded from the classpath, but "cacheSeconds" values
62 * other than "-1" (caching forever) will not work in this case.
63 *
64 * <p>This MessageSource implementation is usually slightly faster than
65 * {@link ResourceBundleMessageSource}, which builds on {@link java.util.ResourceBundle}
66 * - in the default mode, i.e. when caching forever. With "cacheSeconds" set to 1,
67 * message lookup takes about twice as long - with the benefit that changes in
68 * individual properties files are detected with a maximum delay of 1 second.
69 * Higher "cacheSeconds" values usually <i>do not</i> make a significant difference.
70 *
71 * <p>This MessageSource can easily be used outside of an
72 * {@link org.springframework.context.ApplicationContext}: It will use a
73 * {@link org.springframework.core.io.DefaultResourceLoader} as default,
74 * simply getting overridden with the ApplicationContext's resource loader
75 * if running in a context. It does not have any other specific dependencies.
76 *
77 * <p>Thanks to Thomas Achleitner for providing the initial implementation of
78 * this message source!
79 *
80 * @author Juergen Hoeller
81 * @see #setCacheSeconds
82 * @see #setBasenames
83 * @see #setDefaultEncoding
84 * @see #setFileEncodings
85 * @see #setPropertiesPersister
86 * @see #setResourceLoader
87 * @see org.springframework.util.DefaultPropertiesPersister
88 * @see org.springframework.core.io.DefaultResourceLoader
89 * @see ResourceBundleMessageSource
90 * @see java.util.ResourceBundle
91 */
92 public class ReloadableResourceBundleMessageSource extends AbstractMessageSource
93 implements ResourceLoaderAware {
94
95 private static final String PROPERTIES_SUFFIX = ".properties";
96
97 private static final String XML_SUFFIX = ".xml";
98
99
100 private String[] basenames = new String[0];
101
102 private String defaultEncoding;
103
104 private Properties fileEncodings;
105
106 private boolean fallbackToSystemLocale = true;
107
108 private long cacheMillis = -1;
109
110 private PropertiesPersister propertiesPersister = new DefaultPropertiesPersister();
111
112 private ResourceLoader resourceLoader = new DefaultResourceLoader();
113
114 /** Cache to hold filename lists per Locale */
115 private final Map cachedFilenames = new HashMap();
116
117 /** Cache to hold already loaded properties per filename */
118 private final Map cachedProperties = new HashMap();
119
120 /** Cache to hold merged loaded properties per basename */
121 private final Map cachedMergedProperties = new HashMap();
122
123
124 /**
125 * Set a single basename, following the basic ResourceBundle convention of
126 * not specifying file extension or language codes, but in contrast to
127 * {@link ResourceBundleMessageSource} referring to a Spring resource location:
128 * e.g. "WEB-INF/messages" for "WEB-INF/messages.properties",
129 * "WEB-INF/messages_en.properties", etc.
130 * <p>As of Spring 1.2.2, XML properties files are also supported:
131 * e.g. "WEB-INF/messages" will find and load "WEB-INF/messages.xml",
132 * "WEB-INF/messages_en.xml", etc as well. Note that this will only
133 * work on JDK 1.5+.
134 * @param basename the single basename
135 * @see #setBasenames
136 * @see org.springframework.core.io.ResourceEditor
137 * @see java.util.ResourceBundle
138 */
139 public void setBasename(String basename) {
140 setBasenames(new String[] {basename});
141 }
142
143 /**
144 * Set an array of basenames, each following the basic ResourceBundle convention
145 * of not specifying file extension or language codes, but in contrast to
146 * {@link ResourceBundleMessageSource} referring to a Spring resource location:
147 * e.g. "WEB-INF/messages" for "WEB-INF/messages.properties",
148 * "WEB-INF/messages_en.properties", etc.
149 * <p>As of Spring 1.2.2, XML properties files are also supported:
150 * e.g. "WEB-INF/messages" will find and load "WEB-INF/messages.xml",
151 * "WEB-INF/messages_en.xml", etc as well. Note that this will only
152 * work on JDK 1.5+.
153 * <p>The associated resource bundles will be checked sequentially
154 * when resolving a message code. Note that message definitions in a
155 * <i>previous</i> resource bundle will override ones in a later bundle,
156 * due to the sequential lookup.
157 * @param basenames an array of basenames
158 * @see #setBasename
159 * @see java.util.ResourceBundle
160 */
161 public void setBasenames(String[] basenames) {
162 if (basenames != null) {
163 this.basenames = new String[basenames.length];
164 for (int i = 0; i < basenames.length; i++) {
165 String basename = basenames[i];
166 Assert.hasText(basename, "Basename must not be empty");
167 this.basenames[i] = basename.trim();
168 }
169 }
170 else {
171 this.basenames = new String[0];
172 }
173 }
174
175 /**
176 * Set the default charset to use for parsing properties files.
177 * Used if no file-specific charset is specified for a file.
178 * <p>Default is none, using the <code>java.util.Properties</code>
179 * default encoding.
180 * <p>Only applies to classic properties files, not to XML files.
181 * @param defaultEncoding the default charset
182 * @see #setFileEncodings
183 * @see org.springframework.util.PropertiesPersister#load
184 */
185 public void setDefaultEncoding(String defaultEncoding) {
186 this.defaultEncoding = defaultEncoding;
187 }
188
189 /**
190 * Set per-file charsets to use for parsing properties files.
191 * <p>Only applies to classic properties files, not to XML files.
192 * @param fileEncodings Properties with filenames as keys and charset
193 * names as values. Filenames have to match the basename syntax,
194 * with optional locale-specific appendices: e.g. "WEB-INF/messages"
195 * or "WEB-INF/messages_en".
196 * @see #setBasenames
197 * @see org.springframework.util.PropertiesPersister#load
198 */
199 public void setFileEncodings(Properties fileEncodings) {
200 this.fileEncodings = fileEncodings;
201 }
202
203 /**
204 * Set whether to fall back to the system Locale if no files for a specific
205 * Locale have been found. Default is "true"; if this is turned off, the only
206 * fallback will be the default file (e.g. "messages.properties" for
207 * basename "messages").
208 * <p>Falling back to the system Locale is the default behavior of
209 * <code>java.util.ResourceBundle</code>. However, this is often not
210 * desirable in an application server environment, where the system Locale
211 * is not relevant to the application at all: Set this flag to "false"
212 * in such a scenario.
213 */
214 public void setFallbackToSystemLocale(boolean fallbackToSystemLocale) {
215 this.fallbackToSystemLocale = fallbackToSystemLocale;
216 }
217
218 /**
219 * Set the number of seconds to cache loaded properties files.
220 * <ul>
221 * <li>Default is "-1", indicating to cache forever (just like
222 * <code>java.util.ResourceBundle</code>).
223 * <li>A positive number will cache loaded properties files for the given
224 * number of seconds. This is essentially the interval between refresh checks.
225 * Note that a refresh attempt will first check the last-modified timestamp
226 * of the file before actually reloading it; so if files don't change, this
227 * interval can be set rather low, as refresh attempts will not actually reload.
228 * <li>A value of "0" will check the last-modified timestamp of the file on
229 * every message access. <b>Do not use this in a production environment!</b>
230 * </ul>
231 */
232 public void setCacheSeconds(int cacheSeconds) {
233 this.cacheMillis = (cacheSeconds * 1000);
234 }
235
236 /**
237 * Set the PropertiesPersister to use for parsing properties files.
238 * <p>The default is a DefaultPropertiesPersister.
239 * @see org.springframework.util.DefaultPropertiesPersister
240 */
241 public void setPropertiesPersister(PropertiesPersister propertiesPersister) {
242 this.propertiesPersister =
243 (propertiesPersister != null ? propertiesPersister : new DefaultPropertiesPersister());
244 }
245
246 /**
247 * Set the ResourceLoader to use for loading bundle properties files.
248 * <p>The default is a DefaultResourceLoader. Will get overridden by the
249 * ApplicationContext if running in a context, as it implements the
250 * ResourceLoaderAware interface. Can be manually overridden when
251 * running outside of an ApplicationContext.
252 * @see org.springframework.core.io.DefaultResourceLoader
253 * @see org.springframework.context.ResourceLoaderAware
254 */
255 public void setResourceLoader(ResourceLoader resourceLoader) {
256 this.resourceLoader = (resourceLoader != null ? resourceLoader : new DefaultResourceLoader());
257 }
258
259
260 /**
261 * Resolves the given message code as key in the retrieved bundle files,
262 * returning the value found in the bundle as-is (without MessageFormat parsing).
263 */
264 protected String resolveCodeWithoutArguments(String code, Locale locale) {
265 if (this.cacheMillis < 0) {
266 PropertiesHolder propHolder = getMergedProperties(locale);
267 String result = propHolder.getProperty(code);
268 if (result != null) {
269 return result;
270 }
271 }
272 else {
273 for (int i = 0; i < this.basenames.length; i++) {
274 List filenames = calculateAllFilenames(this.basenames[i], locale);
275 for (int j = 0; j < filenames.size(); j++) {
276 String filename = (String) filenames.get(j);
277 PropertiesHolder propHolder = getProperties(filename);
278 String result = propHolder.getProperty(code);
279 if (result != null) {
280 return result;
281 }
282 }
283 }
284 }
285 return null;
286 }
287
288 /**
289 * Resolves the given message code as key in the retrieved bundle files,
290 * using a cached MessageFormat instance per message code.
291 */
292 protected MessageFormat resolveCode(String code, Locale locale) {
293 if (this.cacheMillis < 0) {
294 PropertiesHolder propHolder = getMergedProperties(locale);
295 MessageFormat result = propHolder.getMessageFormat(code, locale);
296 if (result != null) {
297 return result;
298 }
299 }
300 else {
301 for (int i = 0; i < this.basenames.length; i++) {
302 List filenames = calculateAllFilenames(this.basenames[i], locale);
303 for (int j = 0; j < filenames.size(); j++) {
304 String filename = (String) filenames.get(j);
305 PropertiesHolder propHolder = getProperties(filename);
306 MessageFormat result = propHolder.getMessageFormat(code, locale);
307 if (result != null) {
308 return result;
309 }
310 }
311 }
312 }
313 return null;
314 }
315
316
317 /**
318 * Get a PropertiesHolder that contains the actually visible properties
319 * for a Locale, after merging all specified resource bundles.
320 * Either fetches the holder from the cache or freshly loads it.
321 * <p>Only used when caching resource bundle contents forever, i.e.
322 * with cacheSeconds < 0. Therefore, merged properties are always
323 * cached forever.
324 */
325 protected PropertiesHolder getMergedProperties(Locale locale) {
326 synchronized (this.cachedMergedProperties) {
327 PropertiesHolder mergedHolder = (PropertiesHolder) this.cachedMergedProperties.get(locale);
328 if (mergedHolder != null) {
329 return mergedHolder;
330 }
331 Properties mergedProps = new Properties();
332 mergedHolder = new PropertiesHolder(mergedProps, -1);
333 for (int i = this.basenames.length - 1; i >= 0; i--) {
334 List filenames = calculateAllFilenames(this.basenames[i], locale);
335 for (int j = filenames.size() - 1; j >= 0; j--) {
336 String filename = (String) filenames.get(j);
337 PropertiesHolder propHolder = getProperties(filename);
338 if (propHolder.getProperties() != null) {
339 mergedProps.putAll(propHolder.getProperties());
340 }
341 }
342 }
343 this.cachedMergedProperties.put(locale, mergedHolder);
344 return mergedHolder;
345 }
346 }
347
348 /**
349 * Calculate all filenames for the given bundle basename and Locale.
350 * Will calculate filenames for the given Locale, the system Locale
351 * (if applicable), and the default file.
352 * @param basename the basename of the bundle
353 * @param locale the locale
354 * @return the List of filenames to check
355 * @see #setFallbackToSystemLocale
356 * @see #calculateFilenamesForLocale
357 */
358 protected List calculateAllFilenames(String basename, Locale locale) {
359 synchronized (this.cachedFilenames) {
360 Map localeMap = (Map) this.cachedFilenames.get(basename);
361 if (localeMap != null) {
362 List filenames = (List) localeMap.get(locale);
363 if (filenames != null) {
364 return filenames;
365 }
366 }
367 List filenames = new ArrayList(7);
368 filenames.addAll(calculateFilenamesForLocale(basename, locale));
369 if (this.fallbackToSystemLocale && !locale.equals(Locale.getDefault())) {
370 List fallbackFilenames = calculateFilenamesForLocale(basename, Locale.getDefault());
371 for (Iterator it = fallbackFilenames.iterator(); it.hasNext();) {
372 String fallbackFilename = (String) it.next();
373 if (!filenames.contains(fallbackFilename)) {
374 // Entry for fallback locale that isn't already in filenames list.
375 filenames.add(fallbackFilename);
376 }
377 }
378 }
379 filenames.add(basename);
380 if (localeMap != null) {
381 localeMap.put(locale, filenames);
382 }
383 else {
384 localeMap = new HashMap();
385 localeMap.put(locale, filenames);
386 this.cachedFilenames.put(basename, localeMap);
387 }
388 return filenames;
389 }
390 }
391
392 /**
393 * Calculate the filenames for the given bundle basename and Locale,
394 * appending language code, country code, and variant code.
395 * E.g.: basename "messages", Locale "de_AT_oo" -> "messages_de_AT_OO",
396 * "messages_de_AT", "messages_de".
397 * @param basename the basename of the bundle
398 * @param locale the locale
399 * @return the List of filenames to check
400 */
401 protected List calculateFilenamesForLocale(String basename, Locale locale) {
402 List result = new ArrayList(3);
403 String language = locale.getLanguage();
404 String country = locale.getCountry();
405 String variant = locale.getVariant();
406 StringBuffer temp = new StringBuffer(basename);
407
408 if (language.length() > 0) {
409 temp.append('_').append(language);
410 result.add(0, temp.toString());
411 }
412
413 if (country.length() > 0) {
414 temp.append('_').append(country);
415 result.add(0, temp.toString());
416 }
417
418 if (variant.length() > 0) {
419 temp.append('_').append(variant);
420 result.add(0, temp.toString());
421 }
422
423 return result;
424 }
425
426
427 /**
428 * Get a PropertiesHolder for the given filename, either from the
429 * cache or freshly loaded.
430 * @param filename the bundle filename (basename + Locale)
431 * @return the current PropertiesHolder for the bundle
432 */
433 protected PropertiesHolder getProperties(String filename) {
434 synchronized (this.cachedProperties) {
435 PropertiesHolder propHolder = (PropertiesHolder) this.cachedProperties.get(filename);
436 if (propHolder != null &&
437 (propHolder.getRefreshTimestamp() < 0 ||
438 propHolder.getRefreshTimestamp() > System.currentTimeMillis() - this.cacheMillis)) {
439 // up to date
440 return propHolder;
441 }
442 return refreshProperties(filename, propHolder);
443 }
444 }
445
446 /**
447 * Refresh the PropertiesHolder for the given bundle filename.
448 * The holder can be <code>null</code> if not cached before, or a timed-out cache entry
449 * (potentially getting re-validated against the current last-modified timestamp).
450 * @param filename the bundle filename (basename + Locale)
451 * @param propHolder the current PropertiesHolder for the bundle
452 */
453 protected PropertiesHolder refreshProperties(String filename, PropertiesHolder propHolder) {
454 long refreshTimestamp = (this.cacheMillis < 0) ? -1 : System.currentTimeMillis();
455
456 Resource resource = this.resourceLoader.getResource(filename + PROPERTIES_SUFFIX);
457 if (!resource.exists()) {
458 resource = this.resourceLoader.getResource(filename + XML_SUFFIX);
459 }
460
461 if (resource.exists()) {
462 long fileTimestamp = -1;
463 if (this.cacheMillis >= 0) {
464 // Last-modified timestamp of file will just be read if caching with timeout.
465 try {
466 fileTimestamp = resource.lastModified();
467 if (propHolder != null && propHolder.getFileTimestamp() == fileTimestamp) {
468 if (logger.isDebugEnabled()) {
469 logger.debug("Re-caching properties for filename [" + filename + "] - file hasn't been modified");
470 }
471 propHolder.setRefreshTimestamp(refreshTimestamp);
472 return propHolder;
473 }
474 }
475 catch (IOException ex) {
476 // Probably a class path resource: cache it forever.
477 if (logger.isDebugEnabled()) {
478 logger.debug(
479 resource + " could not be resolved in the file system - assuming that is hasn't changed", ex);
480 }
481 fileTimestamp = -1;
482 }
483 }
484 try {
485 Properties props = loadProperties(resource, filename);
486 propHolder = new PropertiesHolder(props, fileTimestamp);
487 }
488 catch (IOException ex) {
489 if (logger.isWarnEnabled()) {
490 logger.warn("Could not parse properties file [" + resource.getFilename() + "]", ex);
491 }
492 // Empty holder representing "not valid".
493 propHolder = new PropertiesHolder();
494 }
495 }
496
497 else {
498 // Resource does not exist.
499 if (logger.isDebugEnabled()) {
500 logger.debug("No properties file found for [" + filename + "] - neither plain properties nor XML");
501 }
502 // Empty holder representing "not found".
503 propHolder = new PropertiesHolder();
504 }
505
506 propHolder.setRefreshTimestamp(refreshTimestamp);
507 this.cachedProperties.put(filename, propHolder);
508 return propHolder;
509 }
510
511 /**
512 * Load the properties from the given resource.
513 * @param resource the resource to load from
514 * @param filename the original bundle filename (basename + Locale)
515 * @return the populated Properties instance
516 * @throws IOException if properties loading failed
517 */
518 protected Properties loadProperties(Resource resource, String filename) throws IOException {
519 InputStream is = resource.getInputStream();
520 Properties props = new Properties();
521 try {
522 if (resource.getFilename().endsWith(XML_SUFFIX)) {
523 if (logger.isDebugEnabled()) {
524 logger.debug("Loading properties [" + resource.getFilename() + "]");
525 }
526 this.propertiesPersister.loadFromXml(props, is);
527 }
528 else {
529 String encoding = null;
530 if (this.fileEncodings != null) {
531 encoding = this.fileEncodings.getProperty(filename);
532 }
533 if (encoding == null) {
534 encoding = this.defaultEncoding;
535 }
536 if (encoding != null) {
537 if (logger.isDebugEnabled()) {
538 logger.debug("Loading properties [" + resource.getFilename() + "] with encoding '" + encoding + "'");
539 }
540 this.propertiesPersister.load(props, new InputStreamReader(is, encoding));
541 }
542 else {
543 if (logger.isDebugEnabled()) {
544 logger.debug("Loading properties [" + resource.getFilename() + "]");
545 }
546 this.propertiesPersister.load(props, is);
547 }
548 }
549 return props;
550 }
551 finally {
552 is.close();
553 }
554 }
555
556
557 /**
558 * Clear the resource bundle cache.
559 * Subsequent resolve calls will lead to reloading of the properties files.
560 */
561 public void clearCache() {
562 logger.debug("Clearing entire resource bundle cache");
563 synchronized (this.cachedProperties) {
564 this.cachedProperties.clear();
565 }
566 synchronized (this.cachedMergedProperties) {
567 this.cachedMergedProperties.clear();
568 }
569 }
570
571 /**
572 * Clear the resource bundle caches of this MessageSource and all its ancestors.
573 * @see #clearCache
574 */
575 public void clearCacheIncludingAncestors() {
576 clearCache();
577 if (getParentMessageSource() instanceof ReloadableResourceBundleMessageSource) {
578 ((ReloadableResourceBundleMessageSource) getParentMessageSource()).clearCacheIncludingAncestors();
579 }
580 }
581
582
583 public String toString() {
584 return getClass().getName() + ": basenames=[" + StringUtils.arrayToCommaDelimitedString(this.basenames) + "]";
585 }
586
587
588 /**
589 * PropertiesHolder for caching.
590 * Stores the last-modified timestamp of the source file for efficient
591 * change detection, and the timestamp of the last refresh attempt
592 * (updated every time the cache entry gets re-validated).
593 */
594 protected class PropertiesHolder {
595
596 private Properties properties;
597
598 private long fileTimestamp = -1;
599
600 private long refreshTimestamp = -1;
601
602 /** Cache to hold already generated MessageFormats per message code */
603 private final Map cachedMessageFormats = new HashMap();
604
605 public PropertiesHolder(Properties properties, long fileTimestamp) {
606 this.properties = properties;
607 this.fileTimestamp = fileTimestamp;
608 }
609
610 public PropertiesHolder() {
611 }
612
613 public Properties getProperties() {
614 return properties;
615 }
616
617 public long getFileTimestamp() {
618 return fileTimestamp;
619 }
620
621 public void setRefreshTimestamp(long refreshTimestamp) {
622 this.refreshTimestamp = refreshTimestamp;
623 }
624
625 public long getRefreshTimestamp() {
626 return refreshTimestamp;
627 }
628
629 public String getProperty(String code) {
630 if (this.properties == null) {
631 return null;
632 }
633 return this.properties.getProperty(code);
634 }
635
636 public MessageFormat getMessageFormat(String code, Locale locale) {
637 if (this.properties == null) {
638 return null;
639 }
640 synchronized (this.cachedMessageFormats) {
641 Map localeMap = (Map) this.cachedMessageFormats.get(code);
642 if (localeMap != null) {
643 MessageFormat result = (MessageFormat) localeMap.get(locale);
644 if (result != null) {
645 return result;
646 }
647 }
648 String msg = this.properties.getProperty(code);
649 if (msg != null) {
650 if (localeMap == null) {
651 localeMap = new HashMap();
652 this.cachedMessageFormats.put(code, localeMap);
653 }
654 MessageFormat result = createMessageFormat(msg, locale);
655 localeMap.put(locale, result);
656 return result;
657 }
658 return null;
659 }
660 }
661 }
662
663 }