001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software, please see the
018 * company website: https://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: https://www.opencms.org
022 *
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.i18n;
029
030import org.opencms.util.CmsFileUtil;
031
032import java.io.File;
033import java.io.FileInputStream;
034import java.io.IOException;
035import java.io.InputStream;
036import java.net.URL;
037import java.util.HashSet;
038import java.util.List;
039import java.util.Locale;
040import java.util.Map;
041import java.util.MissingResourceException;
042import java.util.ResourceBundle;
043import java.util.Set;
044import java.util.concurrent.ConcurrentHashMap;
045
046/**
047 * Resource bundle loader for property based resource bundles from OpenCms that has a flushable cache.<p>
048 *
049 * The main reason for implementing this is that the Java default resource bundle loading mechanism
050 * provided by {@link java.util.ResourceBundle#getBundle(java.lang.String, java.util.Locale)} uses a
051 * cache that can NOT be flushed by any standard means. This means for every simple change in a resource
052 * bundle, the Java VM (and the webapp container that runs OpenCms) must be restarted.
053 * This non-standard resource bundle loader avoids this by providing a flushable cache.<p>
054 *
055 * In case the requested bundle can not be found, a fallback mechanism to
056 * {@link java.util.ResourceBundle#getBundle(java.lang.String, java.util.Locale)} is used to look up
057 * the resource bundle with the Java default resource bundle loading mechanism.<p>
058 *
059 * @see java.util.ResourceBundle
060 * @see java.util.PropertyResourceBundle
061 * @see org.opencms.i18n.CmsPropertyResourceBundle
062 *
063 * @since 6.2.0
064 */
065public final class CmsResourceBundleLoader {
066
067    /**
068     * Cache key for the ResourceBundle cache.<p>
069     *
070     * Resource bundles are keyed by the combination of bundle name, locale, and class loader.
071     */
072    private static class BundleKey {
073
074        /** The base bundle name. */
075        private String m_baseName;
076
077        /** The hash code. */
078        private int m_hashcode;
079
080        /** The locale. */
081        private Locale m_locale;
082
083        /**
084         * Create an initialized bundle key.<p>
085         *
086         * @param s the base name
087         * @param l the locale
088         */
089        BundleKey(String s, Locale l) {
090
091            set(s, l);
092        }
093
094        /**
095         * @see java.lang.Object#equals(java.lang.Object)
096         */
097        @Override
098        public boolean equals(Object o) {
099
100            if (!(o instanceof BundleKey)) {
101                return false;
102            }
103            BundleKey key = (BundleKey)o;
104            return (m_hashcode == key.m_hashcode) && m_baseName.equals(key.m_baseName) && m_locale.equals(key.m_locale);
105        }
106
107        /**
108         * @see java.lang.Object#hashCode()
109         */
110        @Override
111        public int hashCode() {
112
113            return m_hashcode;
114        }
115
116        /**
117         * Checks if the given base name is identical to the base name of this bundle key.<p>
118         *
119         * @param baseName the base name to compare
120         *
121         * @return <code>true</code> if the given base name is identical to the base name of this bundle key
122         */
123        public boolean isSameBase(String baseName) {
124
125            return m_baseName.equals(baseName);
126        }
127
128        /**
129         * @see java.lang.Object#toString()
130         */
131        @Override
132        public String toString() {
133
134            return m_baseName + "_" + m_locale;
135        }
136
137        /**
138         * Initialize this bundle key.<p>
139         *
140         * @param s the base name
141         * @param l the locale
142         */
143        void set(String s, Locale l) {
144
145            m_baseName = s;
146            m_locale = l;
147            m_hashcode = m_baseName.hashCode() ^ m_locale.hashCode();
148        }
149    }
150
151    /**  The resource bundle cache. */
152    private static Map<BundleKey, ResourceBundle> m_bundleCache;
153
154    /** The last default Locale we saw, if this ever changes then we have to reset our caches. */
155    private static Locale m_lastDefaultLocale;
156
157    /** Cache lookup key to avoid having to a new one for every getBundle() call. */
158    // private static BundleKey m_lookupKey = new BundleKey();
159
160    /**  The permanent list resource bundle cache. */
161    private static Map<String, I_CmsResourceBundle> m_permanentCache;
162
163    /** Singleton cache entry to represent previous failed lookups. */
164    private static final ResourceBundle NULL_ENTRY = new CmsListResourceBundle();
165
166    static {
167        m_bundleCache = new ConcurrentHashMap<BundleKey, ResourceBundle>();
168        m_lastDefaultLocale = Locale.getDefault();
169        m_permanentCache = new ConcurrentHashMap<String, I_CmsResourceBundle>();
170    }
171
172    /**
173     * Hides the public constructor.<p>
174     */
175    private CmsResourceBundleLoader() {
176
177        // noop
178    }
179
180    /**
181     * Adds the specified resource bundle to the permanent cache.<p>
182     *
183     * @param baseName the raw bundle name, without locale qualifiers
184     * @param locale the locale
185     * @param bundle the bundle to cache
186     */
187    public static void addBundleToCache(String baseName, Locale locale, I_CmsResourceBundle bundle) {
188
189        String key = baseName;
190        if (locale != null) {
191            key += "_" + locale;
192        }
193        m_permanentCache.put(key, bundle);
194    }
195
196    /**
197     * Flushes the complete resource bundle cache.<p>
198     */
199    public static void flushBundleCache() {
200
201        synchronized (m_bundleCache) {
202            m_bundleCache.clear();
203        }
204        // We are not flushing the permanent cache on clear!
205        // Reason: It's not 100% clear if the cache would be filled correctly from the XML after a flush.
206        // For example if a reference to an XML content object is held, than after a clear cache, this
207        // object would not have a working localization since the schema and handler would not be initialized again.
208        // For XML contents that are unmarshalled after the clear cache the localization would work, but it
209        // seems likely that old references are held.
210        // On the other hand, if something is changed in the XML, the cache is updated anyway, so we won't be
211        // stuck with "old" resource bundles that require a server restart.
212
213        // m_permanentCache.clear();
214    }
215
216    /**
217     * Flushes all variations for the provided bundle from the cache.<p>
218     *
219     * @param baseName the bundle base name to flush the variations for
220     * @param flushPermanent if true, the cache for additional message bundles will be flushed, too
221     */
222    public static void flushBundleCache(String baseName, boolean flushPermanent) {
223
224        if (baseName != null) {
225            synchronized (m_bundleCache) {
226
227                // first check and clear the bundle cache
228                Map<BundleKey, ResourceBundle> bundleCacheNew = new ConcurrentHashMap<BundleKey, ResourceBundle>(
229                    m_bundleCache.size());
230                for (Map.Entry<BundleKey, ResourceBundle> entry : m_bundleCache.entrySet()) {
231                    if (!entry.getKey().isSameBase(baseName)) {
232                        // entry has a different base name, keep it
233                        bundleCacheNew.put(entry.getKey(), entry.getValue());
234                    }
235                }
236                if (bundleCacheNew.size() < m_bundleCache.size()) {
237                    // switch caches if only if at least one entry was removed
238                    m_bundleCache = bundleCacheNew;
239                }
240                if (flushPermanent) {
241                    flushPermanentCache(baseName);
242                }
243            }
244        }
245    }
246
247    /**
248     * Removes bundles with the given base name from the permanent cache.<p>
249     *
250     * @param baseName the bundle base name
251     */
252    public static void flushPermanentCache(String baseName) {
253
254        Set<String> keys = new HashSet<String>(m_permanentCache.keySet());
255        for (String key : keys) {
256            if ((key.startsWith(baseName)
257                && ((key.length() == baseName.length()) || (key.charAt(baseName.length()) == '_')))) {
258                // entry has a the same base name, remove it
259                m_permanentCache.remove(key);
260            }
261        }
262    }
263
264    /**
265     * Get the appropriate ResourceBundle for the given locale. The following
266     * strategy is used:
267     *
268     * <p>A sequence of candidate bundle names are generated, and tested in
269     * this order, where the suffix 1 means the string from the specified
270     * locale, and the suffix 2 means the string from the default locale:</p>
271     *
272     * <ul>
273     * <li>baseName + "_" + language1 + "_" + country1 + "_" + variant1</li>
274     * <li>baseName + "_" + language1 + "_" + country1</li>
275     * <li>baseName + "_" + language1</li>
276     * <li>baseName + "_" + language2 + "_" + country2 + "_" + variant2</li>
277     * <li>baseName + "_" + language2 + "_" + country2</li>
278     * <li>baseName + "_" + language2</li>
279     * <li>baseName</li>
280     * </ul>
281     *
282     * <p>In the sequence, entries with an empty string are ignored. Next,
283     * <code>getBundle</code> tries to instantiate the resource bundle:</p>
284     *
285     * <ul>
286     * <li>This implementation only resolves property based resource bundles.
287     * Class based resource bundles are nor found.</li>
288     * <li>A search is made for a property resource file, by replacing
289     * '.' with '/' and appending ".properties", and using
290     * ClassLoader.getResource(). If a file is found, then a
291     * PropertyResourceBundle is created from the file's contents.</li>
292     * </ul>
293     *
294     * <p>If no resource bundle was found, the default resource bundle loader
295     * is used to look for the resource bundle. Class based resource bundles
296     * will be found now.<p>
297     *
298     * @param baseName the name of the ResourceBundle
299     * @param locale A locale
300     * @return the desired resource bundle
301     */
302    // This method is synchronized so that the cache is properly
303    // handled.
304    public static ResourceBundle getBundle(String baseName, Locale locale) {
305
306        // If the default locale changed since the last time we were called,
307        // all cache entries are invalidated.
308        Locale defaultLocale = Locale.getDefault();
309        if (defaultLocale != m_lastDefaultLocale) {
310            synchronized (m_bundleCache) {
311                if (defaultLocale != m_lastDefaultLocale) {
312                    m_bundleCache = new ConcurrentHashMap<BundleKey, ResourceBundle>();
313                    m_lastDefaultLocale = defaultLocale;
314                }
315            }
316        }
317
318        // This will throw NullPointerException if any arguments are null.
319        BundleKey m_lookupKey = new BundleKey(baseName, locale);
320
321        Object obj = m_bundleCache.get(m_lookupKey);
322
323        if (obj instanceof ResourceBundle) {
324            return (ResourceBundle)obj;
325        } else if (obj == NULL_ENTRY) {
326            // Lookup has failed previously. Fall through.
327        } else {
328            synchronized (m_bundleCache) {
329                obj = m_bundleCache.get(m_lookupKey);
330                if (obj instanceof ResourceBundle) {
331                    // check the bundle again
332                    return (ResourceBundle)obj;
333                }
334                // First, look for a bundle for the specified locale. We don't want
335                // the base bundle this time.
336                boolean wantBase = locale.equals(m_lastDefaultLocale);
337                ResourceBundle bundle = tryBundle(baseName, locale, wantBase);
338
339                // Try the default locale if necessary
340                if ((bundle == null) && !locale.equals(m_lastDefaultLocale)) {
341                    bundle = tryBundle(baseName, m_lastDefaultLocale, true);
342                }
343
344                BundleKey key = new BundleKey(baseName, locale);
345                if (bundle != null) {
346                    // Cache the result and return it.
347                    m_bundleCache.put(key, bundle);
348                    return bundle;
349                }
350            }
351        }
352
353        // unable to find the resource bundle with this implementation
354        // use default Java mechanism to look up the bundle again
355        return ResourceBundle.getBundle(baseName, locale);
356    }
357
358    /**
359     * Tries to load a property file with the specified name.
360     *
361     * @param localizedName the name
362     * @return the resource bundle if it was loaded, otherwise the backup
363     */
364    private static I_CmsResourceBundle tryBundle(String localizedName) {
365
366        I_CmsResourceBundle result = null;
367
368        try {
369
370            String resourceName = localizedName.replace('.', '/') + ".properties";
371            URL url = CmsResourceBundleLoader.class.getClassLoader().getResource(resourceName);
372
373            I_CmsResourceBundle additionalBundle = m_permanentCache.get(localizedName);
374            if (additionalBundle != null) {
375                result = additionalBundle.getClone();
376            } else if (url != null) {
377                // the resource was found on the file system
378                InputStream is = null;
379                String path = CmsFileUtil.normalizePath(url);
380                File file = new File(path);
381                try {
382                    // try to load the resource bundle from a file, NOT with the resource loader first
383                    // this is important since using #getResourceAsStream() may return cached results,
384                    // for example Tomcat by default does cache all resources loaded by the class loader
385                    // this means a changed resource bundle file is not loaded
386                    is = new FileInputStream(file);
387                } catch (IOException ex) {
388                    // this will happen if the resource is contained for example in a .jar file
389                    is = CmsResourceBundleLoader.class.getClassLoader().getResourceAsStream(resourceName);
390                } catch (SecurityException acex) {
391                    // fixed bug #1550
392                    // this will happen if the resource is contained for example in a .jar file
393                    // and security manager is turned on.
394                    is = CmsResourceBundleLoader.class.getClassLoader().getResourceAsStream(resourceName);
395                }
396                if (is != null) {
397                    result = new CmsPropertyResourceBundle(is);
398                }
399            }
400        } catch (IOException ex) {
401            // can't localized these message since this may lead to a chicken-egg problem
402            MissingResourceException mre = new MissingResourceException(
403                "Failed to load bundle '" + localizedName + "'",
404                localizedName,
405                "");
406            mre.initCause(ex);
407            throw mre;
408        }
409
410        return result;
411    }
412
413    /**
414     * Tries to load a the bundle for a given locale, also loads the backup
415     * locales with the same language.
416     *
417     * @param baseName the raw bundle name, without locale qualifiers
418     * @param locale the locale
419     * @param wantBase whether a resource bundle made only from the base name
420     *        (with no locale information attached) should be returned.
421     * @return the resource bundle if it was loaded, otherwise the backup
422     */
423    private static ResourceBundle tryBundle(String baseName, Locale locale, boolean wantBase) {
424
425        I_CmsResourceBundle first = null; // The most specialized bundle.
426        I_CmsResourceBundle last = null; // The least specialized bundle.
427
428        List<String> bundleNames = CmsLocaleManager.getLocaleVariants(baseName, locale, true, true);
429        for (String bundleName : bundleNames) {
430            // break if we would try the base bundle, but we do not want it directly
431            if (bundleName.equals(baseName) && !wantBase && (first == null)) {
432                break;
433            }
434            I_CmsResourceBundle foundBundle = tryBundle(bundleName);
435            if (foundBundle != null) {
436                if (first == null) {
437                    first = foundBundle;
438                }
439
440                if (last != null) {
441                    last.setParent((ResourceBundle)foundBundle);
442                }
443                foundBundle.setLocale(locale);
444
445                last = foundBundle;
446            }
447        }
448        return (ResourceBundle)first;
449    }
450}