Springboot thymeleaf i18n国际化多语言选择

原作者

源码:https://github.com/zzzzbw/Spring-Boot-I18n-Pro
https://github.com/zzzzbw/Spring-Boot-I18n-Pro/tree/starter 【starter】分支
原文
https://zzzzbw.cn/article/7

这里只是一个整理,没有按步骤一步一步的复制,初学者请看原作者的原文

目的:

从文件夹中直接加载多个国际化文件
后台设置前端页面显示国际化信息的文件
利用拦截器和注解自动设置前端页面显示国际化信息的文件

因为业务需要,对此功能进行了扩展,新功能->
Springboot thymeleaf i18n国际化多语言选择->2.业务流程内部返回 对应的语言 https://blog.csdn.net/fenglailea/article/details/89882786

利用拦截器和注解自动设置前端页面显示国际化信息的文件

虽然已经可以指定对应的国际化信息,但是这样要在每个controller里的HttpServletRequest中设置国际化文件实在太麻烦了,所以现在我们实现自动判定来显示对应的文件。

首先我们创建一个注解,这个注解可以放在类上或者方法上。

package com.zbw.i18n.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * @auther zbw
 * @create 2018/4/4 17:59
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface I18n {
    /**
     * 国际化文件名
     */
    String value();
}

然后我们把这个创建的I18n注解放在DashboardController控制器中或控制器的方法中,控制器方法中看后续部分案例

package com.zbw.i18n.controller;

import com.zbw.i18n.annotation.I18n;
import com.zbw.i18n.model.Merchant;
import com.zbw.i18n.model.Shop;
import com.zbw.i18n.model.User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import java.util.ArrayList;
import java.util.List;


/**
 * @author zbw
 * @create 2018/4/3 16:23
 */
@I18n("dashboard")
@Controller
@RequestMapping("dashboard")
public class DashboardController {
    
    @GetMapping
    public String dashboard(Model model) {
        List<Merchant> merchants = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            Merchant merchant = new Merchant();
            merchants.add(merchant);
        }
        List<Shop> shops = new ArrayList<>();
        for (int i = 0; i < 15; i++) {
            Shop shop = new Shop();
            shops.add(shop);
        }
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            User user = new User();
            users.add(user);
        }
        model.addAttribute("merchants", merchants);
        model.addAttribute("shops", shops);
        model.addAttribute("users", users);
        return "system/dashboard";
    }
}

为了显示他的效果,我们再创建一个HelloController,同时也创建对应的’demo’的国际化文件,内容也都是一个’hello’。

注意这个HelloController 在源码中是没有的,在 starter 分支中才有

@Controller
public class HelloController {
    @GetMapping("/hello")
    public String index() {
        return "system/hello";
    }

    @I18n("dashboard")
    @GetMapping("/dashboard")
    public String dashboard() {
        return "dashboard";
    }

    @I18n("merchant")
    @GetMapping("/merchant")
    public String merchant() {
        return "merchant";
    }
}
@I18n("shop")
@Controller
public class ShopController {
    @GetMapping("shop")
    public String shop() {
        return "shop";
    }
}
@Controller
public class UserController {
    @GetMapping("user")
    public String user() {
        return "user";
    }
}

我们把I18n注解分别放在HelloController下的dashboard和merchant方法下,和ShopController类上。
准备工作都做好了,现在看看如何实现根据这些注解自动的指定国际化文件。

package com.zbw.i18n.compoment;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;

/**
 * @author zbw
 * @create 2018/4/3 18:20
 */
@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;

    public static String I18N_ATTRIBUTE = "i18n_attribute";

    @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();
         //是否是多个目录
        if (basename.indexOf(",") > 0) {
            parent.setBasenames(basename.split(","));
        } else {
            parent.setBasename(basename);
        }
        //设置文件编码
        parent.setDefaultEncoding("UTF-8");
        this.setParentMessageSource(parent);
    }

    @Override
    protected String resolveCodeWithoutArguments(String code, Locale locale) {
        // 获取request中设置的指定国际化文件名
        ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        final String i18File = (String) attr.getAttribute(I18N_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (!StringUtils.isEmpty(i18File)) {
            //获取在basenameSet中匹配的国际化文件名
            String basename = getBasenameSet().stream()
                    .filter(name -> StringUtils.endsWithIgnoreCase(name, i18File))
                    .findFirst().orElse(null);
            if (!StringUtils.isEmpty(basename)) {
                //得到指定的国际化文件资源
                ResourceBundle bundle = getResourceBundle(basename, locale);
                if (bundle != null) {
                    return getStringOrNull(bundle, code);
                }
            }
        }
        //如果指定i18文件夹中没有该国际化字段,返回null会在ParentMessageSource中查找
        return null;
    }

    /**
     * 获取文件夹下所有的国际化文件名
     *
     * @param folderName 文件名
     * @return
     * @throws IOException
     */
    private String[] getAllBaseNames(final String folderName) throws IOException {
        URL url = Thread.currentThread().getContextClassLoader()
                .getResource(folderName);
        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]);
    }

    /**
     * 把普通文件名转换成国际化文件名
     *
     * @param filename
     * @return
     */
    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("\\", "/");
    }
}

简单讲解一下这个拦截器。

首先,如果request中已经有I18N_ATTRIBUTE,说明在Controller的方法中指定设置了,就不再判断。
然后判断一下进入拦截器的方法上有没有I18n的注解,如果有就设置I18N_ATTRIBUTErequest中并退出拦截器,如果没有就继续。
再判断进入拦截的类上有没有I18n的注解,如果有就设置I18N_ATTRIBUTErequest中并退出拦截器,如果没有就继续。
最后假如方法和类上都没有I18n的注解,那我们可以根据Controller名自动设置指定的国际化文件,比如UserController那么就会去找user的国际化文件。

MessageResourceExtension重写resolveCodeWithoutArguments方法(如果有字符格式化的需求就重写resolveCode方法)。
在我们重写的resolveCodeWithoutArguments方法中,从HttpServletRequest中获取到‘I18N_ATTRIBUTE’(等下再说这个在哪里设置),这个对应我们想要显示的国际化文件名,然后我们在BasenameSet中查找该文件,再通过getResourceBundle获取到资源,最后再getStringOrNull获取到对应的国际化信息

resolveCodeWithoutArguments为了解决多写前缀问题。 dashboard.properties中的国际化信息为dashboard.hellomerchant.properties中的是merchant.hello,这样每个都要写一个前缀岂不是很麻烦,现在我想要在dashboardmerchant的国际化文件中都只写’hello’但是显示的是dashboardmerchant的国际化信息。

拦截器完成了,现在把拦截器配置到系统中。修改I18nApplication启动类:

package com.zbw.i18n;

import com.zbw.i18n.interceptor.MessageResourceInterceptor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

import java.util.Locale;

@SpringBootApplication
@Configuration
public class I18nApplication {

    public static void main(String[] args) {
        SpringApplication.run(I18nApplication.class, args);
    }

    @Bean
    public LocaleResolver localeResolver() {
        CookieLocaleResolver slr = new CookieLocaleResolver();
        slr.setDefaultLocale(Locale.CHINA);
        slr.setCookieMaxAge(3600);
        slr.setCookieName("Language");
        return slr;
    }


    @Bean
    public WebMvcConfigurer webMvcConfigurer() {
        return new WebMvcConfigurer() {
            //拦截器
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**");
                registry.addInterceptor(new MessageResourceInterceptor()).addPathPatterns("/**");
            }
        };
    }
}

其中I18nApplication.java设置了一个CookieLocaleResolver,采用cookie来控制国际化的语言。设置LocaleChangeInterceptorMessageResourceInterceptor拦截器来拦截国际化语言的变化。

继承ResourceBundleMessageSource

package com.zbw.i18n.compoment;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;

/**
 * @author zbw
 * @create 2018/4/3 18:20
 */
@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;

    public static String I18N_ATTRIBUTE = "i18n_attribute";

    @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();
         //是否是多个目录
        if (basename.indexOf(",") > 0) {
            parent.setBasenames(basename.split(","));
        } else {
            parent.setBasename(basename);
        }
        //设置文件编码
        parent.setDefaultEncoding("UTF-8");
        this.setParentMessageSource(parent);
    }

    @Override
    protected String resolveCodeWithoutArguments(String code, Locale locale) {
        // 获取request中设置的指定国际化文件名
        ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        final String i18File = (String) attr.getAttribute(I18N_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST);
        if (!StringUtils.isEmpty(i18File)) {
            //获取在basenameSet中匹配的国际化文件名
            String basename = getBasenameSet().stream()
                    .filter(name -> StringUtils.endsWithIgnoreCase(name, i18File))
                    .findFirst().orElse(null);
            if (!StringUtils.isEmpty(basename)) {
                //得到指定的国际化文件资源
                ResourceBundle bundle = getResourceBundle(basename, locale);
                if (bundle != null) {
                    return getStringOrNull(bundle, code);
                }
            }
        }
        //如果指定i18文件夹中没有该国际化字段,返回null会在ParentMessageSource中查找
        return null;
    }

    /**
     * 获取文件夹下所有的国际化文件名
     *
     * @param folderName 文件名
     * @return
     * @throws IOException
     */
    private String[] getAllBaseNames(final String folderName) throws IOException {
        URL url = Thread.currentThread().getContextClassLoader()
                .getResource(folderName);
        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]);
    }

    /**
     * 把普通文件名转换成国际化文件名
     *
     * @param filename
     * @return
     */
    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("\\", "/");
    }
}

在项目下创建一个类继承ResourceBundleMessageSource或者ReloadableResourceBundleMessageSource,起名为MessageResourceExtension。并且注入到bean中起名为messageSource,这里我们继承ResourceBundleMessageSource
注意这里我们的Component名字必须为messageSource,因为在初始化ApplicationContext的时候,会查找bean名为messageSourcebean。这个过程在AbstractApplicationContext.java中,我们看一下源代码

/**
* Initialize the MessageSource.
* Use parent's if none defined in this context.
*/
protected void initMessageSource() {
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
...
}
}
...

在这个初始化MessageSource的方法中,beanFactory查找注入名为MESSAGE_SOURCE_BEAN_NAME(messageSource)bean,如果没有找到,就会在其父类中查找是否有该名的bean

MessageResourceExtension 依次解释一下几个方法。

1.init()方法上有一个@PostConstruct注解,这会在MessageResourceExtension类被实例化之后自动调用init()方法。这个方法获取到baseFolder目录下所有的国际化文件并设置到basenameSet中。并且设置一个ParentMessageSource,这会在找不到国际化信息的时候,调用父MessageSource来查找国际化信息。
2. getAllBaseNames()方法获取到baseFolder的路径,然后调用getAllFile()方法获取到该目录下所有的国际化文件的文件名。
3. getAllFile()遍历目录,如果是文件夹就继续遍历,如果是文件就调用getI18FileName()把文件名转为i18n/basename/格式的国际化资源名。

getAllBaseNames()方法中会先判断项目的Url形式为文件形式还是jar包形式。
如果是文件形式则就以普通文件夹的方式读取,这里还用了java8中的Files.walk()方法获取到文件夹下的所有文件,比原来自己写递归来读取方便多了。
如果是jar包的形式,那么就要用JarEntry来处理文件了。
首先是获取到项目jar包所在的的目录,如E:/workspace/java/Spring-Boot-I18n-Pro/target/i18n-0.0.1.jar这种,然后根据该目录new一个JarFile
接着遍历这个JarFile包下的资源,这会把我们项目jar包下的所有文件都读取出来,所以我们要先找到我们i18n资源文件所在的目录,通过.filter(jar -> jar.endsWith(folderName + "/"))获取资源所在目录。
接下来就是判断JarFile包下的文件是否在i18n资源目录了,如果是则调用getI18FileName()方法将其格式化成我们所需要的名字形式。
经过这段操作就实现了获取jar包下i18n的资源文件名了。

所以简单来说就是在MessageResourceExtension被实例化之后,把’i18n’文件夹下的资源文件的名字,加载到Basenames中。现在来看一下效果。

首先我们在application.properties文件中添加一个spring.messages.baseFolder=i18n,这会把i18n这个值赋值给MessageResourceExtension中的baseFolder

在启动后看到控制台里打印出了init信息,表示被@PostConstruct注解的init()方法已经执行。

 [           main] c.z.i.c.MessageResourceExtension         : init MessageResourceExtension...

然后我们再创建两组国际化信息文件:dashboardmerchant,里面分别只有一个国际化信息:dashboard.hellomerchant.hello

源码:https://github.com/zzzzbw/Spring-Boot-I18n-Pro
https://zzzzbw.cn/article/7

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值