javascript
自己动手在Spring-Boot上加强国际化功能
前言
公司將項目由Struts2轉到Springmvc了,由于公司業務是境外服務,所以對國際化功能需求很高。Struts2自帶的國際化功能相對Springmvc來說更加完善,不過spring很大的特性就是可定定制化性強,所以在公司項目移植的到Springmvc的時候增加了其國際化的功能。特此整理記錄并且完善了一下。
本文主要實現的功能:
注:本文不詳細介紹怎么配置國際化,區域解析器等。
實現
國際化項目初始化
先創建一個基本的Spring-Boot+thymeleaf+國際化信息(message.properties)項目,如果有需要可以從我的Github下載。
簡單看一下項目的目錄和文件
其中I18nApplication.java設置了一個CookieLocaleResolver,采用cookie來控制國際化的語言。還設置一個LocaleChangeInterceptor攔截器來攔截國際化語言的變化。
public class I18nApplication {public static void main(String[] args) {SpringApplication.run(I18nApplication.class, args);}public LocaleResolver localeResolver() {CookieLocaleResolver slr = new CookieLocaleResolver();slr.setCookieMaxAge(3600);slr.setCookieName("Language");//設置存儲的Cookie的name為Languagereturn slr;}public WebMvcConfigurer webMvcConfigurer() {return new WebMvcConfigurer() {//攔截器public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LocaleChangeInterceptor()).addPathPatterns("/**");}};} } 復制代碼我們再看一下hello.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。
("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
中寫加載文件的方法了。
("messageSource") public class MessageResourceExtension extends ResourceBundleMessageSource {private final static Logger logger = LoggerFactory.getLogger(MessageResourceExtension.class);/*** 指定的國際化文件目錄*/(value = "${spring.messages.baseFolder:i18n}")private String baseFolder;/*** 父MessageSource指定的國際化文件*/(value = "${spring.messages.basename:message}")private String basename;public 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;} } 復制代碼依次解釋一下幾個方法。
所以簡單來說就是在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方法)。
("messageSource") public class MessageResourceExtension extends ResourceBundleMessageSource {...public static String I18N_ATTRIBUTE = "i18n_attribute";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里加兩個方法。
public class HelloController {("/hello")public String index(HttpServletRequest request) {request.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, "hello");return "system/hello";}("/dashboard")public String dashboard(HttpServletRequest request) {request.setAttribute(MessageResourceExtension.I18N_ATTRIBUTE, "dashboard");return "dashboard";}("/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中設置國際化文件實在太麻煩了,所以現在我們實現自動判定來顯示對應的文件。
首先我們創建一個注解,這個注解可以放在類上或者方法上。
({ElementType.TYPE, ElementType.METHOD}) (RetentionPolicy.RUNTIME) public I18n {/*** 國際化文件名*/String value(); } 復制代碼然后我們把這個創建的I18n注解放在剛才的Controller方法中,為了顯示他的效果,我們再創建一個ShopController和UserController,同時也創建對應的'shop'和'user'的國際化文件,內容也都是一個'hello'。
public class HelloController {("/hello")public String index() {return "system/hello";}18n("dashboard")("/dashboard")public String dashboard() {return "dashboard";}18n("merchant")("/merchant")public String merchant() {return "merchant";} } 復制代碼18n("shop") public class ShopController {("shop")public String shop() {return "shop";} } 復制代碼 public class UserController {("user")public String user() {return "user";} } 復制代碼我們把I18n注解分別放在HelloController下的dashboard和merchant方法下,和ShopController類上。并且去除了原來dashboard和merchant方法下設置‘I18N_ATTRIBUTE’的語句。
準備工作都做好了,現在看看如何實現根據這些注解自動的指定國際化文件。
public class MessageResourceInterceptor implements HandlerInterceptor {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上注解了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);}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啟動類:
public class I18nApplication {...public WebMvcConfigurer webMvcConfigurer() {return new WebMvcConfigurer() {//攔截器public 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:
("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上加强国际化功能的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 异地取数
- 下一篇: linux以太坊开发环境搭建