松哥之前写过 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);

}

这里两个方法:

  1. resolveLocale:根据当前请求解析器出 Locale 对象。
  2. 设置 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);
}
  1. 首先去获取默认的 Locale 对象。
  2. 如果存在默认的 Locale 对象,并且请求头中没有设置 Accept-Language 字段,则直接返回默认的 Locale。
  3. 从 request 中取出当前的 Locale 对象,然后查询出支持的 supportedLocales,如果 supportedLocales 或者 supportedLocales 中包含 requestLocale,则直接返回 requestLocale。
  4. 如果前面还是没有匹配成功的,则从 request 中取出 locales 集合,然后再去和支持的 locale 进行比对,选择匹配成功的 locale 返回。
  5. 如果前面都没能返回,则判断 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 的理解应该又更近一步了吧。