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