本文是《轻量级 Java Web 框架架构设计》的系列博文。
在 JSTL、Struts、Spring 中都提供了 I18N(国际化)支持,也就是说,同一个页面可支持多种语言,这是一个非常有用的特性。当然,底层都是使用的 Java 提供的 ResourceBundle 技术,通过设置不同的 Locale 来访问具体的语言包(实际上就是一个 properties 文件),这样就实现了国际化支持。
对于语言包还有一些命名规则,比如:中文语言包为 xxx_zh_CN.properties,英文语言包为 xxx_en_US.properties。xxx 表示语言包的前缀(可以不要),zh/en 表示语言,CN/US 表示国家。
市面上这些工具仅提供了 JSP 中的国际化支持,实际上它们都是通过 JSP 标签实现的。那么如果想在 JS 中使用 Java 语言包,可以吗?恐怕不太现实吧,因为 JS 是无法读取 WEB-INF 下 classes 中的 properties 文件的。
所以,如果想让 JSP 与 JS 都实现国际化支持,必须想一个办法让 JS 可以读取语言包。我们知道 JS 是可以读取 JSON 的,那么就可以将 Java 语言包通过代码生成技术,生成为对应的 JS 语言包(JSON 格式),这样问题是否就能解决了呢?
下面就是我的解决方案。
第一步:创建一个 I18NPlugin 插件类
该插件类继承于 Smart 框架提供的 Plugin 接口,所以可以进行初始化(实现 init 方法即可)。我就是想在初始化的时候,读取 Java 语言包,从而生成 JS 语言包。详细的代码如下:
public class I18NPlugin implements Plugin {
@Override
public void init() {
// 生成 JS 语言包
String appBasePath = ClassUtil.getClassPath() + "../../";
generateJS(appBasePath);
}
public static void generateJS(String appBasePath) {
// 定义相关根路径
String propsBasePath = appBasePath + I18NConstant.I18N_PROPS_PATH;
String jsBasePath = appBasePath + I18NConstant.I18N_JS_PATH;
// 获取属性文件目录
File propsBaseDir = new File(propsBasePath);
if (propsBaseDir.exists()) {
// 获取所有属性文件
String[] propsFileNames = propsBaseDir.list();
if (ArrayUtil.isNotEmpty(propsFileNames)) {
// 遍历所有属性文件
for (String propsFileName : propsFileNames) {
// 定义 JS 文件路径
String jsFilePath = jsBasePath + propsFileName.substring(0, propsFileName.lastIndexOf(".")) + ".js";
// 从属性文件中加载相关数据
Map<String, String> map = new HashMap<String, String>();
Properties props = FileUtil.loadPropFile(I18NConstant.I18N_PATH + propsFileName);
Enumeration names = props.propertyNames();
while (names.hasMoreElements()) {
String name = (String) names.nextElement();
String value = props.getProperty(name);
map.put(name, value);
}
// 将数据转换为 JSON 并写入 JS 文件
String jsFileContent = "window.I18N = " + JSONUtil.toJSON(map) + ";";
FileUtil.writeFile(jsFilePath, jsFileContent);
}
}
}
}
}
可见,通过读取 classpath 下 i18n 目录中所有的 properties 文件(Java 语言包),并遍历这些文件,然后生成对应的 js 文件(JS 语言包)。
这里用到了 I18NConstant 常量类,代码如下:
public interface I18NConstant {
String SYSTEM_LANGUAGE = "system_language";
String COOKIE_LANGUAGE = "cookie_language";
String I18N_PATH = "i18n/";
String I18N_PROPS_PATH = "/WEB-INF/classes/i18n/";
String I18N_JS_PATH = "/www/asset/script/i18n/";
boolean RELOAD_FLAG = true;
}
下面就是开发人员需要编写的 Java 语言包:
i18n_en_US.properties | i18n_zh_CN.properties |
common.smart_sample=Smart Sample common.copyright=Copyright Reserved © 2013 common.language=System Language common.logout=Logout common.logout_confirm=Do you want to logout system? common.action=Action common.edit=Edit common.delete=Delete common.save=Save common.cancel=Cancel product=Product customer=Customer customer.customer_list=Customer List customer.new_customer=New Customer customer.view_customer=View Customer customer.edit_customer=Edit Customer customer.customer_name=Customer Name customer.description=Description customer.delete_confirm=Do you want to delete customer {0}? | common.smart_sample=Smart 示例 common.copyright=版权所有 © 2013 common.language=系统语言 common.logout=注销 common.logout_confirm=你想注销系统吗? common.action=操作 common.edit=编辑 common.delete=删除 common.save=保存 common.cancel=取消 product=产品 customer=客户 customer.customer_list=客户列表 customer.new_customer=新增客户 customer.view_customer=查看客户 customer.edit_customer=编辑客户 customer.customer_name=客户名称 customer.description=描述 customer.delete_confirm=你确定删除客户 {0} 吗? |
下面就是 I18N 插件自动生成的 JS 语言包:
i18n_en_US.js | i18n_zh_CN.js |
window.I18N = { "common.delete": "Delete", "customer.customer_list": "Customer List", "customer.new_customer": "New Customer", "common.language": "System Language", "common.logout": "Logout", "common.save": "Save", "customer": "Customer", "common.copyright": "Copyright Reserved © 2013", "common.action": "Action", "common.cancel": "Cancel", "common.logout_confirm": "Do you want to logout system?", "product": "Product", "customer.description": "Description", "customer.edit_customer": "Edit Customer", "customer.delete_confirm": "Do you want to delete customer {0}?", "customer.view_customer": "View Customer", "common.edit": "Edit", "common.smart_sample": "Smart Sample", "customer.customer_name": "Customer Name" }; | window.I18N = { "common.delete": "删除", "customer.customer_list": "客户列表", "customer.new_customer": "新增客户", "common.language": "系统语言", "common.logout": "注销", "common.save": "保存", "customer": "客户", "common.copyright": "版权所有 © 2013", "common.action": "操作", "common.cancel": "取消", "common.logout_confirm": "你想注销系统吗?", "product": "产品", "customer.description": "描述", "customer.edit_customer": "编辑客户", "customer.delete_confirm": "你确定删除客户 {0} 吗?", "customer.view_customer": "查看客户", "common.edit": "编辑", "common.smart_sample": "Smart 示例", "customer.customer_name": "客户名称" }; |
可见,JS 语言包与 Java 语言包完全对应,只不过条目排列顺序不一致罢了(因为是根据 HashMap 生成的 JSON)。
现在 Java 与 JS 的语言包可以同步了,当然只能在启动 Tomcat 时,才会执行代码生成。那么就有以下 2 个问题:
- 如何让用户自由切换语言包,并且记住自己上次所做的选择?
- 为了提高开发效率,若修改了 properties 文件,如何实现 reload,并自动生成 js 文件?(注意:仅针对开发环境而言,对于生产环境无需这样处理)
在解决这两个问题之前,有必要先在 JSP 中引入 JSTL 的国际化标签库,因为它实在太有用了。
第二步:在 JSP 中使用 JSTL 国际化标签库
首先需要引入一个名为 fmt 的标签库:
<%@ taglib prefix="f" uri="http://java.sun.com/jsp/jstl/fmt" %>
不妨定义它的前缀为 f,其实就是该标签的简称。有些人喜欢命名为 fmt,这都无所谓了。
我们就可以在 JSP 中使用 f:message 标签来实现国际化支持了。用法如下:
<f:message key="common.smart_sample"/>
以上代码会在相应的 Java 语言包中根据 key 寻找 value,可能为 Smart Sample(英文环境)或 Smart 示例(中文环境)。
如何分辨是哪种语言环境呢?需要再使用 f:message 标签设置默认语言包:
<f:setBundle basename="i18n.i18n_${system_language}"/>
其中定义了一个 system_language 的变量,该变量是谁来负责初始化呢?我们不妨带着这个问题进入下一步吧。
第三步:提供一个切换系统语言的控件
我们可以将用户设置的系统语言会存入到 Cookie 中,也可以对 Cookie 设置一个有效期,不妨让它尽可能的长一些,比如一年。需要说明的是,无法设置为永久,因为 IE 不支持。
不妨在页面的 footer 处,提供一个系统语言切换的控件,如下图:
JSP 代码如下:
<%@ page pageEncoding="UTF-8" %>
<div id="footer">
<div id="copyright">
<span><f:message key="common.copyright"/></span>
</div>
<div id="language">
<span><f:message key="common.language"/>:</span>
<a href="#" data-value="zh_CN">中文</a>
<span>|</span>
<a href="#" data-value="en_US">English</a>
</div>
</div>
谁负责将系统语言存入 Cookie?当然是 JS 了。不妨直接在 global.js 中扩展一下吧。
$(function() {
...
// 切换系统语言
$('#language').find('a').click(function() {
var language = $(this).data('value');
$.cookie('cookie_language', language, {expires: 365, path: '/'});
location.reload();
});
});
将 id 为 language 的元素下所有的 a(链接)绑定 click 事件,并将 a 中的 data-value 属性值放入到 Cookie 中,最后刷新页面,就会自动切换系统语言。
看似挺神奇的,在 JS 里只是告诉了 Cookie 当前的系统语言,就可以改变整个系统的语言环境了。后端应如何实现呢?
第四步:使用 Filter 获取系统语言并重新加载语言包
每当用户发出一个请求(比如上面进行 Cookie 操作后的刷新页面),我们都可获取用户当前所选择的系统语言,可以根据以下策略进行获取:
- 首先从 Cookie 中寻找
- 然后从浏览器中寻找
- 最后从操作系统中寻找
@WebFilter("/*")
public class I18NFilter implements Filter {
private static final String wwwPath = ConfigHelper.getStringProperty(Constant.APP_WWW_PATH);
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 获取请求路径
HttpServletRequest req = (HttpServletRequest) request;
String requestPath = WebUtil.getRequestPath(req);
if (!requestPath.startsWith(wwwPath)) {
// 获取系统语言并放入 Request 中
String systemLanguage = getSystemLanguage((HttpServletRequest) request);
request.setAttribute(I18NConstant.SYSTEM_LANGUAGE, systemLanguage);
// 判断是否重新
if (I18NConstant.RELOAD_FLAG) {
// 清理 ResourceBundle 缓存
ResourceBundle.clearCache();
// 生成 JS 语言包
String appBasePath = req.getServletContext().getRealPath("/");
I18NPlugin.generateJS(appBasePath);
}
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
private static String getSystemLanguage(HttpServletRequest request) {
// 先从 Cookie 中获取系统语言
String language = WebUtil.getCookie(request, I18NConstant.COOKIE_LANGUAGE);
if (StringUtil.isEmpty(language)) {
// 若为空,则获取浏览器首语言
language = request.getLocale().toString();
if (StringUtil.isEmpty(language)) {
// 若为空,则获取操作系统语言
language = Locale.getDefault().toString();
}
}
return language;
}
}
在 doFilter 方法中,做了两件非常重要的事情,其中第一件事情是必须要做的,第二件事情是可选的。
- 第一件事情就是初始化系统语言,也就是首先获取系统语言,然后将其放入 Request 属性中,名称就是 system_language(放在 I18NConstant 常量中了)。
- 第二件事情其实是为我们开发人员做的,在开发阶段,我们可以将 RELOAD_FLAG 设为 true,这样每次请求都会清理 ResourceBundle 缓存并且生成 JS 语言包。
需要注意的是,Filter 会拦截所有的请求,包括:动态请求(Action 请求)、静态请求(JS、CSS、图片),所以我们需要过滤掉静态请求,也就是 requestPath 中前缀为 /www/ 的请求。
还需要注意 getSystemLanguage 方法,它是获取系统语言的具体算法,其中 Cookie 级别是最高的,然后是浏览器语言,最后才是操作系统语言。
通过以上步骤,JSP 的国际化已经可以完全支持了,因为有 JSTL 国际化标签,还有 Java 提供的 ResourceBundle 语言包技术。那么,JS 又如何实现国际化呢?
第五步:实现 JS 国际化支持
比如,有这样一个 JS 脚本:
if (confirm('Do you want to delete customer [' + customerName + ']?')) {
...
}
其中不仅有需要国际化处理的文字,而且还有参数。
对于这类情况,最好能够扩展一个 jQuery 函数,让它为我们简化开发过程,我们可以这样写:
if (confirm($.i18n('customer.delete_confirm', customerName))) {
...
}
在 $.i18n 函数中,第一个参数 customer.delete_confirm 为 JS 语言包中的 key,从第二个参数开始的都是动态参数,可使用它们来填充语言包中的占位符。我们不妨回头看一下 JS 语言包中对应的条目是怎样的:
window.I18N = {
...
"customer.delete_confirm": "Do you want to delete customer {0}?",
...
};
这里就是通过 customerName 去填充 {0} 的,是不是很有意思呢?那么又是如何实现的呢?可在 global.js 中添加以下代码:
$(function() {
$.extend($, {
i18n: function() {
var args = arguments;
var key = args[0];
var value = window['I18N'][key];
if (value) {
if (args.length > 0) {
value = value.replace(/\{(\d+)\}/g, function(m, i) {
return args[parseInt(i) + 1];
});
}
return value;
} else {
return key;
}
}
});
...
若在 window.I18N 变量中能够通过 key 进行匹配,那么就通过正则表达式替换其中的 {X} 占位符。若匹配不上,则直接返回 key。
最后不要忘了在 JSP 中引入所生成的 js 文件:
<script type="text/javascript" src="${BASE}/www/asset/script/i18n/i18n_${system_language}.js"></script>
同样需要从 Request 中读取 system_language 属性,才能定位到相应的 JS 语言包。
最后一步:使用 Smart I18N 插件
在您的 Maven 配置文件中添加如下代码即可:
...
<dependency>
<groupId>com.smart</groupId>
<artifactId>smart-plugin-i18n</artifactId>
<version>1.0</version>
</dependency>
...
总结
- 我们只需编写 Java 语言包(properties 文件)即可,Smart I18N 插件将自动生成 JS 语言包(一个存放 JSON 数据的 js 文件)。
- 使用 JSTL 的 fmt 标签实现 JSP 中的国际化,使用 $.i18n 函数实现 JS 中的国际化。
- 在开发过程中我们可以开启 RELOAD_FLAG 标志,它将自动 reload properties 文件并同步生成 js 文件,在生产环境下请关闭此标志。
还等什么呢?赶快去使用一下 Smart I18N 插件提供的国际化支持吧!
源码地址:http://git.oschina.net/huangyong/smart-plugin-i18n
贴两个界面展示一下最终的效果: