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}