原作者
源码: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_ATTRIBUTE
到request
中并退出拦截器,如果没有就继续。
再判断进入拦截的类上有没有I18n
的注解,如果有就设置I18N_ATTRIBUTE
到request
中并退出拦截器,如果没有就继续。
最后假如方法和类上都没有I18n
的注解,那我们可以根据Controller名
自动设置指定的国际化文件,比如UserController
那么就会去找user
的国际化文件。
在MessageResourceExtension
重写resolveCodeWithoutArguments
方法(如果有字符格式化的需求就重写resolveCode
方法)。
在我们重写的resolveCodeWithoutArguments
方法中,从HttpServletRequest
中获取到‘I18N_ATTRIBUTE’(等下再说这个在哪里设置),这个对应我们想要显示的国际化文件名,然后我们在BasenameSet
中查找该文件,再通过getResourceBundle
获取到资源,最后再getStringOrNull
获取到对应的国际化信息
resolveCodeWithoutArguments为了解决多写前缀问题。
dashboard.properties
中的国际化信息为dashboard.hello
而merchant.properties
中的是merchant.hello
,这样每个都要写一个前缀岂不是很麻烦,现在我想要在dashboard
和merchant
的国际化文件中都只写’hello’但是显示的是dashboard
或merchant
的国际化信息。
拦截器完成了,现在把拦截器配置到系统中。修改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
来控制国际化的语言。设置LocaleChangeInterceptor
和MessageResourceInterceptor
拦截器来拦截国际化语言的变化。
继承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
名为messageSource
的bean
。这个过程在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...
然后我们再创建两组国际化信息文件:dashboard
和merchant
,里面分别只有一个国际化信息:dashboard.hello
和merchant.hello
。
源码:https://github.com/zzzzbw/Spring-Boot-I18n-Pro
https://zzzzbw.cn/article/7