SpringBoot集成I18n国际化文件在jar包外生效
- 问题描述
- 不生效的原因
- 解决办法
问题描述
公司最近出了个需求,是i18n国际化文件需要在springboot生成的jar包外生效.集成i18n国际化网上的文章很多就不在赘述了.但是一直无法在jar包外生效.因为每次都要替换jar包里面的文件比较麻烦.从部署程序的需求上来说,倒是比较合理.所以记录一下解决过程.
不生效的原因
我们通过配置文件中的key"spring.messages.basename",找到对应的使用类ResourceBundleCondition
我们看到了,问题就出现在最下面的红框classpath*了,因为用的是classpath,而不是filepath,所以限制了只能在jar包里面被找到.
解决办法
既然原因找到了,那就要考虑如何解决了.
我开发的时候先是使用i18n加强功能基础上开发的,之前的一些i18n加强功能已经有其他文章介绍了,就不摘抄了(文章后面会有完成的类源码,包含了此部分)
上面链接里面写了一个MessageResourceExtension类(下面会用到),继承了ResourceBundleMessageSource.
跟着代码断点查找发现用了org.springframework.context.MessageSource.getMessage(String code, @Nullable Object[] args, Locale locale)
中间的断点不进行一步一步追踪了.
最后发现是在java.util.ResourceBundle#loadBundle中加载的i18n文件
代码如下:
private static ResourceBundle loadBundle(CacheKey cacheKey,
List<String> formats,
Control control,
boolean reload) {
// Here we actually load the bundle in the order of formats
// specified by the getFormats() value.
Locale targetLocale = cacheKey.getLocale();
ResourceBundle bundle = null;
int size = formats.size();
for (int i = 0; i < size; i++) {
String format = formats.get(i);
try {
bundle = control.newBundle(cacheKey.getName(), targetLocale, format,
cacheKey.getLoader(), reload);
} catch (LinkageError error) {
// We need to handle the LinkageError case due to
// inconsistent case-sensitivity in ClassLoader.
// See 6572242 for details.
cacheKey.setCause(error);
} catch (Exception cause) {
cacheKey.setCause(cause);
}
if (bundle != null) {
// Set the format in the cache key so that it can be
// used when calling needsReload later.
cacheKey.setFormat(format);
bundle.name = cacheKey.getName();
bundle.locale = targetLocale;
// Bundle provider might reuse instances. So we should make
// sure to clear the expired flag here.
bundle.expired = false;
break;
}
}
return bundle;
}
不难发现control.newBundle方法才是关键,spring的org.springframework.context.support.ResourceBundleMessageSource.MessageSourceControl就是继承了此类,但是,我们需要自己做国际化路径文件的配置,怎么才能把自己的类注入呢?
向上翻找发现org.springframework.context.support.ResourceBundleMessageSource#getResourceBundle(String basename, Locale locale)如图:
所以我们首先要写一个容纳自定义的国际化文件路径的类,参照spring的org.springframework.context.support.ResourceBundleMessageSource.MessageSourceControl就好,之后自己的改造一下,以下为我自己的代码:
private class I18nMessageSourceControl extends ResourceBundle.Control {
@Override
@Nullable
public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
throws IllegalAccessException, InstantiationException, IOException {
// Special handling of default encoding
if (format.equals("java.properties")) {
String bundleName = toBundleName(baseName, locale);
final String resourceName = toResourceName(bundleName, "properties");
final ClassLoader classLoader = loader;
final boolean reloadFlag = reload;
InputStream inputStream;
try {
inputStream = AccessController.doPrivileged((PrivilegedExceptionAction<InputStream>) () -> {
InputStream is = null;
if (reloadFlag) {
URL url = classLoader.getResource(resourceName);
if (url != null) {
URLConnection connection = url.openConnection();
if (connection != null) {
connection.setUseCaches(false);
is = connection.getInputStream();
// 如果jar包部署存在同级目录下国际化文件,优先读取同级目录文件
if (url.getProtocol().equalsIgnoreCase("jar")) {
is = getBufferedInputStream(resourceName, is);
}
}
}
}
else {
is = classLoader.getResourceAsStream(resourceName);
// 如果jar包部署存在同级目录下国际化文件,优先读取同级目录文件
URL url = classLoader.getResource(resourceName);
if (url.getProtocol().equalsIgnoreCase("jar")) {
is = getBufferedInputStream(resourceName, is);
}
}
return is;
});
}
catch (PrivilegedActionException ex) {
throw (IOException) ex.getException();
}
if (inputStream != null) {
String encoding = getDefaultEncoding();
if (encoding != null) {
try (InputStreamReader bundleReader = new InputStreamReader(inputStream, encoding)) {
return loadBundle(bundleReader);
}
}
else {
try (InputStream bundleStream = inputStream) {
return loadBundle(bundleStream);
}
}
}
else {
return null;
}
}
else {
// Delegate handling of "java.class" format to standard Control
return super.newBundle(baseName, locale, format, loader, reload);
}
}
@Override
@Nullable
public Locale getFallbackLocale(String baseName, Locale locale) {
Locale defaultLocale = getDefaultLocale();
return (defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null);
}
@Override
public long getTimeToLive(String baseName, Locale locale) {
long cacheMillis = getCacheMillis();
return (cacheMillis >= 0 ? cacheMillis : super.getTimeToLive(baseName, locale));
}
@Override
public boolean needsReload(
String baseName, Locale locale, String format, ClassLoader loader, ResourceBundle bundle, long loadTime) {
if (super.needsReload(baseName, locale, format, loader, bundle, loadTime)) {
cachedBundleMessageFormats.remove(bundle);
return true;
}
else {
return false;
}
}
}
里面主要在类加载器中修改了读取输入流的地方,已经添加了注释,这样就可以实现自定义在jar包外路径加载配置文件了.之后会有完整的MessageResourceExtension代码
所以我们还要在MessageResourceExtension类重写此方法:
protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
ClassLoader classLoader = getBundleClassLoader();
Assert.state(classLoader != null, "No bundle ClassLoader set");
I18nMessageSourceControl control = this.control;
if (control != null) {
try {
return ResourceBundle.getBundle(basename, locale, classLoader, control);
}
catch (UnsupportedOperationException ex) {
// Probably in a Jigsaw environment on JDK 9+
this.control = null;
String encoding = getDefaultEncoding();
if (encoding != null && logger.isInfoEnabled()) {
logger.info("ResourceBundleMessageSource is configured to read resources with encoding '" +
encoding + "' but ResourceBundle.Control not supported in current system environment: " +
ex.getMessage() + " - falling back to plain ResourceBundle.getBundle retrieval with the " +
"platform default encoding. Consider setting the 'defaultEncoding' property to 'null' " +
"for participating in the platform default and therefore avoiding this log message.");
}
}
}
// Fallback: plain getBundle lookup without Control handle
return ResourceBundle.getBundle(basename, locale, classLoader);
}
看起来好像是什么都没有变,主要是I18nMessageSourceControl control = this.control;将原来的MessageSourceControl类替换成了我们自己的,当然也不要忘记在MessageResourceExtension类中添加成员属性,如下:
@Nullable
private volatile I18nMessageSourceControl control = new I18nMessageSourceControl();
至此,问题已经解决.但是每次修改还是需要重启才能生效.只是将配置文件提取到jar包外面.
要是有用的话,帮忙点个赞吧!
下面是我的MessageResourceExtension完整代码:
/**
* @author liziqi
* @Classname MessageResourceExtension
* @Description
* @Date 2021/8/20 16:15
*/
@Component("messageSource")
public class MessageResourceExtension extends ResourceBundleMessageSource {
private final static Logger logger = LoggerFactory.getLogger(MessageResourceExtension.class);
/**
* 指定的国际化文件目录
*/
@Value(value = "${spring.messages.baseFolder:i18n}")
private String baseFolder;
/**
* 父MessageSource指定的国际化文件
*/
@Value(value = "${spring.messages.basename:message}")
private String basename;
@PostConstruct
public void init() {
logger.info("init MessageResourceExtension...");
if (!StringUtils.isEmpty(baseFolder)) {
try {
this.setBasenames(getAllBaseNames(baseFolder));
} catch (IOException e) {
logger.error(e.getMessage());
}
}
//设置父MessageSource
ResourceBundleMessageSource parent = new ResourceBundleMessageSource();
parent.setBasename(basename);
this.setParentMessageSource(parent);
}
/**
* 遍历所有文件
*
* @param basenames
* @param folder
* @param path
*/
private void getAllFile(List<String> basenames, File folder, String path) {
if (folder.isDirectory()) {
for (File file : folder.listFiles()) {
this.getAllFile(basenames, file, path + folder.getName() + File.separator);
}
} else {
String i18Name = this.getI18FileName(path + folder.getName());
if (!basenames.contains(i18Name)) {
basenames.add(i18Name);
}
}
}
/**
* 获取文件夹下所有的国际化文件名
*/
private String[] getAllBaseNames(final String folderName) throws IOException {
URL url = Thread.currentThread().getContextClassLoader()
.getResource(folderName);
logger.info("url.file:{}",url.getFile());
if (null == url) {
throw new RuntimeException("无法获取资源文件路径");
}
List<String> baseNames = new ArrayList<>();
if (url.getProtocol().equalsIgnoreCase("file")) {
// 文件夹形式,用File获取资源路径
File file = new File(url.getFile());
if (file.exists() && file.isDirectory()) {
baseNames = Files.walk(file.toPath())
.filter(path -> path.toFile().isFile())
.map(Path::toString)
.map(path -> path.substring(path.indexOf(folderName)))
.map(this::getI18FileName)
.distinct()
.collect(Collectors.toList());
} else {
logger.error("指定的baseFile不存在或者不是文件夹");
}
} else if (url.getProtocol().equalsIgnoreCase("jar")) {
// jar包形式,用JarEntry获取资源路径
String jarPath = url.getFile().substring(url.getFile().indexOf(":") + 2, url.getFile().indexOf("!"));
JarFile jarFile = new JarFile(new File(jarPath));
List<String> baseJars = jarFile.stream()
.map(ZipEntry::toString)
.filter(jar -> jar.endsWith(folderName + "/")).collect(Collectors.toList());
if (baseJars.isEmpty()) {
logger.info("不存在{}资源文件夹", folderName);
return new String[0];
}
baseNames = jarFile.stream().map(ZipEntry::toString)
.filter(jar -> baseJars.stream().anyMatch(jar::startsWith))
.filter(jar -> jar.endsWith(".properties"))
.map(jar -> jar.substring(jar.indexOf(folderName)))
.map(this::getI18FileName)
.distinct()
.collect(Collectors.toList());
}
return baseNames.toArray(new String[0]);
}
/**
* 把普通文件名转换成国际化文件名
*/
private String getI18FileName(String filename) {
filename = filename.replace(".properties", "");
for (int i = 0; i < 2; i++) {
int index = filename.lastIndexOf("_");
if (index != -1) {
filename = filename.substring(0, index);
}
}
return filename.replace("\\", "/");
}
@Nullable
private volatile I18nMessageSourceControl control = new I18nMessageSourceControl();
/**
* Obtain the resource bundle for the given basename and Locale.
* @param basename the basename to look for
* @param locale the Locale to look for
* @return the corresponding ResourceBundle
* @throws MissingResourceException if no matching bundle could be found
* @see java.util.ResourceBundle#getBundle(String, Locale, ClassLoader)
* @see #getBundleClassLoader()
*/
protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
ClassLoader classLoader = getBundleClassLoader();
Assert.state(classLoader != null, "No bundle ClassLoader set");
I18nMessageSourceControl control = this.control;
if (control != null) {
try {
return ResourceBundle.getBundle(basename, locale, classLoader, control);
}
catch (UnsupportedOperationException ex) {
// Probably in a Jigsaw environment on JDK 9+
this.control = null;
String encoding = getDefaultEncoding();
if (encoding != null && logger.isInfoEnabled()) {
logger.info("ResourceBundleMessageSource is configured to read resources with encoding '" +
encoding + "' but ResourceBundle.Control not supported in current system environment: " +
ex.getMessage() + " - falling back to plain ResourceBundle.getBundle retrieval with the " +
"platform default encoding. Consider setting the 'defaultEncoding' property to 'null' " +
"for participating in the platform default and therefore avoiding this log message.");
}
}
}
// Fallback: plain getBundle lookup without Control handle
return ResourceBundle.getBundle(basename, locale, classLoader);
}
/**
* Cache to hold already generated MessageFormats.
* This Map is keyed with the ResourceBundle, which holds a Map that is
* keyed with the message code, which in turn holds a Map that is keyed
* with the Locale and holds the MessageFormat values. This allows for
* very efficient hash lookups without concatenated keys.
* @see #getMessageFormat
*/
private final Map<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats =
new ConcurrentHashMap<>();
/**
* Custom implementation of {@code ResourceBundle.Control}, adding support
* for custom file encodings, deactivating the fallback to the system locale
* and activating ResourceBundle's native cache, if desired.
*/
private class I18nMessageSourceControl extends ResourceBundle.Control {
@Override
@Nullable
public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload)
throws IllegalAccessException, InstantiationException, IOException {
// Special handling of default encoding
if (format.equals("java.properties")) {
String bundleName = toBundleName(baseName, locale);
final String resourceName = toResourceName(bundleName, "properties");
final ClassLoader classLoader = loader;
final boolean reloadFlag = reload;
InputStream inputStream;
try {
inputStream = AccessController.doPrivileged((PrivilegedExceptionAction<InputStream>) () -> {
InputStream is = null;
if (reloadFlag) {
URL url = classLoader.getResource(resourceName);
if (url != null) {
URLConnection connection = url.openConnection();
if (connection != null) {
connection.setUseCaches(false);
is = connection.getInputStream();
// 如果jar包部署存在同级目录下国际化文件,优先读取同级目录文件
if (url.getProtocol().equalsIgnoreCase("jar")) {
is = getBufferedInputStream(resourceName, is);
}
}
}
}
else {
is = classLoader.getResourceAsStream(resourceName);
// 如果jar包部署存在同级目录下国际化文件,优先读取同级目录文件
URL url = classLoader.getResource(resourceName);
if (url.getProtocol().equalsIgnoreCase("jar")) {
is = getBufferedInputStream(resourceName, is);
}
}
return is;
});
}
catch (PrivilegedActionException ex) {
throw (IOException) ex.getException();
}
if (inputStream != null) {
String encoding = getDefaultEncoding();
if (encoding != null) {
try (InputStreamReader bundleReader = new InputStreamReader(inputStream, encoding)) {
return loadBundle(bundleReader);
}
}
else {
try (InputStream bundleStream = inputStream) {
return loadBundle(bundleStream);
}
}
}
else {
return null;
}
}
else {
// Delegate handling of "java.class" format to standard Control
return super.newBundle(baseName, locale, format, loader, reload);
}
}
@Override
@Nullable
public Locale getFallbackLocale(String baseName, Locale locale) {
Locale defaultLocale = getDefaultLocale();
return (defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null);
}
@Override
public long getTimeToLive(String baseName, Locale locale) {
long cacheMillis = getCacheMillis();
return (cacheMillis >= 0 ? cacheMillis : super.getTimeToLive(baseName, locale));
}
@Override
public boolean needsReload(
String baseName, Locale locale, String format, ClassLoader loader, ResourceBundle bundle, long loadTime) {
if (super.needsReload(baseName, locale, format, loader, bundle, loadTime)) {
cachedBundleMessageFormats.remove(bundle);
return true;
}
else {
return false;
}
}
}
/**
* @Description: 拼接url 并返回输入流
* @Param: [resourceName, is]
* @returns: java.io.InputStream
* @Author: ziqi.li
* @Date: 2021/8/23 11:05
*/
public InputStream getBufferedInputStream(String resourceName, InputStream is) throws FileNotFoundException {
String fileUrl = System.getProperty("user.dir") + "\\" + resourceName;
File file = new File(fileUrl);
if (file.exists()) {
FileInputStream fileInputStream = new FileInputStream(file);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
return bufferedInputStream;
}
return is;
}
}