网站国际化多语言之Struts2实现研究
2012年12月16日


一、基本原理

先不提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的路径以一个参数的形式,交给actionaction再去转发。

 

但是我们不想这么麻烦,每次都要写i18n.action,所以,高手们想,是不是能够编写一个过滤器(Filter) 自动实现此功能?当然可以!

我们编写一个Filter,拦截所有的jsp访问,然后转交给i18n.action去处理。

 

OK这算是一种方法,不过,网上能够找到这种教程,所以我不再多讲,有兴趣可以baidugoogle

 

这种方法有个劣势,就是如果你直接访问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对象。

 

我们需要明确一点:

Locale defultLocale=Locale.getDefault();

是获取操作系统的locale,这个值我们不应该改变(一改就会涉及到所有用户),也不推荐使用。

 

我们要根据浏览器去设置LOCALE值?怎么办呢?

打开IE的语言设置,我们可以看到,可以设置多个语言,所以说实际上浏览器端的Locale是一个列表。通过request可以获得它:

Enumeration locales=request.getLocales();

while(locales.hasMoreElements())

{

Locale clientLocale=(Locale)locales.nextElement();

out.println("国别:"+clientLocale.getDisplayCountry()+"<br>");

out.println("语言:"+clientLocale.getDisplayLanguage()+"<br>");

   }

另外,获取客户端用户设置的第一个locale

Locale first=request.getLocale();

 

如此,我们有了浏览器端的Locale,但是每次都去request里面取,是不是有些麻烦?稍后,可以改进一下。

 

这还不够,我们要做到的是根据用户的选择,去切换语言类型。

不同的浏览器、不同的访问应该有不同的Locale,所以应该把Locale放在HttpSession中。所以说切换语言其实很简单,将Locale存入Session中,然后国际化的时候从Session中去寻找Locale就行了。

 

综上,总结出国际化的步骤:

第一点:默认情况下(用户没有切换语言),则Session里面没有Locale值,此时从用户请求的浏览器端读取,并设置到Session中。

第二点:用户选择了切换语言,则将切换后的语言设置到Session中。

 

第一点Struts2是做到了,每次访问一个jsp,或ActionStruts都会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呢?

答案是否定的。实际上每次访问一个jspAction时,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是服务器端和浏览器端双向交互的。不过浏览器端存的是sessionidTomcatJava程序通常是一个32位字符串,这个id是存在cookie中的,名为JSESSIONID,如下图是我在FireFox中捕捉到的),而服务器端存的是session对象,当浏览器访问服务器时,如果是以jspaction等非静态访问形式,第一次连接时,服务器会新建一个session对象,以后的访问中,只要浏览器没有重启或者session没有过期或销毁,那么这个session是不会变的,也就是说后面用的session都是第一次建立的那个。

 

 

实际上,访问jsp时,会调用如下方法:

HttpServletRequest getSession(true);

该方法,如果session为空,则会new一个session,否则,返回已有的session。官方解释为:

public HttpSession getSession(boolean arg)

Returns the current HttpSession associated with 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 valid HttpSession, 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的信息和正常程序参数区分开来。

为了在整个交互过程中始终保持状态,就必须在每个客户端可能请求的路径后面都包含这个session id

 

还有一个问题,是不是关闭浏览器后session就消失了呢?

回答:session是在服务器端的,你关不关浏览器对它没有影响,因为你关闭浏览器时,只是浏览器端的session id丢了,但是浏览器并会主动通知服务器说“我已经关闭了,你将session注销吧”。

 

最后一个问题,session如何过期的呢?

1)主动注销

服务器会check session object 是不是valid,如果是无效的,invalid,则先throw IllegalStateException然后开始后续处理(从map中移除,通知listener等)

代码片段如下(Tomcat源码):

/**  
 * 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可能会意外丢失,这个就不是我们能控制的了。

 

 

好了,国际化涉及到的难题基本已经讲解完了,不懂的多看几遍,理解理解。我也是花了很多时间实践和分析才得出的,若有不正确之处,还望赐教。

 

附:Struts2国际化的DEMO项目

下载地址:http://dl.vmall.com/c04g39g2q7

strutsjar包需自己添加,2.3以上的版本均可,需要把xwork-core-2.3.4.1.jar里面的com/opensymphony/xwork2/ActionContext.class删掉,因为我重写了这个类)