SpringBoot集成I18n国际化文件在jar包外生效

  • 问题描述
  • 不生效的原因
  • 解决办法


问题描述

公司最近出了个需求,是i18n国际化文件需要在springboot生成的jar包外生效.集成i18n国际化网上的文章很多就不在赘述了.但是一直无法在jar包外生效.因为每次都要替换jar包里面的文件比较麻烦.从部署程序的需求上来说,倒是比较合理.所以记录一下解决过程.

不生效的原因

我们通过配置文件中的key"spring.messages.basename",找到对应的使用类ResourceBundleCondition

spring boot i18n spring boot i18n文件不生效_jar


我们看到了,问题就出现在最下面的红框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 boot i18n spring boot i18n文件不生效_bundle_02


所以我们首先要写一个容纳自定义的国际化文件路径的类,参照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;
    }
}