本身動手在Spring-Boot上增強國際化功能

前言

公司將項目由Struts2轉到Springmvc了,因爲公司業務是境外服務,因此對國際化功能需求很高。Struts2自帶的國際化功能相對Springmvc來講更加完善,不過spring很大的特性就是可定定製化性強,因此在公司項目移植的到Springmvc的時候增長了其國際化的功能。特此整理記錄而且完善了一下。

本文主要實現的功能:html

  1. 從文件夾中直接加載多個國際化文件
  2. 後臺設置前端頁面顯示國際化信息的文件
  3. 利用攔截器和註解自動設置前端頁面顯示國際化信息的文件
注:本文不詳細介紹怎麼配置國際化,區域解析器等。

實現

國際化項目初始化

先建立一個基本的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

hello.html

因爲瀏覽器默認的語言是中文,因此他默認會去messages_zh_CN.properties中找,若是沒有就會去messages.properties中找國際化詞。spring

而後咱們在瀏覽器中輸入http://localhost:9090/hello?locale=en_US,語言就會切到英文。一樣的若是url後參數設置爲locale=zh_CH,語言就會切到中文。bootstrap

hello.html_en_US

從文件夾中直接加載多個國際化文件

在咱們hello.html頁面中,只有'i18n_page'和'hello'兩個國際化信息,然而在實際項目中確定不會只有幾個國際化信息那麼少,一般都是成千上百個的,那咱們確定不能把這麼多的國際化信息都放在messages.properties一個文件中,一般都是把國際化信息分類存放在幾個文件中。可是當項目大了之後,這些國際化文件也會愈來愈多,這時候在application.properties文件中一個個的去配置這個文件也是不方便的,因此如今咱們實現一個功能自動加載制定目錄下全部的國際化文件。瀏覽器

繼承ResourceBundleMessageSource

在項目下建立一個類繼承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;
    }
}

依次解釋一下幾個方法。

  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()方法已經執行。

image

而後咱們再建立兩組國際化信息文件:'dashboard'和'merchant',裏面分別只有一個國際化信息:'dashboard.hello'和'merchant.hello'。

image

以後再修改一下hello.html文件,而後訪問hello頁面。

...
<body>
<h1>國際化頁面!</h1>
<p th:text="#{hello}"></p>
<p th:text="#{merchant.hello}"></p>
<p th:text="#{dashboard.hello}"></p>
</body>
...

imageimage

能夠看到網頁中加載了'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方法中,爲了顯示他的效果,咱們再建立一個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 {
    @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


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

相關文章
相關標籤/搜索