国际化之Struts2实现研究
一、基本原理
先不提Struts这一工具,也不用其他现成的工具,如何实现国际化?
最基本的实现就是,根据不同的Locale读取不同的文本。
例如有两个资源文件:
第一个:ApplicationResources_zh_CN.properties
第二个:ApplicationResources_en_US.properties
当Locale=zh_CN时,就去第一个文件查找;当Locale=en_US时,就去第二个文件查找。
二、自己写方案去实现
明白这个原理后,我们可以自己编写一套工具类,去实现国际化。通常,为了方便,我们需要自定义一个页面标签,类似于<s:text>那种,可以根据Locale获取相应语言的字符串。
三、借助Struts2
其实,Struts2也是通过这个原理去实现国际化的。我们何必再重复造轮子?
Struts2是开源的,源代码全都有,如果你的项目没有用到Struts2,也没有其他简便的国际化工具,我想你照搬Struts2那一套也不难。
四、Struts2国际化研究
本人查阅了很多网上的资料,其实Struts2国际化,有个问题。
Struts2的页面国际化,默认要走action才行,也就是如果你直接访问jsp文件,它是没有国际化效果的,除非每个jsp都通过action去访问(这也是Struts2推荐的方式)。
通常,大家都会写一个通用Action,去转发所有jsp。
比如我有个通用I18nAction,名为i18n,现在要从index.jsp直接跳转到main.jsp,如果写成
href="main.jsp"
这样跳转过去,main.jsp是没有国际化效果的,因为它没有经过action处理,所有要写成:
href="i18n.action?jsp=main.jsp"
我们将jsp的路径以一个参数的形式,交给action,action再去转发。
但是我们不想这么麻烦,每次都要写i18n.action,所以,高手们想,是不是能够编写一个过滤器(Filter) 自动实现此功能?当然可以!
我们编写一个Filter,拦截所有的jsp访问,然后转交给i18n.action去处理。
OK,这算是一种方法,不过,网上能够找到这种教程,所以我不再多讲,有兴趣可以baidu或google。
这种方法有个劣势,就是如果你直接访问jsp,那还是没有国际化效果。而且拦截器可能带来一些问题,因为它拦截了所有jsp,有时我们并不希望这样做。
我要讲另外一种方法,可以直接访问jsp,而无需经过action,当然也就无需拦截器。
我们从基本原理入手,从问题的根源入手,Struts2国际化是怎么实现的呢,其入口不是action,而是<s:text>标签,只要我们能找出<s:text>标签实现的源码,并稍作修改,就可以使其按照我们的模式去工作。我就是这么做的。
<s:text>标签的源码是怎样一个逻辑呢?请看下面的代码段:
(取自com.opensymphony.xwork2.util.LocalizedTextUtil.java)
就是根据Locale去寻找aTextName对应的value,
直接访问jsp时,之所以没有国际化效果,是因为Locale设置有问题。
Struts2获取text时默认取Loacle的方式为:
ActionContext.getContext().getLocale()
getLocale()方法源码如下:
这个getLocale()方法,是从
Map<String,Object> context里面去找
key= "com.opensymphony.xwork2.ActionContext.locale"的Object,这个Object就是Locale对象。
我们需要明确一点:
LocaledefultLocale=Locale.getDefault();
是获取操作系统的locale,这个值我们不应该改变(一改就会涉及到所有用户),也不推荐使用。
我们要根据浏览器去设置LOCALE值?怎么办呢?
打开IE的语言设置,我们可以看到,可以设置多个语言,所以说实际上浏览器端的Locale是一个列表。通过request可以获得它:
Enumeration locales=request.getLocales();
while(locales.hasMoreElements())
{
LocaleclientLocale=(Locale)locales.nextElement();
out.println("国别:"+clientLocale.getDisplayCountry()+"<br>");
out.println("语言:"+clientLocale.getDisplayLanguage()+"<br>");
}
另外,获取客户端用户设置的第一个locale:
Localefirst=request.getLocale();
如此,我们有了浏览器端的Locale,但是每次都去request里面取,是不是有些麻烦?稍后,可以改进一下。
这还不够,我们要做到的是根据用户的选择,去切换语言类型。
不同的浏览器、不同的访问应该有不同的Locale,所以应该把Locale放在HttpSession中。所以说切换语言其实很简单,将Locale存入Session中,然后国际化的时候从Session中去寻找Locale就行了。
综上,总结出国际化的步骤:
第一点:默认情况下(用户没有切换语言),则Session里面没有Locale值,此时从用户请求的浏览器端读取,并设置到Session中。
第二点:用户选择了切换语言,则将切换后的语言设置到Session中。
第一点Struts2是做到了,每次访问一个jsp,或Action,Struts都会new一个新的Map<String, Object> context,如下源码所示:
(取自org.apache.struts2.dispatcher. Dispatcher.java)
但是第二点,Struts的处理方式就不是我想要的形式,Struts是怎样切换语言环境的呢?
是在action后面加request_locale参数,例如
changeLan.action?request_locale=en_US
执行每个action时,它都会去检查是否有request_locale这个参数,如果有就会将Locale设置到session里面,其key为:"WW_TRANS_I18N_LOCALE"
同时执行:
ActionContext.setLocale(locale);改变ActionContext里面的Map<String, Object>context值
我之前说了:
Struts2使用Locale时,默认是从ActionContext中取:
ActionContext.getContext().getLocale()
那是不是以后取出的Locale都是第一次设置的locale呢?
答案是否定的。实际上每次访问一个jsp或Action时,Struts都会new一个新的Map<String, Object> context,并且如果是访问的Action,还会额外的经过一个名叫I18nInterceptor的拦截器,当session里面不存在Locale时,它会添加进去,如果存在就不添加。最后重新设置context里面的Locale(这就说明,每次访问Action时,ActionContext里面的Locale都是新的)。见下面的源码:
看到没有,这个拦截器会拦截所有Action,当locale==null(也即requested_locale==null)时就会去session中取Locale(如果没有,则取ActionContext.getLocale),且最后,始终会执行saveLocale方法,这个方法调用了ActionContext.setLocale(locale),重新设置Locale。
所有说,按Struts的模式(action后面加request_locale参数)切换了语言后,当访问Action时,locale实际上是从session里面取出来的,但是当访问jsp时,因为I18nInterceptor拦截器不会执行,而ActionContext里面的Map<String, Object>context又是新new出来的,且在new context时,用的是request.getLocale()(见上面我摘取的Dispatcher.java源码),所以访问jsp时,locale不是从session中取到的,而是读取的浏览器Locale。
好了,我们知道struts2的这个毛病之后,应该怎样改进呢?
很简单的逻辑:访问jsp时,如果session里面的Locale不为空,就应该以它为准,而不是以浏览器的Locale为准(除非session里面的Locale为空)。
显然,我们不希望每个jsp都通过action,进而通过I18nInterceptor拦截器。
我们希望直接访问jsp就能实现我们想要的那种效果。
用户选择切换语言时,session里面一定是有我们想要的那个Locale的(我们可以设置进去)。
关键是<s:text>标签获取Locale时出了问题,上面我已经说过,它是从
ActionContext.getContext().getLocale()
里面去拿的,在直接访问jsp的情况下,它的Locale值是浏览器端的语言。我们将其换成session里面的值不就行了?Yes!
下面就是我改造后的getLocale()方法:
或者换一个更标准的写法:
OK,如此一来,国际化就完美了,我们做一个changeLocale.jsp,嵌入到指定页面,只要用户一切换语言,访问其他jsp时就能正确的国际化了,不需要通过action,不需要拦截器。此时整个环境也都是用户选择的那种Locale,所以即使在java代码中,也能正确的识别并做国际化处理。
另外,当用户不切换语言时,我们能识别用户使用的浏览器语言,因为我们默认设置的是request.getLocale(),当用户切换语言后,session里面有Locale了,以后就用从session里面读取的Locale。
另外,补充一点在国际化研究中,实践得出的一些关于Session的理解:
服务器重启,session仍然有效
浏览器重启,session失效
可见session应该是双向的,服务器存一个,浏览器端也存一个。如果
服务器重启,它的session没有丢失(应该是保存在了磁盘上),而
浏览器重启,则session丢失(应该是保存在浏览器端的内存中)
进一步研究得出结论:
session是服务器端和浏览器端双向交互的。不过浏览器端存的是sessionid(Tomcat下Java程序通常是一个32位字符串,这个id是存在cookie中的,名为JSESSIONID,如下图是我在FireFox中捕捉到的),而服务器端存的是session对象,当浏览器访问服务器时,如果是以jsp、action等非静态访问形式,第一次连接时,服务器会新建一个session对象,以后的访问中,只要浏览器没有重启或者session没有过期或销毁,那么这个session是不会变的,也就是说后面用的session都是第一次建立的那个。
实际上,访问jsp时,会调用如下方法:
HttpServletRequestgetSession(true);
该方法,如果session为空,则会new一个session,否则,返回已有的session。官方解释为:
publicHttpSession getSession(boolean arg)
Returns the current HttpSession associatedwith this request or, if if there is no current session and arg is true,returns a new session.
If arg is false and the request has no validHttpSession, this method returns null.
Tomcat默认是启用了session持久化技术的(session persistence),也就是说服务器关闭后,session会存在磁盘上(文件名为session.ser),重启服务器时,只要session没过期,仍然可以用。
(提醒一点,存入session的类建议实现序列化接口,比如User什么的)
在tomcat的配置文件context.xml中有一个<Manager ... />标签,可以配置session的持久化。
在JSP页面,我们可以设置
<%@ page session="false"%>
这样设置呢,不是不让页面创建Session,而是在此JSP页面无法使用session,可以减少网络数据传输。
另外补充一个“URL重写技术”:
通常session id是保存在浏览器的cookie中的,由于cookie可以被人为的禁止,必须有其他机制以便在cookie被禁止时仍然能够把session id传递回服务器。URL重写,就是把session id直接附加在URL路径的后面,附加方式也有两种,一种是作为URL路径的附加信息,表现形式为
http://...../xxx;jsessionid=ByOK3vjFD75aPnrF788764
另一种是作为查询字符串附加在URL后面,表现形式为
http://...../xxx?jsessionid=ByOK3vjFD75aPnrF88764
这两种方式对于用户来说是没有区别的,只是服务器在解析的时候处理的方式不同,采用第一种方式也有利于把session id的信息和正常程序参数区分开来。
为了在整个交互过程中始终保持状态,就必须在每个客户端可能请求的路径后面都包含这个sessionid。
还有一个问题,是不是关闭浏览器后session就消失了呢?
回答:session是在服务器端的,你关不关浏览器对它没有影响,因为你关闭浏览器时,只是浏览器端的session id丢了,但是浏览器并会主动通知服务器说“我已经关闭了,你将session注销吧”。
最后一个问题,session如何过期的呢?
1)主动注销
服务器会check session object 是不是valid的,如果是无效的。如果invalid,则先throw IllegalStateException,然后开始后续处理(从map中移除,通知listener等)
代码片段如下:
/**
* Perform the internal processing required to invalidate this session,
* without triggering an exception if the session has already expired.
*
* @param notify Should we notify listeners about the demise of
* this session?
*/
public void expire(boolean notify) {
// Check to see if expire is in progress or has previously been called
if (expiring || !isValid)
return;
synchronized (this) {
// Check again, now we are inside the sync so this code only runs once
// Double check locking - expiring and isValid need to be volatile
if (expiring || !isValid)
return;
expiring = true;
setValid(false);
manager.remove(this, true);//在管理对象中讲这个session object删除(内部也是map实现的)
// 此处nofity标示是否在注销session的时候发送Evnet给listener,典型的观察者pattern
if (notify) {
fireSessionEvent(Session.SESSION_DESTROYED_EVENT, null);
}
expiring = false;
// Unbind any objects associated with this session
String keys[] = keys();
for (int i = 0; i < keys.length; i++)
removeAttributeInternal(keys[i], notify);
}
}
2)超时注销
如果浏览器端一直有操作(即一直有请求),那么session就不会过期,是什么原理呢?
其实,有一个守护线程去检查session到期时间,每两次访问的时间间隔,如果超过timeout时间,则执行销毁工作。所以,想让session永不过期,可以在timeout时间内,一直保持有request。
不过session可能会意外丢失,这个就不是我们能控制的了。