/* * Copyright 2002-2008 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.springframework.context.support; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.text.MessageFormat; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import org.springframework.context.ResourceLoaderAware; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.util.Assert; import org.springframework.util.DefaultPropertiesPersister; import org.springframework.util.PropertiesPersister; import org.springframework.util.StringUtils; /** * {@link org.springframework.context.MessageSource} implementation that * accesses resource bundles using specified basenames. This class uses * {@link java.util.Properties} instances as its custom data structure for * messages, loading them via a {@link org.springframework.util.PropertiesPersister} * strategy: The default strategy is capable of loading properties files * with a specific character encoding, if desired. * *
In contrast to {@link ResourceBundleMessageSource}, this class supports * reloading of properties files through the {@link #setCacheSeconds "cacheSeconds"} * setting, and also through programmatically clearing the properties cache. * Since application servers typically cache all files loaded from the classpath, * it is necessary to store resources somewhere else (for example, in the * "WEB-INF" directory of a web app). Otherwise changes of files in the * classpath will not be reflected in the application. * *
Note that the base names set as {@link #setBasenames "basenames"} property * are treated in a slightly different fashion than the "basenames" property of * {@link ResourceBundleMessageSource}. It follows the basic ResourceBundle rule of not * specifying file extension or language codes, but can refer to any Spring resource * location (instead of being restricted to classpath resources). With a "classpath:" * prefix, resources can still be loaded from the classpath, but "cacheSeconds" values * other than "-1" (caching forever) will not work in this case. * *
This MessageSource implementation is usually slightly faster than * {@link ResourceBundleMessageSource}, which builds on {@link java.util.ResourceBundle} * - in the default mode, i.e. when caching forever. With "cacheSeconds" set to 1, * message lookup takes about twice as long - with the benefit that changes in * individual properties files are detected with a maximum delay of 1 second. * Higher "cacheSeconds" values usually do not make a significant difference. * *
This MessageSource can easily be used outside of an * {@link org.springframework.context.ApplicationContext}: It will use a * {@link org.springframework.core.io.DefaultResourceLoader} as default, * simply getting overridden with the ApplicationContext's resource loader * if running in a context. It does not have any other specific dependencies. * *
Thanks to Thomas Achleitner for providing the initial implementation of * this message source! * * @author Juergen Hoeller * @see #setCacheSeconds * @see #setBasenames * @see #setDefaultEncoding * @see #setFileEncodings * @see #setPropertiesPersister * @see #setResourceLoader * @see org.springframework.util.DefaultPropertiesPersister * @see org.springframework.core.io.DefaultResourceLoader * @see ResourceBundleMessageSource * @see java.util.ResourceBundle */ public class ReloadableResourceBundleMessageSource extends AbstractMessageSource implements ResourceLoaderAware { private static final String PROPERTIES_SUFFIX = ".properties"; private static final String XML_SUFFIX = ".xml"; private String[] basenames = new String[0]; private String defaultEncoding; private Properties fileEncodings; private boolean fallbackToSystemLocale = true; private long cacheMillis = -1; private PropertiesPersister propertiesPersister = new DefaultPropertiesPersister(); private ResourceLoader resourceLoader = new DefaultResourceLoader(); /** Cache to hold filename lists per Locale */ private final Map cachedFilenames = new HashMap(); /** Cache to hold already loaded properties per filename */ private final Map cachedProperties = new HashMap(); /** Cache to hold merged loaded properties per basename */ private final Map cachedMergedProperties = new HashMap(); /** * Set a single basename, following the basic ResourceBundle convention of * not specifying file extension or language codes, but in contrast to * {@link ResourceBundleMessageSource} referring to a Spring resource location: * e.g. "WEB-INF/messages" for "WEB-INF/messages.properties", * "WEB-INF/messages_en.properties", etc. *
As of Spring 1.2.2, XML properties files are also supported: * e.g. "WEB-INF/messages" will find and load "WEB-INF/messages.xml", * "WEB-INF/messages_en.xml", etc as well. Note that this will only * work on JDK 1.5+. * @param basename the single basename * @see #setBasenames * @see org.springframework.core.io.ResourceEditor * @see java.util.ResourceBundle */ public void setBasename(String basename) { setBasenames(new String[] {basename}); } /** * Set an array of basenames, each following the basic ResourceBundle convention * of not specifying file extension or language codes, but in contrast to * {@link ResourceBundleMessageSource} referring to a Spring resource location: * e.g. "WEB-INF/messages" for "WEB-INF/messages.properties", * "WEB-INF/messages_en.properties", etc. *
As of Spring 1.2.2, XML properties files are also supported: * e.g. "WEB-INF/messages" will find and load "WEB-INF/messages.xml", * "WEB-INF/messages_en.xml", etc as well. Note that this will only * work on JDK 1.5+. *
The associated resource bundles will be checked sequentially * when resolving a message code. Note that message definitions in a * previous resource bundle will override ones in a later bundle, * due to the sequential lookup. * @param basenames an array of basenames * @see #setBasename * @see java.util.ResourceBundle */ public void setBasenames(String[] basenames) { if (basenames != null) { this.basenames = new String[basenames.length]; for (int i = 0; i < basenames.length; i++) { String basename = basenames[i]; Assert.hasText(basename, "Basename must not be empty"); this.basenames[i] = basename.trim(); } } else { this.basenames = new String[0]; } } /** * Set the default charset to use for parsing properties files. * Used if no file-specific charset is specified for a file. *
Default is none, using the java.util.Properties
* default encoding.
*
Only applies to classic properties files, not to XML files. * @param defaultEncoding the default charset * @see #setFileEncodings * @see org.springframework.util.PropertiesPersister#load */ public void setDefaultEncoding(String defaultEncoding) { this.defaultEncoding = defaultEncoding; } /** * Set per-file charsets to use for parsing properties files. *
Only applies to classic properties files, not to XML files. * @param fileEncodings Properties with filenames as keys and charset * names as values. Filenames have to match the basename syntax, * with optional locale-specific appendices: e.g. "WEB-INF/messages" * or "WEB-INF/messages_en". * @see #setBasenames * @see org.springframework.util.PropertiesPersister#load */ public void setFileEncodings(Properties fileEncodings) { this.fileEncodings = fileEncodings; } /** * Set whether to fall back to the system Locale if no files for a specific * Locale have been found. Default is "true"; if this is turned off, the only * fallback will be the default file (e.g. "messages.properties" for * basename "messages"). *
Falling back to the system Locale is the default behavior of
* java.util.ResourceBundle
. However, this is often not
* desirable in an application server environment, where the system Locale
* is not relevant to the application at all: Set this flag to "false"
* in such a scenario.
*/
public void setFallbackToSystemLocale(boolean fallbackToSystemLocale) {
this.fallbackToSystemLocale = fallbackToSystemLocale;
}
/**
* Set the number of seconds to cache loaded properties files.
*
java.util.ResourceBundle
).
* The default is a DefaultPropertiesPersister. * @see org.springframework.util.DefaultPropertiesPersister */ public void setPropertiesPersister(PropertiesPersister propertiesPersister) { this.propertiesPersister = (propertiesPersister != null ? propertiesPersister : new DefaultPropertiesPersister()); } /** * Set the ResourceLoader to use for loading bundle properties files. *
The default is a DefaultResourceLoader. Will get overridden by the * ApplicationContext if running in a context, as it implements the * ResourceLoaderAware interface. Can be manually overridden when * running outside of an ApplicationContext. * @see org.springframework.core.io.DefaultResourceLoader * @see org.springframework.context.ResourceLoaderAware */ public void setResourceLoader(ResourceLoader resourceLoader) { this.resourceLoader = (resourceLoader != null ? resourceLoader : new DefaultResourceLoader()); } /** * Resolves the given message code as key in the retrieved bundle files, * returning the value found in the bundle as-is (without MessageFormat parsing). */ @Override protected String resolveCodeWithoutArguments(String code, Locale locale) { if (this.cacheMillis < 0) { PropertiesHolder propHolder = getMergedProperties(locale); String result = propHolder.getProperty(code); if (result != null) { return result; } } else { for (int i = 0; i < this.basenames.length; i++) { List filenames = calculateAllFilenames(this.basenames[i], locale); for (int j = 0; j < filenames.size(); j++) { String filename = (String) filenames.get(j); PropertiesHolder propHolder = getProperties(filename); String result = propHolder.getProperty(code); if (result != null) { return result; } } } } return null; } /** * Resolves the given message code as key in the retrieved bundle files, * using a cached MessageFormat instance per message code. */ @Override protected MessageFormat resolveCode(String code, Locale locale) { if (this.cacheMillis < 0) { PropertiesHolder propHolder = getMergedProperties(locale); MessageFormat result = propHolder.getMessageFormat(code, locale); if (result != null) { return result; } } else { for (int i = 0; i < this.basenames.length; i++) { List filenames = calculateAllFilenames(this.basenames[i], locale); for (int j = 0; j < filenames.size(); j++) { String filename = (String) filenames.get(j); PropertiesHolder propHolder = getProperties(filename); MessageFormat result = propHolder.getMessageFormat(code, locale); if (result != null) { return result; } } } } return null; } /** * Get a PropertiesHolder that contains the actually visible properties * for a Locale, after merging all specified resource bundles. * Either fetches the holder from the cache or freshly loads it. *
Only used when caching resource bundle contents forever, i.e.
* with cacheSeconds < 0. Therefore, merged properties are always
* cached forever.
*/
protected PropertiesHolder getMergedProperties(Locale locale) {
synchronized (this.cachedMergedProperties) {
PropertiesHolder mergedHolder = (PropertiesHolder) this.cachedMergedProperties.get(locale);
if (mergedHolder != null) {
return mergedHolder;
}
Properties mergedProps = new Properties();
mergedHolder = new PropertiesHolder(mergedProps, -1);
for (int i = this.basenames.length - 1; i >= 0; i--) {
List filenames = calculateAllFilenames(this.basenames[i], locale);
for (int j = filenames.size() - 1; j >= 0; j--) {
String filename = (String) filenames.get(j);
PropertiesHolder propHolder = getProperties(filename);
if (propHolder.getProperties() != null) {
mergedProps.putAll(propHolder.getProperties());
}
}
}
this.cachedMergedProperties.put(locale, mergedHolder);
return mergedHolder;
}
}
/**
* Calculate all filenames for the given bundle basename and Locale.
* Will calculate filenames for the given Locale, the system Locale
* (if applicable), and the default file.
* @param basename the basename of the bundle
* @param locale the locale
* @return the List of filenames to check
* @see #setFallbackToSystemLocale
* @see #calculateFilenamesForLocale
*/
protected List calculateAllFilenames(String basename, Locale locale) {
synchronized (this.cachedFilenames) {
Map localeMap = (Map) this.cachedFilenames.get(basename);
if (localeMap != null) {
List filenames = (List) localeMap.get(locale);
if (filenames != null) {
return filenames;
}
}
List filenames = new ArrayList(7);
filenames.addAll(calculateFilenamesForLocale(basename, locale));
if (this.fallbackToSystemLocale && !locale.equals(Locale.getDefault())) {
List fallbackFilenames = calculateFilenamesForLocale(basename, Locale.getDefault());
for (Iterator it = fallbackFilenames.iterator(); it.hasNext();) {
String fallbackFilename = (String) it.next();
if (!filenames.contains(fallbackFilename)) {
// Entry for fallback locale that isn't already in filenames list.
filenames.add(fallbackFilename);
}
}
}
filenames.add(basename);
if (localeMap != null) {
localeMap.put(locale, filenames);
}
else {
localeMap = new HashMap();
localeMap.put(locale, filenames);
this.cachedFilenames.put(basename, localeMap);
}
return filenames;
}
}
/**
* Calculate the filenames for the given bundle basename and Locale,
* appending language code, country code, and variant code.
* E.g.: basename "messages", Locale "de_AT_oo" -> "messages_de_AT_OO",
* "messages_de_AT", "messages_de".
* @param basename the basename of the bundle
* @param locale the locale
* @return the List of filenames to check
*/
protected List calculateFilenamesForLocale(String basename, Locale locale) {
List result = new ArrayList(3);
String language = locale.getLanguage();
String country = locale.getCountry();
String variant = locale.getVariant();
StringBuffer temp = new StringBuffer(basename);
if (language.length() > 0) {
temp.append('_').append(language);
result.add(0, temp.toString());
}
if (country.length() > 0) {
temp.append('_').append(country);
result.add(0, temp.toString());
}
if (variant.length() > 0) {
temp.append('_').append(variant);
result.add(0, temp.toString());
}
return result;
}
/**
* Get a PropertiesHolder for the given filename, either from the
* cache or freshly loaded.
* @param filename the bundle filename (basename + Locale)
* @return the current PropertiesHolder for the bundle
*/
protected PropertiesHolder getProperties(String filename) {
synchronized (this.cachedProperties) {
PropertiesHolder propHolder = (PropertiesHolder) this.cachedProperties.get(filename);
if (propHolder != null &&
(propHolder.getRefreshTimestamp() < 0 ||
propHolder.getRefreshTimestamp() > System.currentTimeMillis() - this.cacheMillis)) {
// up to date
return propHolder;
}
return refreshProperties(filename, propHolder);
}
}
/**
* Refresh the PropertiesHolder for the given bundle filename.
* The holder can be null
if not cached before, or a timed-out cache entry
* (potentially getting re-validated against the current last-modified timestamp).
* @param filename the bundle filename (basename + Locale)
* @param propHolder the current PropertiesHolder for the bundle
*/
protected PropertiesHolder refreshProperties(String filename, PropertiesHolder propHolder) {
long refreshTimestamp = (this.cacheMillis < 0) ? -1 : System.currentTimeMillis();
Resource resource = this.resourceLoader.getResource(filename + PROPERTIES_SUFFIX);
if (!resource.exists()) {
resource = this.resourceLoader.getResource(filename + XML_SUFFIX);
}
if (resource.exists()) {
long fileTimestamp = -1;
if (this.cacheMillis >= 0) {
// Last-modified timestamp of file will just be read if caching with timeout.
try {
fileTimestamp = resource.lastModified();
if (propHolder != null && propHolder.getFileTimestamp() == fileTimestamp) {
if (logger.isDebugEnabled()) {
logger.debug("Re-caching properties for filename [" + filename + "] - file hasn't been modified");
}
propHolder.setRefreshTimestamp(refreshTimestamp);
return propHolder;
}
}
catch (IOException ex) {
// Probably a class path resource: cache it forever.
if (logger.isDebugEnabled()) {
logger.debug(
resource + " could not be resolved in the file system - assuming that is hasn't changed", ex);
}
fileTimestamp = -1;
}
}
try {
Properties props = loadProperties(resource, filename);
propHolder = new PropertiesHolder(props, fileTimestamp);
}
catch (IOException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Could not parse properties file [" + resource.getFilename() + "]", ex);
}
// Empty holder representing "not valid".
propHolder = new PropertiesHolder();
}
}
else {
// Resource does not exist.
if (logger.isDebugEnabled()) {
logger.debug("No properties file found for [" + filename + "] - neither plain properties nor XML");
}
// Empty holder representing "not found".
propHolder = new PropertiesHolder();
}
propHolder.setRefreshTimestamp(refreshTimestamp);
this.cachedProperties.put(filename, propHolder);
return propHolder;
}
/**
* Load the properties from the given resource.
* @param resource the resource to load from
* @param filename the original bundle filename (basename + Locale)
* @return the populated Properties instance
* @throws IOException if properties loading failed
*/
protected Properties loadProperties(Resource resource, String filename) throws IOException {
InputStream is = resource.getInputStream();
Properties props = new Properties();
try {
if (resource.getFilename().endsWith(XML_SUFFIX)) {
if (logger.isDebugEnabled()) {
logger.debug("Loading properties [" + resource.getFilename() + "]");
}
this.propertiesPersister.loadFromXml(props, is);
}
else {
String encoding = null;
if (this.fileEncodings != null) {
encoding = this.fileEncodings.getProperty(filename);
}
if (encoding == null) {
encoding = this.defaultEncoding;
}
if (encoding != null) {
if (logger.isDebugEnabled()) {
logger.debug("Loading properties [" + resource.getFilename() + "] with encoding '" + encoding + "'");
}
this.propertiesPersister.load(props, new InputStreamReader(is, encoding));
}
else {
if (logger.isDebugEnabled()) {
logger.debug("Loading properties [" + resource.getFilename() + "]");
}
this.propertiesPersister.load(props, is);
}
}
return props;
}
finally {
is.close();
}
}
/**
* Clear the resource bundle cache.
* Subsequent resolve calls will lead to reloading of the properties files.
*/
public void clearCache() {
logger.debug("Clearing entire resource bundle cache");
synchronized (this.cachedProperties) {
this.cachedProperties.clear();
}
synchronized (this.cachedMergedProperties) {
this.cachedMergedProperties.clear();
}
}
/**
* Clear the resource bundle caches of this MessageSource and all its ancestors.
* @see #clearCache
*/
public void clearCacheIncludingAncestors() {
clearCache();
if (getParentMessageSource() instanceof ReloadableResourceBundleMessageSource) {
((ReloadableResourceBundleMessageSource) getParentMessageSource()).clearCacheIncludingAncestors();
}
}
@Override
public String toString() {
return getClass().getName() + ": basenames=[" + StringUtils.arrayToCommaDelimitedString(this.basenames) + "]";
}
/**
* PropertiesHolder for caching.
* Stores the last-modified timestamp of the source file for efficient
* change detection, and the timestamp of the last refresh attempt
* (updated every time the cache entry gets re-validated).
*/
protected class PropertiesHolder {
private Properties properties;
private long fileTimestamp = -1;
private long refreshTimestamp = -1;
/** Cache to hold already generated MessageFormats per message code */
private final Map cachedMessageFormats = new HashMap();
public PropertiesHolder(Properties properties, long fileTimestamp) {
this.properties = properties;
this.fileTimestamp = fileTimestamp;
}
public PropertiesHolder() {
}
public Properties getProperties() {
return properties;
}
public long getFileTimestamp() {
return fileTimestamp;
}
public void setRefreshTimestamp(long refreshTimestamp) {
this.refreshTimestamp = refreshTimestamp;
}
public long getRefreshTimestamp() {
return refreshTimestamp;
}
public String getProperty(String code) {
if (this.properties == null) {
return null;
}
return this.properties.getProperty(code);
}
public MessageFormat getMessageFormat(String code, Locale locale) {
if (this.properties == null) {
return null;
}
synchronized (this.cachedMessageFormats) {
Map localeMap = (Map) this.cachedMessageFormats.get(code);
if (localeMap != null) {
MessageFormat result = (MessageFormat) localeMap.get(locale);
if (result != null) {
return result;
}
}
String msg = this.properties.getProperty(code);
if (msg != null) {
if (localeMap == null) {
localeMap = new HashMap();
this.cachedMessageFormats.put(code, localeMap);
}
MessageFormat result = createMessageFormat(msg, locale);
localeMap.put(locale, result);
return result;
}
return null;
}
}
}
}