公司将项目由Struts2转到Springmvc了,因为公司业务是境外服务,因此对国际化功能需求很高。Struts2自带的国际化功能相对Springmvc来讲更加完善,不过spring很大的特性就是可定定制化性强,因此在公司项目移植的到Springmvc的时候增长了其国际化的功能。特此整理记录而且完善了一下。
本文主要实现的功能:html
注:本文不详细介绍怎么配置国际化,区域解析器等。
先建立一个基本的Spring-Boot+thymeleaf+国际化信息(message.properties)项目,若是有须要能够从个人Github下载。前端
简单看一下项目的目录和文件java
其中I18nApplication.java设置了一个CookieLocaleResolver
,采用cookie来控制国际化的语言。还设置一个LocaleChangeInterceptor
拦截器来拦截国际化语言的变化。git
@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.setCookieMaxAge(3600); slr.setCookieName("Language");//设置存储的Cookie的name为Language return slr; } @Bean public WebMvcConfigurer webMvcConfigurer() { return new WebMvcConfigurer() { //拦截器 @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**"); } }; } }
咱们再看一下hello.html中写了什么:github
<!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。web
因为浏览器默认的语言是中文,因此他默认会去messages_zh_CN.properties中找,若是没有就会去messages.properties中找国际化词。spring
而后咱们在浏览器中输入http://localhost:9090/hello?locale=en_US
,语言就会切到英文。一样的若是url后参数设置为locale=zh_CH
,语言就会切到中文。bootstrap
在咱们hello.html页面中,只有'i18n_page'和'hello'两个国际化信息,然而在实际项目中确定不会只有几个国际化信息那么少,一般都是成千上百个的,那咱们确定不能把这么多的国际化信息都放在messages.properties
一个文件中,一般都是把国际化信息分类存放在几个文件中。可是当项目大了之后,这些国际化文件也会愈来愈多,这时候在application.properties
文件中一个个的去配置这个文件也是不方便的,因此如今咱们实现一个功能自动加载制定目录下全部的国际化文件。浏览器
在项目下建立一个类继承ResourceBundleMessageSource
或者ReloadableResourceBundleMessageSource
,起名为MessageResourceExtension
。而且注入到bean中起名为messageSource
,这里咱们继承ResourceBundleMessageSource。cookie
@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; @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 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; } }
依次解释一下几个方法。
init()
方法上有一个@PostConstruct
注解,这会在MessageResourceExtension类被实例化以后自动调用init()
方法。这个方法获取到baseFolder
目录下全部的国际化文件并设置到basenameSet
中。而且设置一个ParentMessageSource
,这会在找不到国际化信息的时候,调用父MessageSource来查找国际化信息。getAllBaseNames()
方法获取到baseFolder
的路径,而后调用getAllFile()
方法获取到该目录下全部的国际化文件的文件名。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"; @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; } ... }
在咱们重写的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方法中,为了显示他的效果,咱们再建立一个ShopController
和UserController
,同时也建立对应的'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
下的dashboard
和merchant
方法下,和ShopController
类上。而且去除了原来dashboard
和merchant
方法下设置‘I18N_ATTRIBUTE’的语句。
准备工做都作好了,如今看看如何实现根据这些注解自动的指定国际化文件。
public class MessageResourceInterceptor implements HandlerInterceptor { @Override public void postHandle(HttpServletRequest req, HttpServletResponse rep, Object handler, ModelAndView modelAndView) { // 在方法中设置i18路径 if (null != req.getAttribute(MessageResourceExtension.I18N_ATTRIBUTE)) { return; } HandlerMethod method = (HandlerMethod) handler; // 在method上注解了i18 I18n i18nMethod = method.getMethodAnnotation(I18n.class); if (null != i18nMethod) { req.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, i18nMethod.value()); return; } // 在Controller上注解了i18 I18n i18nController = method.getBeanType().getAnnotation(I18n.class); if (null != i18nController) { req.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, i18nController.value()); return; } // 根据Controller名字设置i18 String 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); } @Override public 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 { ... @Bean public WebMvcConfigurer webMvcConfigurer() { return new WebMvcConfigurer() { //拦截器 @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**"); registry.addInterceptor(new MessageResourceInterceptor()).addPathPatterns("/**"); } }; } }
如今咱们再运行一下看看效果,看到每一个连接都显示的他们对应的国际化信息里的内容。
刚才完成了咱们整个国际化加强的基本功能,最后我把所有代码整理了一下,而且整合了bootstrap4来展现了一下功能的实现效果。
详细的代码能够看我Github上Spring-Boot-I18n-Pro的代码
原文地址:http://zzzzbw.cn
文章发布后,有人向我提到当把项目打成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的资源文件名了。