前言

公司将项目由Struts2转到Springmvc了,由于公司业务是境外服务,所以对国际化功能需求很高。Struts2自带的国际化功能相对Springmvc来说更加完善,不过spring很大的特性就是可定定制化性强,所以在公司项目移植的到Springmvc的时候增加了其国际化的功能。特此整理记录并且完善了一下。

本文主要实现的功能:

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

注:本文不详细介绍怎么配置国际化,区域解析器等。

实现

国际化项目初始化

先创建一个基本的Spring-Boot+thymeleaf+国际化信息(message.properties)项目,如果有需要可以从我的Github下载。

简单看一下项目的目录和文件

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

@SpringBootApplication
@Configuration
public class I18nApplication {public static void main(String[] args) {SpringApplication.run(I18nApplication.class, args);}@Beanpublic LocaleResolver localeResolver() {CookieLocaleResolver slr = new CookieLocaleResolver();slr.setCookieMaxAge(3600);slr.setCookieName("Language");//设置存储的Cookie的name为Languagereturn slr;}@Beanpublic WebMvcConfigurer webMvcConfigurer() {return new WebMvcConfigurer() {//拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**");}};}
}
复制代码

我们再看一下hello.html中写了什么:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head><title>Hello World!</title>
</head>
<body>
<h1 th:text="#{i18n_page}"></h1>
<h3 th:text="#{hello}"></h3>
</body>
</html>
复制代码

现在启动项目并且访问http://localhost:9090/hello(我在application.properties)中设置了端口为9090。

由于浏览器默认的语言是中文,所以他默认会去messages_zh_CN.properties中找,如果没有就会去messages.properties中找国际化词。

然后我们在浏览器中输入http://localhost:9090/hello?locale=en_US,语言就会切到英文。同样的如果url后参数设置为locale=zh_CH,语言就会切到中文。

从文件夹中直接加载多个国际化文件

在我们hello.html页面中,只有'i18n_page'和'hello'两个国际化信息,然而在实际项目中肯定不会只有几个国际化信息那么少,通常都是成千上百个的,那我们肯定不能把这么多的国际化信息都放在messages.properties一个文件中,通常都是把国际化信息分类存放在几个文件中。但是当项目大了以后,这些国际化文件也会越来越多,这时候在application.properties文件中一个个的去配置这个文件也是不方便的,所以现在我们实现一个功能自动加载制定目录下所有的国际化文件。

继承ResourceBundleMessageSource

在项目下创建一个类继承ResourceBundleMessageSource或者ReloadableResourceBundleMessageSource,起名为MessageResourceExtension。并且注入到bean中起名为messageSource,这里我们继承ResourceBundleMessageSource。

@Component("messageSource")
public class MessageResourceExtension extends 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

中写加载文件的方法了。

@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;@PostConstructpublic void init() {logger.info("init MessageResourceExtension...");if (!StringUtils.isEmpty(baseFolder)) {try {this.setBasenames(getAllBaseNames(baseFolder));} catch (IOException e) {logger.error(e.getMessage());}}//设置父MessageSourceResourceBundleMessageSource parent = new ResourceBundleMessageSource();parent.setBasename(basename);this.setParentMessageSource(parent);}/*** 获取文件夹下所有的国际化文件名** @param folderName 文件名* @return* @throws IOException*/private String[] getAllBaseNames(String folderName) throws IOException {Resource resource = new ClassPathResource(folderName);File file = resource.getFile();List<String> baseNames = new ArrayList<>();if (file.exists() && file.isDirectory()) {this.getAllFile(baseNames, file, "");} else {logger.error("指定的baseFile不存在或者不是文件夹");}return baseNames.toArray(new String[baseNames.size()]);}/*** 遍历所有文件** @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);}}}/*** 把普通文件名转换成国际化文件名** @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;}
}
复制代码

依次解释一下几个方法。

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

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

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

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

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

之后再修改一下hello.html文件,然后访问hello页面。

...
<body>
<h1>国际化页面!</h1>
<p th:text="#{hello}"></p>
<p th:text="#{merchant.hello}"></p>
<p th:text="#{dashboard.hello}"></p>
</body>
...
复制代码

可以看到网页中加载了'message','dashboard'和'merchant'中的国际化信息,说明我们已经成功一次性加载了'i18n'文件夹下的文件。

后台设置前端页面显示国际化信息的文件

s刚才那一节我们成功加载了多个国际化文件并显示出了他们的国际化信息。但是'dashboard.properties'中的国际化信息为'dashboard.hello'而'merchant.properties'中的是'merchant.hello',这样每个都要写一个前缀岂不是很麻烦,现在我想要在'dashboard'和'merchant'的国际化文件中都只写'hello'但是显示的是'dashboard'或'merchant'中的国际化信息。

MessageResourceExtension重写resolveCodeWithoutArguments方法(如果有字符格式化的需求就重写resolveCode方法)。

@Component("messageSource")
public class MessageResourceExtension extends ResourceBundleMessageSource {...public static String I18N_ATTRIBUTE = "i18n_attribute";@Overrideprotected 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;}...
}
复制代码

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

现在我们到我们的HelloController里加两个方法。

@Controller
public class HelloController {@GetMapping("/hello")public String index(HttpServletRequest request) {request.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, "hello");return "system/hello";}@GetMapping("/dashboard")public String dashboard(HttpServletRequest request) {request.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, "dashboard");return "dashboard";}@GetMapping("/merchant")public String merchant(HttpServletRequest request) {request.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, "merchant");return "merchant";}
}
复制代码

看到我们在每个方法中都设置一个对应的'I18N_ATTRIBUTE',这会在每次请求中设置对应的国际化文件,然后在MessageResourceExtension中获取。

这时我们看一下我们的国际化文件,我们可以看到所有关键字都是'hello',但是信息却不同。

同时新增两个html文件分别是'dashboard.html'和'merchant.html',里面只有一个'hello'的国际化信息和用于区分的标题。

<!-- 这是hello.html -->
<body>
<h1>国际化页面!</h1>
<p th:text="#{hello}"></p>
</body>
复制代码
<!-- 这是dashboard.html -->
<body>
<h1>国际化页面(dashboard)!</h1>
<p th:text="#{hello}"></p>
</body>
复制代码
<!-- 这是merchant.html -->
<body>
<h1>国际化页面(merchant)!</h1>
<p th:text="#{hello}"></p>
</body>
复制代码

这时我们启动项目看一下。

可以看到虽然在每个页面的国际化词都是'hello',但是我们在对应的页面显示了我们想要显示的信息。

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

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

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

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface I18n {/*** 国际化文件名*/String value();
}
复制代码

然后我们把这个创建的I18n注解放在刚才的Controller方法中,为了显示他的效果,我们再创建一个ShopControllerUserController,同时也创建对应的'shop'和'user'的国际化文件,内容也都是一个'hello'。

@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下的dashboardmerchant方法下,和ShopController类上。并且去除了原来dashboardmerchant方法下设置‘I18N_ATTRIBUTE’的语句。

准备工作都做好了,现在看看如何实现根据这些注解自动的指定国际化文件。

public class MessageResourceInterceptor implements HandlerInterceptor {@Overridepublic void postHandle(HttpServletRequest req, HttpServletResponse rep, Object handler, ModelAndView modelAndView) {// 在方法中设置i18路径if (null != req.getAttribute(MessageResourceExtension.I18N_ATTRIBUTE)) {return;}HandlerMethod method = (HandlerMethod) handler;// 在method上注解了i18I18n i18nMethod = method.getMethodAnnotation(I18n.class);if (null != i18nMethod) {req.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, i18nMethod.value());return;}// 在Controller上注解了i18I18n i18nController = method.getBeanType().getAnnotation(I18n.class);if (null != i18nController) {req.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, i18nController.value());return;}// 根据Controller名字设置i18String controller = method.getBeanType().getName();int index = controller.lastIndexOf(".");if (index != -1) {controller = controller.substring(index + 1, controller.length());}index = controller.toUpperCase().indexOf("CONTROLLER");if (index != -1) {controller = controller.substring(0, index);}req.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, controller);}@Overridepublic boolean preHandle(HttpServletRequest req, HttpServletResponse rep, Object handler) {// 在跳转到该方法先清除request中的国际化信息req.removeAttribute(MessageResourceExtension.I18N_ATTRIBUTE);return true;}
}
复制代码

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

首先,如果request中已经有'I18N_ATTRIBUTE',说明在Controller的方法中指定设置了,就不再判断。

然后判断一下进入拦截器的方法上有没有I18n的注解,如果有就设置'I18N_ATTRIBUTE'到request中并退出拦截器,如果没有就继续。

再判断进入拦截的类上有没有I18n的注解,如果有就设置'I18N_ATTRIBUTE'到request中并退出拦截器,如果没有就继续。

最后假如方法和类上都没有I18n的注解,那我们可以根据Controller名自动设置指定的国际化文件,比如'UserController'那么就会去找'user'的国际化文件。

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

@SpringBootApplication
@Configuration
public class I18nApplication {...@Beanpublic WebMvcConfigurer webMvcConfigurer() {return new WebMvcConfigurer() {//拦截器@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**");registry.addInterceptor(new MessageResourceInterceptor()).addPathPatterns("/**");}};}
}
复制代码

现在我们再运行一下看看效果,看到每个链接都显示的他们对应的国际化信息里的内容。

最后

刚才完成了我们整个国际化增强的基本功能,最后我把全部代码整理了一下,并且整合了bootstrap4来展示了一下功能的实现效果。

详细的代码可以看我Github上Spring-Boot-I18n-Pro的代码

原文地址:zzzzbw.cn


2018/8/30更新

文章发布后,有人向我提到当把项目打成jar包之后执行java -jar i18n-0.0.1.jar的方式来运行程序会报错。看到这样的反馈我立刻就意识到,确实在读取i18n的国际化文件的时候用的是File的形式来读取文件名的,假如打包成jar包后所有文件都是在压缩文件夹中,就不能简单的以File的形式来获取到文件夹下的所有文件了。因为公司的项目是以war包的形式在Tomcat下运行,所以没有发现这个问题。

主要问题是在MessageResourceExtension类在spring-boot启动时读取配置文件导致的,所以修改MessageResourceExtension

@Component("messageSource")
public class MessageResourceExtension extends ResourceBundleMessageSource {.../*** 获取文件夹下所有的国际化文件名*/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]);}/*** 把普通文件名转换成国际化文件名*/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("\\", "/");}...
}
复制代码

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的资源文件名了。

自己动手在Spring-Boot上加强国际化功能相关推荐

  1. Spring Boot Validation提示信息国际化配置

    引言 之前介绍过Spring Boot Validation的使用及扩展,可参见:<SpringBoot Validation> 本文在此基础上重点讲解下Spring Boot Valid ...

  2. Spring Boot 上传文件(spring boot upload file)

    本篇文章将说明在Spring Boot web程序中如何上传文件. 开发环境: 1. eclipse Oxygen Release (4.7.0) 2. Spring Boot 1.4.3 RELEA ...

  3. Spring Boot(十七):使用Spring Boot上传文件

    Spring Boot(十七):使用Spring Boot上传文件 环境:Spring Boot最新版本1.5.9.jdk使用1.8.tomcat8.0 一.pom包配置 <parent> ...

  4. (转)Spring Boot(十七):使用 Spring Boot 上传文件

    http://www.ityouknow.com/springboot/2018/01/12/spring-boot-upload-file.html 上传文件是互联网中常常应用的场景之一,最典型的情 ...

  5. springboot上传文件同时传参数_Spring Boot 系列:使用 Spring Boot 上传文件

    上传文件是互联网中常常应用的场景之一,最典型的情况就是上传头像等,今天就带着带着大家做一个 Spring Boot 上传文件的小案例. 1.pom 包配置 我们使用 Spring Boot 版本 2. ...

  6. Spring boot上传文件(图片)到阿里云OSS(直接上传到应用服务器)

    Spring boot上传文件(图片)到阿里云OSS(直接上传到应用服务器) 主要思路 上传图片的思路有两种: 第一种是上传到应用服务器,再由应用服务器上传到OSS.这种适合上传的文件较小,较为简单, ...

  7. Spring Boot上传文件报UT005023 MultipartException NoSuchFileException

    1.抛出问题: Spring Boot上传文件报: UT005023: Exception handling request to /management/certificateAuthority/u ...

  8. spring boot上传阿里云对象存储OSS快速上手(一、控制台简介)

    代码实现请看下一篇: spring boot上传阿里云对象存储OSS快速上手(二.代码实现,复制粘贴可用) 简介: 1.阿里云对象存储OSS其实就是一个文件存储的服务 2.在我们的开发过程中会遇到类似 ...

  9. Spring Boot 2中的功能切换

    无论您是否喜欢,软件开发都是一项协作活动. 整合工作一直被妖魔化,并被视为必不可少的邪恶. 有几种方法可以解决有效集成的挑战. 功能切换开关属于该组. 在本文中,您将在实践中看到如何在Spring B ...

最新文章

  1. 部署LyncServer2013之一 前期准备
  2. c#和VB混用出现的错误
  3. boost::mutex相关的测试程序
  4. vue-router的路由
  5. 0419 一些不错的UI作品,以后陆续更新
  6. python常用语法和示例_C语言切换案例教程,语法,示例和规则
  7. Spring重点面题总结
  8. 网关中间件研发首选一站式API协作平台:Eolink
  9. 区块链:5、匿名性和隐私性
  10. java打印菱形图案_如何使用java打印出菱形图案
  11. 重装系统后安装并激活Office
  12. linux x99 测试,超频测试总结 - 技嘉X99 Phoenix SLI主板评测:综合素质爆表的主板 - 超能网...
  13. C盘可用空间无法完全压缩卷解决方法
  14. 自己实现的水版MPI_Bcast(使用binomial tree,跟mpich2实现思路一样)
  15. 2021.11.28
  16. 冰雪复古鸿蒙碎片哪里出,复古冰雪玩法总览!!!干货!!!
  17. Android P 9.0 MTK平台 增加以太网静态IP功能
  18. CentOS8—ssh免密登录
  19. 苹果电脑查看已经连上的WiFi密码(亲测可用)
  20. 服务器电源系统,服务器电源系统于新一代数据中心设计的基础意义

热门文章

  1. KVM中virtio、vhost 和vhost-user比较(十一)
  2. FCKeditor 在ASP.Net 中的使用说明
  3. CF932G Palindrome Partition
  4. Django 基于角色的权限控制
  5. swift学习之set和get方法
  6. 2018 GitHub最火技术趋势
  7. 解决手机訪问站点时总体相对屏幕缩小问题?(已解决)
  8. .CN域名总量达1090.6万个:8月份共净增13.8万个
  9. 翻窗户消失的百岁老人/百岁老人跷家去 中文字幕
  10. mysql 更新多条id键值相同记录中的最新(最后一条)记录