松哥之前写过 Spring Boot 国际化的问题,不过那一次没讲源码,这次咱们整点源码来深入理解下这个问题。
国际化,也叫 i18n,为啥叫这个名字呢?因为国际化英文是 internationalization ,在 i 和 n 之间有 18 个字母,所以叫 i18n。我们的应用如果做了国际化就可以在不同的语言环境下,方便的进行切换,最常见的就是中文和英文之间的切换,国际化这个功能也是相当的常见。
# 1.SpringMVC 国际化配置
还是先来说说用法,再来说源码,这样大家不容易犯迷糊。我们先说在 SSM 中如何处理国际化问题。
首先国际化我们可能有两种需求:
- 在页面渲染时实现国际化(这个借助于 Spring 标签实现)
- 在接口中获取国际化匹配后的消息
大致上就是上面这两种场景。接下来松哥通过一个简单的用法来和大家演示下具体玩法。
首先我们在项目的 resources 目录下新建语言文件,language_en_US.properties 和 language_zh-CN.properties,如下图:
内容分别如下:
language_en_US.properties:
login.username=Username
login.password=Password
language_zh-CN.properties:
login.username=用户名
login.password=用户密码
这两个分别对应英中文环境。配置文件写好之后,还需要在 SpringMVC 容器中提供一个 ResourceBundleMessageSource 实例去加载这两个实例,如下:
<bean class="org.springframework.context.support.ResourceBundleMessageSource" id="messageSource">
<property name="basename" value="language"/>
<property name="defaultEncoding" value="UTF-8"/>
</bean>
这里配置了文件名 language 和默认的编码格式。
接下来我们新建一个 login.jsp 文件,如下:
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<spring:message code="login.username"/> <input type="text"> <br>
<spring:message code="login.password"/> <input type="text"> <br>
</body>
</html>
在这个文件中,我们通过 spring:message
标签来引用变量,该标签会根据当前的实际情况,选择合适的语言文件。
接下来我们为 login.jsp 提供一个控制器:
@Controller
public class LoginController {
@Autowired
MessageSource messageSource;
@GetMapping("/login")
public String login() {
String username = messageSource.getMessage("login.username", null, LocaleContextHolder.getLocale());
String password = messageSource.getMessage("login.password", null, LocaleContextHolder.getLocale());
System.out.println("username = " + username);
System.out.println("password = " + password);
return "login";
}
}
控制器中直接返回 login 视图即可。
另外我这还注入了 MessageSource 对象,主要是为了向大家展示如何在处理器中获取国际化后的语言文字。
配置完成后,启动项目进行测试。
默认情况下,系统是根据请求头的中 Accept-Language 字段来判断当前的语言环境的,该这个字段由浏览器自动发送,我们这里为了测试方便,可以使用 POSTMAN 进行测试,然后手动设置 Accept_Language 字段。
首先测试中文环境:
然后测试英文环境:
都没问题,完美!同时观察 IDEA 控制台,也能正确打印出语言文字。
上面这个是基于 AcceptHeaderLocaleResolver 来解析出当前的区域和语言的。
有的时候,我们希望语言环境直接通过请求参数来传递,而不是通过请求头来传递,这个需求我们通过 SessionLocaleResolver 或者 CookieLocaleResolver 都可以实现。
先来看 SessionLocaleResolver。
首先在 SpringMVC 配置文件中提供 SessionLocaleResolver 的实例,同时配置一个拦截器,如下:
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<bean class="org.springframework.web.servlet.i18n.LocaleChangeInterceptor">
<property name="paramName" value="locale"/>
</bean>
</mvc:interceptor>
</mvc:interceptors>
<bean class="org.springframework.web.servlet.i18n.SessionLocaleResolver" id="localeResolver">
</bean>
SessionLocaleResolver 是负责区域解析的,这个没啥好说的。拦截器 LocaleChangeInterceptor 则主要是负责参数解析的,我们在配置拦截器的时候,设置了参数名为 locale(默认即此),也就是说我们将来可以通过 locale 参数来传递当前的环境信息。
配置完成后,我们还是来访问刚才的 login 控制器,如下:
此时我们可以直接通过 locale 参数来控制当前的语言环境,这个 locale 参数就是在前面所配置的 LocaleChangeInterceptor 拦截器中被自动解析的。
如果你不想配置 LocaleChangeInterceptor 拦截器也是可以的,直接自己手动解析 locale 参数然后设置 locale 也行,像下面这样:
@Controller
public class LoginController {
@Autowired
MessageSource messageSource;
@GetMapping("/login")
public String login(String locale,HttpSession session) {
if ("zh-CN".equals(locale)) {
session.setAttribute(SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME, new Locale("zh", "CN"));
} else if ("en-US".equals(locale)) {
session.setAttribute(SessionLocaleResolver.LOCALE_SESSION_ATTRIBUTE_NAME, new Locale("en", "US"));
}
String username = messageSource.getMessage("login.username", null, LocaleContextHolder.getLocale());
String password = messageSource.getMessage("login.password", null, LocaleContextHolder.getLocale());
System.out.println("username = " + username);
System.out.println("password = " + password);
return "login";
}
}
SessionLocaleResolver 所实现的功能也可以通过 CookieLocaleResolver 来实现,不同的是前者将解析出来的区域信息保存在 session 中,而后者则保存在 Cookie 中。保存在 session 中,只要 session 没有发生变化,后续就不用再次传递区域语言参数了,保存在 Cookie 中,只要 Cookie 没变,后续也不用再次传递区域语言参数了。
使用 CookieLocaleResolver 的方式很简单,直接在 SpringMVC 中提供 CookieLocaleResolver 的实例即可,如下:
<bean class="org.springframework.web.servlet.i18n.CookieLocaleResolver" id="localeResolver"/>
注意这里也需要使用到 LocaleChangeInterceptor 拦截器,如果不使用该拦截器,则需要自己手动解析并配置语言环境,手动解析并配置的方式如下:
@GetMapping("/login3")
public String login3(String locale, HttpServletRequest req, HttpServletResponse resp) {
CookieLocaleResolver resolver = new CookieLocaleResolver();
if ("zh-CN".equals(locale)) {
resolver.setLocale(req, resp, new Locale("zh", "CN"));
} else if ("en-US".equals(locale)) {
resolver.setLocale(req, resp, new Locale("en", "US"));
}
String username = messageSource.getMessage("login.username", null, LocaleContextHolder.getLocale());
String password = messageSource.getMessage("login.password", null, LocaleContextHolder.getLocale());
System.out.println("username = " + username);
System.out.println("password = " + password);
return "login";
}
配置完成后,启动项目进行测试,这次测试的方式跟 SessionLocaleResolver 的测试方式一致,松哥就不再多说了。
除了前面介绍的这几种 LocaleResolver 之外,还有一个 FixedLocaleResolver,因为比较少见,松哥这里就不做过多介绍了。
# 2.Spring Boot 国际化配置
# 2.1 基本使用
Spring Boot 和 Spring 一脉相承,对于国际化的支持,默认是通过 AcceptHeaderLocaleResolver 解析器来完成的,这个解析器,默认是通过请求头的 Accept-Language 字段来判断当前请求所属的环境的,进而给出合适的响应。
所以在 Spring Boot 中做国际化,这一块我们可以不用配置,直接就开搞。
首先创建一个普通的 Spring Boot 项目,添加 web 依赖即可。项目创建成功后,默认的国际化配置文件放在 resources 目录下,所以我们直接在该目录下创建四个测试文件,如下:
- 我们的 message 文件是直接创建在 resources 目录下的,IDEA 在展示的时候,会多出一个 Resource Bundle,这个大家不用管,千万别手动去创建这个目录。
- messages.properties 这个是默认的配置,其他的则是不同语言环境下的配置,en_US 是英语(美国),zh_CN 是中文简体,zh_TW 是中文繁体(文末附录里边有一个完整的语言简称表格)。
四个文件创建好之后,第一个默认的我们可以先空着,另外三个分别填入以下内容:
messages_zh_CN.properties
user.name=江南一点雨
messages_zh_TW.properties
user.name=江南壹點雨
messages_en_US.properties
user.name=javaboy
配置完成后,我们就可以直接开始使用了。在需要使用值的地方,直接注入 MessageSource 实例即可。
在 Spring 中需要配置的 MessageSource 现在不用配置了,Spring Boot 会通过
org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
自动帮我们配置一个 MessageSource 实例。
创建一个 HelloController ,内容如下:
@RestController
public class HelloController {
@Autowired
MessageSource messageSource;
@GetMapping("/hello")
public String hello() {
return messageSource.getMessage("user.name", null, LocaleContextHolder.getLocale());
}
}
在 HelloController 中我们可以直接注入 MessageSource 实例,然后调用该实例中的 getMessage 方法去获取变量的值,第一个参数是要获取变量的 key,第二个参数是如果 value 中有占位符,可以从这里传递参数进去,第三个参数传递一个 Locale 实例即可,这相当于当前的语言环境。
接下来我们就可以直接去调用这个接口了。
默认情况下,在接口调用时,通过请求头的 Accept-Language 来配置当前的环境,我这里通过 POSTMAN 来进行测试,结果如下:
小伙伴们看到,我在请求头中设置了 Accept-Language 为 zh-CN,所以拿到的就是简体中文;如果我设置了 zh-TW,就会拿到繁体中文:
是不是很 Easy?
# 2.2 自定义切换
有的小伙伴觉得切换参数放在请求头里边好像不太方便,那么也可以自定义解析方式。例如参数可以当成普通参数放在地址栏上,通过如下配置可以实现我们的需求。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
registry.addInterceptor(interceptor);
}
@Bean
LocaleResolver localeResolver() {
SessionLocaleResolver localeResolver = new SessionLocaleResolver();
localeResolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return localeResolver;
}
}
在这段配置中,我们首先提供了一个 SessionLocaleResolver 实例,这个实例会替换掉默认的 AcceptHeaderLocaleResolver,不同于 AcceptHeaderLocaleResolver 通过请求头来判断当前的环境信息,SessionLocaleResolver 将客户端的 Locale 保存到 HttpSession 对象中,并且可以进行修改(这意味着当前环境信息,前端给浏览器发送一次即可记住,只要 session 有效,浏览器就不必再次告诉服务端当前的环境信息)。
另外我们还配置了一个拦截器,这个拦截器会拦截请求中 key 为 lang 的参数(不配置的话是 locale),这个参数则指定了当前的环境信息。
好了,配置完成后,启动项目,访问方式如下:
我们通过在请求中添加 lang 来指定当前环境信息。这个指定只需要一次即可,也就是说,在 session 不变的情况下,下次请求可以不必带上 lang 参数,服务端已经知道当前的环境信息了。
CookieLocaleResolver 也是类似用法,不再赘述。
# 2.3 其他自定义
默认情况下,我们的配置文件放在 resources 目录下,如果大家想自定义,也是可以的,例如定义在 resources/i18n 目录下:
但是这种定义方式系统就不知道去哪里加载配置文件了,此时还需要 application.properties 中进行额外配置(注意这是一个相对路径):
spring.messages.basename=i18n/messages
另外还有一些编码格式的配置等,内容如下:
spring.messages.cache-duration=3600
spring.messages.encoding=UTF-8
spring.messages.fallback-to-system-locale=true
spring.messages.cache-duration 表示 messages 文件的缓存失效时间,如果不配置则缓存一直有效。
spring.messages.fallback-to-system-locale 属性则略显神奇,网上竟然看不到一个明确的答案,后来翻了一会源码才看出端倪。
这个属性的作用在 org.springframework.context.support.AbstractResourceBasedMessageSource#getDefaultLocale
方法中生效:
protected Locale getDefaultLocale() {
if (this.defaultLocale != null) {
return this.defaultLocale;
}
if (this.fallbackToSystemLocale) {
return Locale.getDefault();
}
return null;
}
从这段代码可以看出,在找不到当前系统对应的资源文件时,如果该属性为 true,则会默认查找当前系统对应的资源文件,否则就返回 null,返回 null 之后,最终又会调用到系统默认的 messages.properties 文件。
# 3.LocaleResolver
国际化这块主要涉及到的组件是 LocaleResolver,这是一个开放的接口,官方默认提供了四个实现。当前该使用什么环境,主要是通过 LocaleResolver 来进行解析的。
LocaleResolver
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}
这里两个方法:
- resolveLocale:根据当前请求解析器出 Locale 对象。
- 设置 Locale 对象。
我们来看看 LocaleResolver 的继承关系:
虽然中间有几个抽象类,不过最终负责实现的其实就四个:
- AcceptHeaderLocaleResolver:根据请求头中的 Accept-Language 字段来确定当前的区域语言等。
- SessionLocaleResolver:根据请求参数来确定区域语言等,确定后会保存在 Session 中,只要 Session 不变,Locale 对象就一直有效。
- CookieLocaleResolver:根据请求参数来确定区域语言等,确定后会保存在 Cookie 中,只要 Session 不变,Locale 对象就一直有效。
- FixedLocaleResolver:配置时直接提供一个 Locale 对象,以后不能修改。
接下来我们就对这几个类逐一进行分析。
# 3.1 AcceptHeaderLocaleResolver
AcceptHeaderLocaleResolver 直接实现了 LocaleResolver 接口,我们来看它的 resolveLocale 方法:
@Override
public Locale resolveLocale(HttpServletRequest request) {
Locale defaultLocale = getDefaultLocale();
if (defaultLocale != null && request.getHeader("Accept-Language") == null) {
return defaultLocale;
}
Locale requestLocale = request.getLocale();
List<Locale> supportedLocales = getSupportedLocales();
if (supportedLocales.isEmpty() || supportedLocales.contains(requestLocale)) {
return requestLocale;
}
Locale supportedLocale = findSupportedLocale(request, supportedLocales);
if (supportedLocale != null) {
return supportedLocale;
}
return (defaultLocale != null ? defaultLocale : requestLocale);
}
- 首先去获取默认的 Locale 对象。
- 如果存在默认的 Locale 对象,并且请求头中没有设置
Accept-Language
字段,则直接返回默认的 Locale。 - 从 request 中取出当前的 Locale 对象,然后查询出支持的 supportedLocales,如果 supportedLocales 或者 supportedLocales 中包含 requestLocale,则直接返回 requestLocale。
- 如果前面还是没有匹配成功的,则从 request 中取出 locales 集合,然后再去和支持的 locale 进行比对,选择匹配成功的 locale 返回。
- 如果前面都没能返回,则判断 defaultLocale 是否为空,如果不为空,就返回 defaultLocale,否则返回 defaultLocale。
再来看看它的 setLocale 方法,直接抛出异常,意味着通过请求头处理 Locale 是不允许修改的。
@Override
public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
throw new UnsupportedOperationException(
"Cannot change HTTP accept header - use a different locale resolution strategy");
}
# 3.2 SessionLocaleResolver
SessionLocaleResolver 的实现多了一个抽象类 AbstractLocaleContextResolver,AbstractLocaleContextResolver 中增加了对 TimeZone 的支持,我们先来看下 AbstractLocaleContextResolver:
public abstract class AbstractLocaleContextResolver extends AbstractLocaleResolver implements LocaleContextResolver {
@Nullable
private TimeZone defaultTimeZone;
public void setDefaultTimeZone(@Nullable TimeZone defaultTimeZone) {
this.defaultTimeZone = defaultTimeZone;
}
@Nullable
public TimeZone getDefaultTimeZone() {
return this.defaultTimeZone;
}
@Override
public Locale resolveLocale(HttpServletRequest request) {
Locale locale = resolveLocaleContext(request).getLocale();
return (locale != null ? locale : request.getLocale());
}
@Override
public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
setLocaleContext(request, response, (locale != null ? new SimpleLocaleContext(locale) : null));
}
}
可以看到,多了一个 TimeZone 属性。从请求中解析出 Locale 还是调用了 resolveLocaleContext 方法,该方法在子类中被实现,另外调用 setLocaleContext 方法设置 Locale,该方法的实现也在子类中。
我们来看下它的子类 SessionLocaleResolver:
@Override
public Locale resolveLocale(HttpServletRequest request) {
Locale locale = (Locale) WebUtils.getSessionAttribute(request, this.localeAttributeName);
if (locale == null) {
locale = determineDefaultLocale(request);
}
return locale;
}
直接从 Session 中获取 Locale,默认的属性名是 SessionLocaleResolver.class.getName() + ".LOCALE"
,如果 session 中不存在 Locale 信息,则调用 determineDefaultLocale 方法去加载 Locale,该方法会首先找到 defaultLocale,如果 defaultLocale 不为 null 就直接返回,否则就从 request 中获取 Locale 返回。
再来看 setLocaleContext 方法,就是将解析出来的 Locale 保存起来。
@Override
public void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response,
@Nullable LocaleContext localeContext) {
Locale locale = null;
TimeZone timeZone = null;
if (localeContext != null) {
locale = localeContext.getLocale();
if (localeContext instanceof TimeZoneAwareLocaleContext) {
timeZone = ((TimeZoneAwareLocaleContext) localeContext).getTimeZone();
}
}
WebUtils.setSessionAttribute(request, this.localeAttributeName, locale);
WebUtils.setSessionAttribute(request, this.timeZoneAttributeName, timeZone);
}
保存到 Session 中即可。大家可以看到,这种保存方式其实和我们前面演示的自己保存代码基本一致,殊途同归。
# 3.3 FixedLocaleResolver
FixedLocaleResolver 有三个构造方法,无论调用哪一个,都会配置默认的 Locale:
public FixedLocaleResolver() {
setDefaultLocale(Locale.getDefault());
}
public FixedLocaleResolver(Locale locale) {
setDefaultLocale(locale);
}
public FixedLocaleResolver(Locale locale, TimeZone timeZone) {
setDefaultLocale(locale);
setDefaultTimeZone(timeZone);
}
要么自己传 Locale 进来,要么调用 Locale.getDefault() 方法获取默认的 Locale。
再来看 resolveLocale 方法:
@Override
public Locale resolveLocale(HttpServletRequest request) {
Locale locale = getDefaultLocale();
if (locale == null) {
locale = Locale.getDefault();
}
return locale;
}
这个应该就不用解释了吧。
需要注意的是它的 setLocaleContext 方法,直接抛异常出来,也就意味着 Locale 在后期不能被修改。
@Override
public void setLocaleContext( HttpServletRequest request, @Nullable HttpServletResponse response,
@Nullable LocaleContext localeContext) {
throw new UnsupportedOperationException("Cannot change fixed locale - use a different locale resolution strategy");
}
# 3.4 CookieLocaleResolver
CookieLocaleResolver 和 SessionLocaleResolver 比较类似,只不过存储介质变成了 Cookie,其他都差不多,松哥就不再重复介绍了。
# 4.附录
搜刮了一个语言简称表,分享给各位小伙伴:
语言 | 简称 |
---|---|
简体中文(中国) | zh_CN |
繁体中文(中国台湾) | zh_TW |
繁体中文(中国香港) | zh_HK |
英语(中国香港) | en_HK |
英语(美国) | en_US |
英语(英国) | en_GB |
英语(全球) | en_WW |
英语(加拿大) | en_CA |
英语(澳大利亚) | en_AU |
英语(爱尔兰) | en_IE |
英语(芬兰) | en_FI |
芬兰语(芬兰) | fi_FI |
英语(丹麦) | en_DK |
丹麦语(丹麦) | da_DK |
英语(以色列) | en_IL |
希伯来语(以色列) | he_IL |
英语(南非) | en_ZA |
英语(印度) | en_IN |
英语(挪威) | en_NO |
英语(新加坡) | en_SG |
英语(新西兰) | en_NZ |
英语(印度尼西亚) | en_ID |
英语(菲律宾) | en_PH |
英语(泰国) | en_TH |
英语(马来西亚) | en_MY |
英语(阿拉伯) | en_XA |
韩文(韩国) | ko_KR |
日语(日本) | ja_JP |
荷兰语(荷兰) | nl_NL |
荷兰语(比利时) | nl_BE |
葡萄牙语(葡萄牙) | pt_PT |
葡萄牙语(巴西) | pt_BR |
法语(法国) | fr_FR |
法语(卢森堡) | fr_LU |
法语(瑞士) | fr_CH |
法语(比利时) | fr_BE |
法语(加拿大) | fr_CA |
西班牙语(拉丁美洲) | es_LA |
西班牙语(西班牙) | es_ES |
西班牙语(阿根廷) | es_AR |
西班牙语(美国) | es_US |
西班牙语(墨西哥) | es_MX |
西班牙语(哥伦比亚) | es_CO |
西班牙语(波多黎各) | es_PR |
德语(德国) | de_DE |
德语(奥地利) | de_AT |
德语(瑞士) | de_CH |
俄语(俄罗斯) | ru_RU |
意大利语(意大利) | it_IT |
希腊语(希腊) | el_GR |
挪威语(挪威) | no_NO |
匈牙利语(匈牙利) | hu_HU |
土耳其语(土耳其) | tr_TR |
捷克语(捷克共和国) | cs_CZ |
斯洛文尼亚语 | sl_SL |
波兰语(波兰) | pl_PL |
瑞典语(瑞典) | sv_SE |
西班牙语(智利) | es_CL |
# 5.小结
好啦,今天主要和小伙伴们聊了下 SpringMVC 中的国际化问题,以及 LocaleResolver 相关的源码,相信大家对 SpringMVC 的理解应该又更近一步了吧。