Created
August 11, 2013 05:51
-
-
Save monzou/6203570 to your computer and use it in GitHub Desktop.
UTF-8 based ResourceBundle.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.usopla.gist; | |
import java.io.BufferedReader; | |
import java.io.IOException; | |
import java.io.InputStream; | |
import java.io.InputStreamReader; | |
import java.io.Reader; | |
import java.net.URL; | |
import java.net.URLConnection; | |
import java.security.AccessController; | |
import java.security.PrivilegedActionException; | |
import java.security.PrivilegedExceptionAction; | |
import java.text.MessageFormat; | |
import java.util.Collections; | |
import java.util.Enumeration; | |
import java.util.List; | |
import java.util.Locale; | |
import java.util.Map; | |
import java.util.MissingResourceException; | |
import java.util.PropertyResourceBundle; | |
import java.util.ResourceBundle; | |
import java.util.ResourceBundle.Control; | |
import javax.annotation.CheckForNull; | |
import javax.annotation.Nullable; | |
import com.usopla.gist.LocaleChangeListener; | |
import com.usopla.gist.LocaleHolder; | |
import com.google.common.base.Function; | |
import com.google.common.base.Strings; | |
import com.google.common.collect.ImmutableList; | |
import com.google.common.collect.Maps; | |
import com.google.common.io.Closer; | |
/** | |
* リソースバンドルを取り扱うユーティリティです。 | |
* <p> | |
* {@link LocaleHolder} に対応した国際化対応を行います。<br /> | |
* 現在の {@link Locale} に対応するリソースバンドルが存在しない場合, フォールバック処理を行いません。 但し | |
* <code>@default</code> という接尾辞のバンドルがある場合, そのバンドルをフォールバック先として採用します。 | |
* <p> | |
* properties ファイルは UTF-8 を想定しています。native2ascii で変換する必要はありません。 | |
* | |
* @author monzou | |
*/ | |
public final class I18nBundle implements Function<String, String> { | |
/** リソースが見つからなかった場合にスローされる例外 */ | |
@SuppressWarnings("serial") | |
public static class ResourceNotFoundException extends RuntimeException { | |
ResourceNotFoundException(String message) { | |
super(message); | |
} | |
} | |
private static final String DEFAULT_SUFFIX = "@default"; | |
private static final String CHARSET_NAME = "UTF-8"; | |
private static final Map<Locale, Map<String, I18nBundle>> REPOSITORY = Maps.newHashMap(); | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param clazz クラス | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Class<?> clazz) { | |
return getBundle(clazz, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param clazz クラス | |
* @param parent 親 {@link I18nBundle} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Class<?> clazz, @Nullable I18nBundle parent) { | |
return getBundle(clazz, parent, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param clazz クラス | |
* @param parent 親 {@link I18nBundle} | |
* @param locale {@link Locale} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Class<?> clazz, @Nullable I18nBundle parent, @Nullable Locale locale) { | |
return getBundle(clazz.getName(), parent, locale); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param pkg パッケージ | |
* @param baseName リソースバンドル名称 | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Package pkg, String baseName) { | |
return getBundle(pkg, baseName, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param pkg パッケージ | |
* @param baseName リソースバンドル名称 | |
* @param parent 親 {@link I18nBundle} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Package pkg, String baseName, @Nullable I18nBundle parent) { | |
return getBundle(pkg, baseName, parent, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param pkg パッケージ | |
* @param baseName リソースバンドル名称 | |
* @param parent 親 {@link I18nBundle} | |
* @param locale {@link Locale} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(Package pkg, String baseName, @Nullable I18nBundle parent, @Nullable Locale locale) { | |
return getBundle(String.format("%s.%s", pkg.getName(), baseName), parent, locale); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param baseName リソースバンドル名称 | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(String baseName) { | |
return getBundle(baseName, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param baseName リソースバンドル名称 | |
* @param parent 親 {@link I18nBundle} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(String baseName, @Nullable I18nBundle parent) { | |
return getBundle(baseName, parent, null); | |
} | |
/** | |
* {@link I18nBundle} を取得します。 | |
* | |
* @param baseName リソースバンドル名称 | |
* @param parent 親 {@link I18nBundle} | |
* @param locale {@link Locale} | |
* @return {@link I18nBundle} | |
*/ | |
public static I18nBundle getBundle(String baseName, @Nullable I18nBundle parent, @Nullable Locale locale) { | |
Locale bundleLocale = locale == null ? LocaleHolder.getInstance().get() : locale; | |
synchronized (REPOSITORY) { | |
Map<String, I18nBundle> cache = REPOSITORY.get(bundleLocale); | |
if (cache == null) { | |
cache = Maps.newHashMap(); | |
REPOSITORY.put(bundleLocale, cache); | |
} | |
I18nBundle bundle = cache.get(baseName); | |
if (bundle == null) { | |
bundle = new I18nBundle(baseName, parent, bundleLocale); | |
cache.put(baseName, bundle); | |
} | |
return bundle; | |
} | |
} | |
/** | |
* キャッシュを破棄します。 | |
*/ | |
static void clearCache() { | |
synchronized (REPOSITORY) { | |
REPOSITORY.clear(); | |
} | |
} | |
private volatile I18nBundle parent; | |
private volatile ResourceBundle defaultBundle; | |
private volatile ResourceBundle bundle; | |
private I18nBundle(final String baseName, I18nBundle parent, Locale locale) { | |
this.parent = parent; | |
LocaleChangeListener localeChangeHandler = new LocaleChangeListener() { | |
@Override | |
public void localeChanged(Locale oldLocale, Locale newLocale) { | |
refresh(baseName, oldLocale, newLocale); | |
} | |
}; | |
try { | |
LocaleHolder.getInstance().addLocaleChangeListener(localeChangeHandler); | |
refresh(baseName, null, locale); | |
} catch (RuntimeException e) { | |
LocaleHolder.getInstance().removeLocaleChangeListener(localeChangeHandler); | |
throw e; | |
} | |
} | |
private void refresh(String baseName, @Nullable Locale oldLocale, @Nullable Locale newLocale) { | |
synchronized (REPOSITORY) { | |
bundle = innerRefresh(baseName, oldLocale, newLocale); | |
defaultBundle = innerRefresh(String.format("%s%s", baseName, DEFAULT_SUFFIX), oldLocale, newLocale); | |
if (bundle == null && defaultBundle == null) { | |
throw new ResourceNotFoundException(String.format("the bundle does not exist: baseName=%s, locale=%s", // | |
baseName, newLocale)); | |
} | |
} | |
} | |
@Nullable | |
@CheckForNull | |
private ResourceBundle innerRefresh(String baseName, @Nullable Locale oldLocale, @Nullable Locale newLocale) { | |
ResourceBundle bundle = null; | |
if (oldLocale != null) { | |
Map<String, I18nBundle> cache = REPOSITORY.get(oldLocale); | |
if (cache != null) { | |
cache.remove(baseName); | |
} | |
} | |
try { | |
bundle = ResourceBundle.getBundle(baseName, newLocale, new NoFallbackControl()); | |
Map<String, I18nBundle> cache = REPOSITORY.get(newLocale); | |
if (cache == null) { | |
cache = Maps.newHashMap(); | |
REPOSITORY.put(newLocale, cache); | |
} | |
cache.put(baseName, this); | |
} catch (MissingResourceException e) { | |
return null; | |
} | |
return bundle; | |
} | |
/** | |
* 対応する {@link Locale} のバンドルが存在しない場合にフォールバックせず, | |
* {@link I18nBundle#CHARSET_NAME} に従いリソースバンドルを読み込む {@link Control} の実装です。 | |
* | |
* @author monzou | |
*/ | |
private static class NoFallbackControl extends Control { | |
/** {@inheritDoc} */ | |
@Override | |
public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) | |
throws IllegalAccessException, InstantiationException, IOException { | |
if ("java.properties".equals(format)) { | |
String bundleName = toBundleName(baseName, locale); | |
String resourceName = toResourceName(bundleName, "properties"); | |
Closer closer = Closer.create(); | |
try { | |
InputStream stream = closer.register(openStream(loader, resourceName, reload)); | |
if (stream != null) { | |
Reader reader = closer.register(new BufferedReader(new InputStreamReader(stream, CHARSET_NAME))); | |
return new PropertyResourceBundle(reader); | |
} | |
} catch (Throwable t) { | |
closer.rethrow(t); | |
} finally { | |
closer.close(); | |
} | |
} | |
return super.newBundle(baseName, locale, format, loader, reload); | |
} | |
private InputStream openStream(final ClassLoader classLoader, final String resourceName, final boolean reload) throws IOException { | |
try { | |
return AccessController.doPrivileged(new PrivilegedExceptionAction<InputStream>() { | |
@Override | |
public InputStream run() throws IOException { | |
InputStream in = null; | |
if (reload) { | |
URL url = classLoader.getResource(resourceName); | |
if (url != null) { | |
URLConnection connection = url.openConnection(); | |
if (connection != null) { | |
connection.setUseCaches(false); | |
in = connection.getInputStream(); | |
} | |
} | |
} else { | |
in = classLoader.getResourceAsStream(resourceName); | |
} | |
return in; | |
} | |
}); | |
} catch (PrivilegedActionException e) { | |
throw (IOException) e.getException(); | |
} | |
} | |
/** {@inheritDoc} */ | |
@Override | |
public List<String> getFormats(String baseName) { | |
return ResourceBundle.Control.FORMAT_DEFAULT; | |
} | |
/** {@inheritDoc} */ | |
@Override | |
public Locale getFallbackLocale(String baseName, Locale locale) { | |
return null; | |
} | |
} | |
/** | |
* 国際化対応された文字列を取得します。 | |
* <p> | |
* キーに対応するパターンが定義されていなかった場合, 空文字が返されます。 | |
* | |
* @param key キー | |
* @param args リソースバンドルのオプション | |
* @return 文字列 | |
*/ | |
public String get(String key, @Nullable Object... args) { | |
String pattern = Strings.nullToEmpty(getPattern(key)); | |
return MessageFormat.format(pattern, args); | |
} | |
/** | |
* 国際化対応された文字列をフォーマットするためのパターンを取得します。 | |
* <p> | |
* 同じキーに対するパターンが定義されていた場合, 子が優先されます。 | |
* | |
* @param key キー | |
* @return 文字列パターン | |
*/ | |
@CheckForNull | |
@Nullable | |
public String getPattern(String key) { | |
String pattern = null; | |
if (bundle != null) { | |
try { | |
pattern = bundle.getString(key); | |
} catch (RuntimeException ignore) { // SUPPRESS CHECKSTYLE | |
} | |
} | |
if (pattern == null && defaultBundle != null) { | |
try { | |
pattern = defaultBundle.getString(key); | |
} catch (RuntimeException ignore) { // SUPPRESS CHECKSTYLE | |
} | |
} | |
if (pattern == null && parent != null) { | |
pattern = parent.getPattern(key); | |
} | |
return pattern; | |
} | |
/** | |
* キーの一覧を取得します。 | |
* <p> | |
* 重複したキーが存在する場合, 子のキーが優先されます。 | |
* | |
* @return キーの一覧 | |
*/ | |
public List<String> getKeys() { | |
List<String> keys = Collections.list(bundle.getKeys()); | |
if (defaultBundle != null) { | |
addIfNotExist(keys, Collections.list(defaultBundle.getKeys())); | |
} | |
if (parent != null) { | |
addIfNotExist(keys, parent.getKeys()); | |
} | |
return ImmutableList.copyOf(keys); | |
} | |
private <E> void addIfNotExist(List<E> destination, List<E> list) { | |
for (E element : list) { | |
if (destination.contains(element)) { | |
continue; | |
} | |
destination.add(element); | |
} | |
} | |
/** | |
* {@link ResourceBundle} に変換したインスタンスを取得します。 | |
* <p> | |
* 通常は使用しないで下さい。 | |
* | |
* @return {@link ResourceBundle} | |
*/ | |
public ResourceBundle asResourceBundle() { | |
return new ResourceBundle() { | |
@Override | |
protected Object handleGetObject(String key) { | |
return I18nBundle.this.getPattern(key); | |
} | |
@Override | |
public Enumeration<String> getKeys() { | |
return Collections.enumeration(I18nBundle.this.getKeys()); | |
} | |
}; | |
} | |
/** {@inheritDoc} */ | |
@Override | |
public String apply(String key) { | |
return key == null ? null : getPattern(key); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.usopla.gist; | |
import java.util.Locale; | |
/** | |
* アプリケーションの {@link Locale} の変更を監視するリスナです。 | |
* | |
* @author monzou | |
*/ | |
public interface LocaleChangeListener { | |
/** | |
* {@link Locale} 変化時のコールバックです。 | |
* | |
* @param oldLocale 変更前の {@link Locale} | |
* @param newLocale 変更後の {@link Locale} | |
*/ | |
void localeChanged(Locale oldLocale, Locale newLocale); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package com.usopla.gist; | |
import java.util.List; | |
import java.util.Locale; | |
import java.util.concurrent.CopyOnWriteArrayList; | |
import java.util.concurrent.locks.Lock; | |
import java.util.concurrent.locks.ReentrantLock; | |
import com.usopla.gist.Tuple; | |
import com.usopla.gist.Tuple.Tuple3; | |
import org.slf4j.Logger; | |
import org.slf4j.LoggerFactory; | |
/** | |
* アプリケーションの {@link Locale} を保持するクラスです。 | |
* | |
* @author monzou | |
*/ | |
public final class LocaleHolder { | |
private static final Logger LOGGER = LoggerFactory.getLogger(LocaleHolder.class); | |
private static final LocaleHolder INSTANCE; | |
static { | |
INSTANCE = new LocaleHolder(); | |
INSTANCE.reset(); | |
} | |
/** | |
* シングルトン・インスタンスを取得します。 | |
* | |
* @return シングルトン・インスタンス | |
*/ | |
public static LocaleHolder getInstance() { | |
return INSTANCE; | |
} | |
private Locale locale = Locale.getDefault(); | |
private final Lock lock; | |
private final List<LocaleChangeListener> listeners = new CopyOnWriteArrayList<>(); | |
/** | |
* {@link Locale} を取得します。 | |
* | |
* @return {@link Locale} | |
*/ | |
public Locale get() { | |
lock.lock(); | |
try { | |
return locale; | |
} finally { | |
lock.unlock(); | |
} | |
} | |
/** | |
* {@link Locale} を初期化します。 | |
*/ | |
public void reset() { | |
Locale locale = Locale.getDefault(); | |
String language = System.getProperty("user.language", locale.getLanguage()); | |
String country = System.getProperty("user.country", locale.getCountry()); | |
String variant = System.getProperty("user.variant", locale.getVariant()); | |
Tuple3<String, String, String> t1 = Tuple.of(locale.getLanguage(), locale.getCountry(), locale.getVariant()); | |
Tuple3<String, String, String> t2 = Tuple.of(language, country, variant); | |
set(t1.equals(t2) ? locale : new Locale(language, country, variant)); | |
} | |
/** | |
* {@link Locale} を設定します。 | |
* | |
* @param locale {@link Locale} | |
*/ | |
public void set(Locale locale) { | |
lock.lock(); | |
try { | |
Locale old = this.locale; | |
if (old != locale) { | |
this.locale = locale; | |
for (LocaleChangeListener listener : listeners) { | |
listener.localeChanged(old, this.locale); | |
} | |
LOGGER.info("Locale changed: {}", locale); | |
} | |
} finally { | |
lock.unlock(); | |
} | |
} | |
/** | |
* {@link LocaleChangeListener} を追加します。 | |
* | |
* @param listener {@link LocaleChangeListener} | |
*/ | |
public void addLocaleChangeListener(LocaleChangeListener listener) { | |
lock.lock(); | |
try { | |
if (listeners.contains(listener)) { | |
return; | |
} | |
listeners.add(listener); | |
} finally { | |
lock.unlock(); | |
} | |
} | |
/** | |
* {@link LocaleChangeListener} を削除します。 | |
* | |
* @param listener {@link LocaleChangeListener} | |
*/ | |
public void removeLocaleChangeListener(LocaleChangeListener listener) { | |
lock.lock(); | |
try { | |
listeners.remove(listener); | |
} finally { | |
lock.unlock(); | |
} | |
} | |
/** | |
* 全ての {@link LocaleChangeListener} を削除します。 | |
*/ | |
public void removeLocaleChangeListeners() { | |
lock.lock(); | |
try { | |
listeners.clear(); | |
} finally { | |
lock.unlock(); | |
} | |
} | |
private LocaleHolder() { | |
lock = new ReentrantLock(false); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment