package com.devtrigger.grails.icu;
import com.ibm.icu.text.MessageFormat;
import grails.util.CacheEntry;
import grails.util.Pair;
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;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* ICU4j MessageFormat aware {@link org.codehaus.groovy.grails.context.support.ReloadableResourceBundleMessageSource}
* drop-in
* @see com.ibm.icu.text.MessageFormat
*/
public class ICUReloadableResourceBundleMessageSource extends ICUAbstractMessageSource
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;
protected long cacheMillis = -1;
protected long fileCacheMillis = Long.MIN_VALUE;
private PropertiesPersister propertiesPersister = new DefaultPropertiesPersister();
private ResourceLoader resourceLoader = new DefaultResourceLoader();
/** Cache to hold filename lists per Locale */
private final ConcurrentMap<Pair<String, Locale>, CacheEntry<List<Pair<String, Resource>>>> cachedFilenames =
new ConcurrentHashMap<Pair<String, Locale>, CacheEntry<List<Pair<String, Resource>>>>();
/** Cache to hold already loaded properties per filename */
private final ConcurrentMap<String, CacheEntry<PropertiesHolder>> cachedProperties = new ConcurrentHashMap<String, CacheEntry<PropertiesHolder>>();
/** Cache to hold merged loaded properties per locale */
private final ConcurrentMap<Locale, CacheEntry<PropertiesHolder>> cachedMergedProperties = new ConcurrentHashMap<Locale, CacheEntry<PropertiesHolder>>();
private final ConcurrentMap<String, CacheEntry<Resource>> cachedResources = new ConcurrentHashMap<String, CacheEntry<Resource>>();
/**
* Set a single basename, following the basic ResourceBundle convention of
* not specifying file extension or language codes, but in contrast to
* {@link org.springframework.context.support.ResourceBundleMessageSource} referring to a Spring resource location:
* e.g. "WEB-INF/messages" for "WEB-INF/messages.properties",
* "WEB-INF/messages_en.properties", etc.
* <p>XML properties files are also supported: .g. "WEB-INF/messages" will find
* and load "WEB-INF/messages.xml", "WEB-INF/messages_en.xml", etc as well.
* @param basename the single basename
* @see #setBasenames
* @see org.springframework.core.io.ResourceEditor
* @see java.util.ResourceBundle
*/
public void setBasename(String basename) {
setBasenames(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 org.springframework.context.support.ResourceBundleMessageSource} referring to a Spring resource location:
* e.g. "WEB-INF/messages" for "WEB-INF/messages.properties",
* "WEB-INF/messages_en.properties", etc.
* <p>XML properties files are also supported: .g. "WEB-INF/messages" will find
* and load "WEB-INF/messages.xml", "WEB-INF/messages_en.xml", etc as well.
* <p>The associated resource bundles will be checked sequentially when resolving
* a message code. Note that message definitions in a <i>previous</i> 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.
* <p>Default is none, using the {@code java.util.Properties}
* default encoding: ISO-8859-1.
* <p>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.
* <p>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").
* <p>Falling back to the system Locale is the default behavior of
* {@code 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 the list of matching properties files.
* <ul>
* <li>Default is "-1", indicating to cache forever (just like
* {@code java.util.ResourceBundle}).
* <li>A positive number will cache the list of matching properties files for the given
* number of seconds. This is essentially the interval between refresh checks.
* <li>A value of "0" will attemp to list the matching properties files on
* every message access. <b>Do not use this in a production environment!</b>
* </ul>
*/
public void setCacheSeconds(int cacheSeconds) {
this.cacheMillis = (cacheSeconds * 1000);
if(fileCacheMillis==Long.MIN_VALUE) {
this.fileCacheMillis = this.cacheMillis;
}
}
/**
* Set the number of seconds to cache loaded properties files.
* <ul>
* <li>Default value is the same value as cacheSeconds
* <li>A positive number will cache loaded properties files for the given
* number of seconds. This is essentially the interval between refresh checks.
* Note that a refresh attempt will first check the last-modified timestamp
* of the file before actually reloading it; so if files don't change, this
* interval can be set rather low, as refresh attempts will not actually reload.
* <li>A value of "0" will check the last-modified timestamp of the file on
* every message access. <b>Do not use this in a production environment!</b>
* </ul>
*/
public void setFileCacheSeconds(int fileCacheSeconds) {
this.fileCacheMillis = (fileCacheSeconds * 1000);
}
/**
* Set the PropertiesPersister to use for parsing properties files.
* <p>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.
* <p>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 (String basename : this.basenames) {
List<Pair<String, Resource>> filenamesAndResources = calculateAllFilenames(basename, locale);
for (Pair<String, Resource> filenameAndResource : filenamesAndResources) {
if(filenameAndResource.getbValue() != null) {
PropertiesHolder propHolder = getProperties(filenameAndResource.getaValue(), filenameAndResource.getbValue());
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 (String basename : this.basenames) {
List<Pair<String, Resource>> filenamesAndResources = calculateAllFilenames(basename, locale);
for (Pair<String, Resource> filenameAndResource : filenamesAndResources) {
if(filenameAndResource.getbValue() != null) {
PropertiesHolder propHolder = getProperties(filenameAndResource.getaValue(), filenameAndResource.getbValue());
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.
* <p>Only used when caching resource bundle contents forever, i.e.
* with cacheSeconds < 0. Therefore, merged properties are always
* cached forever.
*/
protected PropertiesHolder getMergedProperties(final Locale locale) {
return CacheEntry.getValue(cachedMergedProperties, locale, cacheMillis, new Callable<PropertiesHolder>() {
@Override
public PropertiesHolder call() throws Exception {
Properties mergedProps = new Properties();
PropertiesHolder mergedHolder = new PropertiesHolder(mergedProps);
for (int i = basenames.length - 1; i >= 0; i--) {
List<Pair<String, Resource>> filenamesAndResources = calculateAllFilenames(basenames[i], locale);
for (int j = filenamesAndResources.size() - 1; j >= 0; j--) {
Pair<String, Resource> filenameAndResource = filenamesAndResources.get(j);
if (filenameAndResource.getbValue() != null) {
PropertiesHolder propHolder = getProperties(filenameAndResource.getaValue(), filenameAndResource.getbValue());
mergedProps.putAll(propHolder.getProperties());
}
}
}
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<Pair<String, Resource>> calculateAllFilenames(final String basename, final Locale locale) {
Pair<String, Locale> cacheKey = new Pair<String, Locale>(basename, locale);
return CacheEntry.getValue(cachedFilenames, cacheKey, cacheMillis, new Callable<List<Pair<String, Resource>>>() {
@Override
public List<Pair<String, Resource>> call() throws Exception {
List<String> filenames = new ArrayList<String>(7);
filenames.addAll(calculateFilenamesForLocale(basename, locale));
if (fallbackToSystemLocale && !locale.equals(Locale.getDefault())) {
List<String> fallbackFilenames = calculateFilenamesForLocale(basename, Locale.getDefault());
for (String fallbackFilename : fallbackFilenames) {
if (!filenames.contains(fallbackFilename)) {
// Entry for fallback locale that isn't already in filenames list.
filenames.add(fallbackFilename);
}
}
}
filenames.add(basename);
List<Pair<String, Resource>> filenamesAndResources = new ArrayList<Pair<String, Resource>>(filenames.size());
for (String filename : filenames) {
filenamesAndResources.add(new Pair<String, Resource>(filename, locateResource(filename)));
}
return filenamesAndResources;
}
});
}
/**
* 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".
* <p>Follows the rules defined by {@link java.util.Locale#toString()}.
* @param basename the basename of the bundle
* @param locale the locale
* @return the List of filenames to check
*/
protected List<String> calculateFilenamesForLocale(String basename, Locale locale) {
List<String> result = new ArrayList<String>(3);
String language = locale.getLanguage();
String country = locale.getCountry();
String variant = locale.getVariant();
StringBuilder temp = new StringBuilder(basename);
temp.append('_');
if (language.length() > 0) {
temp.append(language);
result.add(0, temp.toString());
}
temp.append('_');
if (country.length() > 0) {
temp.append(country);
result.add(0, temp.toString());
}
if (variant.length() > 0 && (language.length() > 0 || country.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
*/
@SuppressWarnings("rawtypes")
protected PropertiesHolder getProperties(final String filename, final Resource resource) {
return CacheEntry.getValue(cachedProperties, filename, fileCacheMillis, new Callable<PropertiesHolder>() {
@Override
public PropertiesHolder call() throws Exception {
return new PropertiesHolder(filename, resource);
}
}, new Callable<CacheEntry>() {
@Override
public CacheEntry call() throws Exception {
return new PropertiesHolderCacheEntry();
}
}, true, null);
}
protected static class PropertiesHolderCacheEntry extends CacheEntry<PropertiesHolder> {
public PropertiesHolderCacheEntry() {
super();
}
@Override
protected PropertiesHolder updateValue(PropertiesHolder oldValue, Callable<PropertiesHolder> updater, Object cacheRequestObject)
throws Exception {
if(oldValue != null) {
oldValue.update();
return oldValue;
}
return updater.call();
}
}
/**
* 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 java.io.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");
this.cachedProperties.clear();
this.cachedMergedProperties.clear();
this.cachedFilenames.clear();
this.cachedResources.clear();
}
/**
* Clear the resource bundle caches of this MessageSource and all its ancestors.
* @see #clearCache
*/
public void clearCacheIncludingAncestors() {
clearCache();
if (getParentMessageSource() instanceof ICUReloadableResourceBundleMessageSource) {
((ICUReloadableResourceBundleMessageSource) getParentMessageSource()).clearCacheIncludingAncestors();
} else if (getParentMessageSource() instanceof org.springframework.context.support.ReloadableResourceBundleMessageSource) {
((org.springframework.context.support.ReloadableResourceBundleMessageSource) getParentMessageSource()).clearCacheIncludingAncestors();
}
}
@Override
public String toString() {
return getClass().getName() + ": basenames=[" + StringUtils.arrayToCommaDelimitedString(this.basenames) + "]";
}
protected Resource locateResource(final String filename) {
return CacheEntry.getValue(cachedResources, filename, cacheMillis, new Callable<Resource>() {
@Override
public Resource call() throws Exception {
return locateResourceWithoutCache(filename);
}
});
}
protected Resource locateResourceWithoutCache(String filename) {
Resource resource = resourceLoader.getResource(filename + PROPERTIES_SUFFIX);
if (!resource.exists()) {
resource = resourceLoader.getResource(filename + XML_SUFFIX);
}
if (resource.exists()) {
return resource;
} else {
return null;
}
}
/**
* 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 String filename;
private Resource resource;
private long fileTimestamp = -1;
/** Cache to hold already generated MessageFormats per message code */
private final ConcurrentMap<Pair<String, Locale>, CacheEntry<MessageFormat>> cachedMessageFormats =
new ConcurrentHashMap<Pair<String, Locale>, CacheEntry<MessageFormat>>();
public PropertiesHolder(String filename, Resource resource) {
this.filename = filename;
this.resource = resource;
doUpdate(true);
}
public PropertiesHolder(Properties properties) {
this.properties = properties;
}
public boolean update() {
return doUpdate(false);
}
private boolean doUpdate(boolean initialization) {
if(filename == null) {
return false;
}
if(!initialization && cacheMillis >= 0) {
resource = locateResource(filename);
}
if(resource != null) {
long newFileTimestamp;
try {
newFileTimestamp = resource.lastModified();
} catch (IOException ex) {
if (logger.isDebugEnabled()) {
logger.debug(
resource + " could not be resolved in the file system - assuming that is hasn't changed", ex);
}
newFileTimestamp = -1;
}
if (fileCacheMillis >= 0 && newFileTimestamp == fileTimestamp && this.properties != null) {
return false;
}
try {
this.properties = loadProperties(resource, filename);
this.fileTimestamp = newFileTimestamp;
this.cachedMessageFormats.clear();
}
catch (IOException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Could not parse properties file [" + resource.getFilename() + "]", ex);
}
}
return true;
}
else {
// Resource does not exist.
if (logger.isDebugEnabled()) {
logger.debug("No properties file found for [" + filename + "] - neither plain properties nor XML");
}
this.properties = new Properties();
this.fileTimestamp = -1;
this.cachedMessageFormats.clear();
return true;
}
}
public String getFilename() {
return filename;
}
public Properties getProperties() {
return properties;
}
public long getFileTimestamp() {
return fileTimestamp;
}
public String getProperty(String code) {
if (this.properties == null) {
return null;
}
return this.properties.getProperty(code);
}
public MessageFormat getMessageFormat(final String code, final Locale locale) {
if (this.properties == null) {
return null;
}
Pair<String, Locale> cacheKey = new Pair<String, Locale>(code, locale);
return CacheEntry.getValue(cachedMessageFormats, cacheKey, -1, new Callable<MessageFormat>() {
@Override
public MessageFormat call() throws Exception {
String msg = properties.getProperty(code);
if (msg != null) {
return createMessageFormat(msg, locale);
} else {
return null;
}
}
});
}
}
}