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.io.Reader;
023import java.net.URL;
024import java.net.URLConnection;
025import java.security.AccessController;
026import java.security.PrivilegedActionException;
027import java.security.PrivilegedExceptionAction;
028import java.text.MessageFormat;
029import java.util.Locale;
030import java.util.Map;
031import java.util.MissingResourceException;
032import java.util.PropertyResourceBundle;
033import java.util.ResourceBundle;
034import java.util.Set;
035import java.util.concurrent.ConcurrentHashMap;
036
037import org.springframework.beans.factory.BeanClassLoaderAware;
038import org.springframework.lang.Nullable;
039import org.springframework.util.Assert;
040import org.springframework.util.ClassUtils;
041
042/**
043 * {@link org.springframework.context.MessageSource} implementation that
044 * accesses resource bundles using specified basenames. This class relies
045 * on the underlying JDK's {@link java.util.ResourceBundle} implementation,
046 * in combination with the JDK's standard message parsing provided by
047 * {@link java.text.MessageFormat}.
048 *
049 * <p>This MessageSource caches both the accessed ResourceBundle instances and
050 * the generated MessageFormats for each message. It also implements rendering of
051 * no-arg messages without MessageFormat, as supported by the AbstractMessageSource
052 * base class. The caching provided by this MessageSource is significantly faster
053 * than the built-in caching of the {@code java.util.ResourceBundle} class.
054 *
055 * <p>The basenames follow {@link java.util.ResourceBundle} conventions: essentially,
056 * a fully-qualified classpath location. If it doesn't contain a package qualifier
057 * (such as {@code org.mypackage}), it will be resolved from the classpath root.
058 * Note that the JDK's standard ResourceBundle treats dots as package separators:
059 * This means that "test.theme" is effectively equivalent to "test/theme".
060 *
061 * <p>On the classpath, bundle resources will be read with the locally configured
062 * {@link #setDefaultEncoding encoding}: by default, ISO-8859-1; consider switching
063 * this to UTF-8, or to {@code null} for the platform default encoding. On the JDK 9+
064 * module path where locally provided {@code ResourceBundle.Control} handles are not
065 * supported, this MessageSource always falls back to {@link ResourceBundle#getBundle}
066 * retrieval with the platform default encoding: UTF-8 with a ISO-8859-1 fallback on
067 * JDK 9+ (configurable through the "java.util.PropertyResourceBundle.encoding" system
068 * property). Note that {@link #loadBundle(Reader)}/{@link #loadBundle(InputStream)}
069 * won't be called in this case either, effectively ignoring overrides in subclasses.
070 * Consider implementing a JDK 9 {@code java.util.spi.ResourceBundleProvider} instead.
071 *
072 * @author Rod Johnson
073 * @author Juergen Hoeller
074 * @see #setBasenames
075 * @see ReloadableResourceBundleMessageSource
076 * @see java.util.ResourceBundle
077 * @see java.text.MessageFormat
078 */
079public class ResourceBundleMessageSource extends AbstractResourceBasedMessageSource implements BeanClassLoaderAware {
080
081        @Nullable
082        private ClassLoader bundleClassLoader;
083
084        @Nullable
085        private ClassLoader beanClassLoader = ClassUtils.getDefaultClassLoader();
086
087        /**
088         * Cache to hold loaded ResourceBundles.
089         * This Map is keyed with the bundle basename, which holds a Map that is
090         * keyed with the Locale and in turn holds the ResourceBundle instances.
091         * This allows for very efficient hash lookups, significantly faster
092         * than the ResourceBundle class's own cache.
093         */
094        private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles =
095                        new ConcurrentHashMap<>();
096
097        /**
098         * Cache to hold already generated MessageFormats.
099         * This Map is keyed with the ResourceBundle, which holds a Map that is
100         * keyed with the message code, which in turn holds a Map that is keyed
101         * with the Locale and holds the MessageFormat values. This allows for
102         * very efficient hash lookups without concatenated keys.
103         * @see #getMessageFormat
104         */
105        private final Map<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats =
106                        new ConcurrentHashMap<>();
107
108        @Nullable
109        private volatile MessageSourceControl control = new MessageSourceControl();
110
111
112        public ResourceBundleMessageSource() {
113                setDefaultEncoding("ISO-8859-1");
114        }
115
116
117        /**
118         * Set the ClassLoader to load resource bundles with.
119         * <p>Default is the containing BeanFactory's
120         * {@link org.springframework.beans.factory.BeanClassLoaderAware bean ClassLoader},
121         * or the default ClassLoader determined by
122         * {@link org.springframework.util.ClassUtils#getDefaultClassLoader()}
123         * if not running within a BeanFactory.
124         */
125        public void setBundleClassLoader(ClassLoader classLoader) {
126                this.bundleClassLoader = classLoader;
127        }
128
129        /**
130         * Return the ClassLoader to load resource bundles with.
131         * <p>Default is the containing BeanFactory's bean ClassLoader.
132         * @see #setBundleClassLoader
133         */
134        @Nullable
135        protected ClassLoader getBundleClassLoader() {
136                return (this.bundleClassLoader != null ? this.bundleClassLoader : this.beanClassLoader);
137        }
138
139        @Override
140        public void setBeanClassLoader(ClassLoader classLoader) {
141                this.beanClassLoader = classLoader;
142        }
143
144
145        /**
146         * Resolves the given message code as key in the registered resource bundles,
147         * returning the value found in the bundle as-is (without MessageFormat parsing).
148         */
149        @Override
150        protected String resolveCodeWithoutArguments(String code, Locale locale) {
151                Set<String> basenames = getBasenameSet();
152                for (String basename : basenames) {
153                        ResourceBundle bundle = getResourceBundle(basename, locale);
154                        if (bundle != null) {
155                                String result = getStringOrNull(bundle, code);
156                                if (result != null) {
157                                        return result;
158                                }
159                        }
160                }
161                return null;
162        }
163
164        /**
165         * Resolves the given message code as key in the registered resource bundles,
166         * using a cached MessageFormat instance per message code.
167         */
168        @Override
169        @Nullable
170        protected MessageFormat resolveCode(String code, Locale locale) {
171                Set<String> basenames = getBasenameSet();
172                for (String basename : basenames) {
173                        ResourceBundle bundle = getResourceBundle(basename, locale);
174                        if (bundle != null) {
175                                MessageFormat messageFormat = getMessageFormat(bundle, code, locale);
176                                if (messageFormat != null) {
177                                        return messageFormat;
178                                }
179                        }
180                }
181                return null;
182        }
183
184
185        /**
186         * Return a ResourceBundle for the given basename and code,
187         * fetching already generated MessageFormats from the cache.
188         * @param basename the basename of the ResourceBundle
189         * @param locale the Locale to find the ResourceBundle for
190         * @return the resulting ResourceBundle, or {@code null} if none
191         * found for the given basename and Locale
192         */
193        @Nullable
194        protected ResourceBundle getResourceBundle(String basename, Locale locale) {
195                if (getCacheMillis() >= 0) {
196                        // Fresh ResourceBundle.getBundle call in order to let ResourceBundle
197                        // do its native caching, at the expense of more extensive lookup steps.
198                        return doGetBundle(basename, locale);
199                }
200                else {
201                        // Cache forever: prefer locale cache over repeated getBundle calls.
202                        Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
203                        if (localeMap != null) {
204                                ResourceBundle bundle = localeMap.get(locale);
205                                if (bundle != null) {
206                                        return bundle;
207                                }
208                        }
209                        try {
210                                ResourceBundle bundle = doGetBundle(basename, locale);
211                                if (localeMap == null) {
212                                        localeMap = new ConcurrentHashMap<>();
213                                        Map<Locale, ResourceBundle> existing = this.cachedResourceBundles.putIfAbsent(basename, localeMap);
214                                        if (existing != null) {
215                                                localeMap = existing;
216                                        }
217                                }
218                                localeMap.put(locale, bundle);
219                                return bundle;
220                        }
221                        catch (MissingResourceException ex) {
222                                if (logger.isWarnEnabled()) {
223                                        logger.warn("ResourceBundle [" + basename + "] not found for MessageSource: " + ex.getMessage());
224                                }
225                                // Assume bundle not found
226                                // -> do NOT throw the exception to allow for checking parent message source.
227                                return null;
228                        }
229                }
230        }
231
232        /**
233         * Obtain the resource bundle for the given basename and Locale.
234         * @param basename the basename to look for
235         * @param locale the Locale to look for
236         * @return the corresponding ResourceBundle
237         * @throws MissingResourceException if no matching bundle could be found
238         * @see java.util.ResourceBundle#getBundle(String, Locale, ClassLoader)
239         * @see #getBundleClassLoader()
240         */
241        protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
242                ClassLoader classLoader = getBundleClassLoader();
243                Assert.state(classLoader != null, "No bundle ClassLoader set");
244
245                MessageSourceControl control = this.control;
246                if (control != null) {
247                        try {
248                                return ResourceBundle.getBundle(basename, locale, classLoader, control);
249                        }
250                        catch (UnsupportedOperationException ex) {
251                                // Probably in a Jigsaw environment on JDK 9+
252                                this.control = null;
253                                String encoding = getDefaultEncoding();
254                                if (encoding != null && logger.isInfoEnabled()) {
255                                        logger.info("ResourceBundleMessageSource is configured to read resources with encoding '" +
256                                                        encoding + "' but ResourceBundle.Control not supported in current system environment: " +
257                                                        ex.getMessage() + " - falling back to plain ResourceBundle.getBundle retrieval with the " +
258                                                        "platform default encoding. Consider setting the 'defaultEncoding' property to 'null' " +
259                                                        "for participating in the platform default and therefore avoiding this log message.");
260                                }
261                        }
262                }
263
264                // Fallback: plain getBundle lookup without Control handle
265                return ResourceBundle.getBundle(basename, locale, classLoader);
266        }
267
268        /**
269         * Load a property-based resource bundle from the given reader.
270         * <p>This will be called in case of a {@link #setDefaultEncoding "defaultEncoding"},
271         * including {@link ResourceBundleMessageSource}'s default ISO-8859-1 encoding.
272         * Note that this method can only be called with a {@code ResourceBundle.Control}:
273         * When running on the JDK 9+ module path where such control handles are not
274         * supported, any overrides in custom subclasses will effectively get ignored.
275         * <p>The default implementation returns a {@link PropertyResourceBundle}.
276         * @param reader the reader for the target resource
277         * @return the fully loaded bundle
278         * @throws IOException in case of I/O failure
279         * @since 4.2
280         * @see #loadBundle(InputStream)
281         * @see PropertyResourceBundle#PropertyResourceBundle(Reader)
282         */
283        protected ResourceBundle loadBundle(Reader reader) throws IOException {
284                return new PropertyResourceBundle(reader);
285        }
286
287        /**
288         * Load a property-based resource bundle from the given input stream,
289         * picking up the default properties encoding on JDK 9+.
290         * <p>This will only be called with {@link #setDefaultEncoding "defaultEncoding"}
291         * set to {@code null}, explicitly enforcing the platform default encoding
292         * (which is UTF-8 with a ISO-8859-1 fallback on JDK 9+ but configurable
293         * through the "java.util.PropertyResourceBundle.encoding" system property).
294         * Note that this method can only be called with a {@code ResourceBundle.Control}:
295         * When running on the JDK 9+ module path where such control handles are not
296         * supported, any overrides in custom subclasses will effectively get ignored.
297         * <p>The default implementation returns a {@link PropertyResourceBundle}.
298         * @param inputStream the input stream for the target resource
299         * @return the fully loaded bundle
300         * @throws IOException in case of I/O failure
301         * @since 5.1
302         * @see #loadBundle(Reader)
303         * @see PropertyResourceBundle#PropertyResourceBundle(InputStream)
304         */
305        protected ResourceBundle loadBundle(InputStream inputStream) throws IOException {
306                return new PropertyResourceBundle(inputStream);
307        }
308
309        /**
310         * Return a MessageFormat for the given bundle and code,
311         * fetching already generated MessageFormats from the cache.
312         * @param bundle the ResourceBundle to work on
313         * @param code the message code to retrieve
314         * @param locale the Locale to use to build the MessageFormat
315         * @return the resulting MessageFormat, or {@code null} if no message
316         * defined for the given code
317         * @throws MissingResourceException if thrown by the ResourceBundle
318         */
319        @Nullable
320        protected MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale)
321                        throws MissingResourceException {
322
323                Map<String, Map<Locale, MessageFormat>> codeMap = this.cachedBundleMessageFormats.get(bundle);
324                Map<Locale, MessageFormat> localeMap = null;
325                if (codeMap != null) {
326                        localeMap = codeMap.get(code);
327                        if (localeMap != null) {
328                                MessageFormat result = localeMap.get(locale);
329                                if (result != null) {
330                                        return result;
331                                }
332                        }
333                }
334
335                String msg = getStringOrNull(bundle, code);
336                if (msg != null) {
337                        if (codeMap == null) {
338                                codeMap = new ConcurrentHashMap<>();
339                                Map<String, Map<Locale, MessageFormat>> existing =
340                                                this.cachedBundleMessageFormats.putIfAbsent(bundle, codeMap);
341                                if (existing != null) {
342                                        codeMap = existing;
343                                }
344                        }
345                        if (localeMap == null) {
346                                localeMap = new ConcurrentHashMap<>();
347                                Map<Locale, MessageFormat> existing = codeMap.putIfAbsent(code, localeMap);
348                                if (existing != null) {
349                                        localeMap = existing;
350                                }
351                        }
352                        MessageFormat result = createMessageFormat(msg, locale);
353                        localeMap.put(locale, result);
354                        return result;
355                }
356
357                return null;
358        }
359
360        /**
361         * Efficiently retrieve the String value for the specified key,
362         * or return {@code null} if not found.
363         * <p>As of 4.2, the default implementation checks {@code containsKey}
364         * before it attempts to call {@code getString} (which would require
365         * catching {@code MissingResourceException} for key not found).
366         * <p>Can be overridden in subclasses.
367         * @param bundle the ResourceBundle to perform the lookup in
368         * @param key the key to look up
369         * @return the associated value, or {@code null} if none
370         * @since 4.2
371         * @see ResourceBundle#getString(String)
372         * @see ResourceBundle#containsKey(String)
373         */
374        @Nullable
375        protected String getStringOrNull(ResourceBundle bundle, String key) {
376                if (bundle.containsKey(key)) {
377                        try {
378                                return bundle.getString(key);
379                        }
380                        catch (MissingResourceException ex) {
381                                // Assume key not found for some other reason
382                                // -> do NOT throw the exception to allow for checking parent message source.
383                        }
384                }
385                return null;
386        }
387
388        /**
389         * Show the configuration of this MessageSource.
390         */
391        @Override
392        public String toString() {
393                return getClass().getName() + ": basenames=" + getBasenameSet();
394        }
395
396
397        /**
398         * Custom implementation of {@code ResourceBundle.Control}, adding support
399         * for custom file encodings, deactivating the fallback to the system locale
400         * and activating ResourceBundle's native cache, if desired.
401         */
402        private class MessageSourceControl extends ResourceBundle.Control {
403
404                @Override
405                @Nullable
406                public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
407                                throws IllegalAccessException, InstantiationException, IOException {
408
409                        // Special handling of default encoding
410                        if (format.equals("java.properties")) {
411                                String bundleName = toBundleName(baseName, locale);
412                                final String resourceName = toResourceName(bundleName, "properties");
413                                final ClassLoader classLoader = loader;
414                                final boolean reloadFlag = reload;
415                                InputStream inputStream;
416                                try {
417                                        inputStream = AccessController.doPrivileged((PrivilegedExceptionAction<InputStream>) () -> {
418                                                InputStream is = null;
419                                                if (reloadFlag) {
420                                                        URL url = classLoader.getResource(resourceName);
421                                                        if (url != null) {
422                                                                URLConnection connection = url.openConnection();
423                                                                if (connection != null) {
424                                                                        connection.setUseCaches(false);
425                                                                        is = connection.getInputStream();
426                                                                }
427                                                        }
428                                                }
429                                                else {
430                                                        is = classLoader.getResourceAsStream(resourceName);
431                                                }
432                                                return is;
433                                        });
434                                }
435                                catch (PrivilegedActionException ex) {
436                                        throw (IOException) ex.getException();
437                                }
438                                if (inputStream != null) {
439                                        String encoding = getDefaultEncoding();
440                                        if (encoding != null) {
441                                                try (InputStreamReader bundleReader = new InputStreamReader(inputStream, encoding)) {
442                                                        return loadBundle(bundleReader);
443                                                }
444                                        }
445                                        else {
446                                                try (InputStream bundleStream = inputStream) {
447                                                        return loadBundle(bundleStream);
448                                                }
449                                        }
450                                }
451                                else {
452                                        return null;
453                                }
454                        }
455                        else {
456                                // Delegate handling of "java.class" format to standard Control
457                                return super.newBundle(baseName, locale, format, loader, reload);
458                        }
459                }
460
461                @Override
462                @Nullable
463                public Locale getFallbackLocale(String baseName, Locale locale) {
464                        Locale defaultLocale = getDefaultLocale();
465                        return (defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null);
466                }
467
468                @Override
469                public long getTimeToLive(String baseName, Locale locale) {
470                        long cacheMillis = getCacheMillis();
471                        return (cacheMillis >= 0 ? cacheMillis : super.getTimeToLive(baseName, locale));
472                }
473
474                @Override
475                public boolean needsReload(
476                                String baseName, Locale locale, String format, ClassLoader loader, ResourceBundle bundle, long loadTime) {
477
478                        if (super.needsReload(baseName, locale, format, loader, bundle, loadTime)) {
479                                cachedBundleMessageFormats.remove(bundle);
480                                return true;
481                        }
482                        else {
483                                return false;
484                        }
485                }
486        }
487
488}