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}