SpringBoot自定义国际化案例及源码探究
对于一个这样的首页,我们试图通过点击下方的“中文”、“English”实现两种语言页面的跳转
index.html如下
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="Mark Otto, Jacob Thornton, and Bootstrap contributors">
<meta name="generator" content="Jekyll v4.1.1">
<title>Signin Template · Bootstrap</title>
<link rel="canonical" href="https://getbootstrap.com/docs/4.5/examples/sign-in/">
<!-- Bootstrap core CSS -->
<link th:href="@{/assets/dist/css/bootstrap.min.css}" rel="stylesheet">
<!--Custom styles for this template-->
<link th:href="@{/assets/dist/css/signin.css}" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<!-- Custom styles for this template -->
<link href="signin.css" rel="stylesheet">
</head>
<body class="text-center">
<form class="form-signin">
<img class="mb-4" th:src="@{/assets/brand/bootstrap-solid.svg}" alt="" width="72" height="72">
<h1 class="h3 mb-3 font-weight-normal" th:text="#{login.tip}">Please sign in</h1>
<input type="email" id="inputEmail" class="form-control" th:placeholder="#{login.username}" required autofocus>
<input type="password" id="inputPassword" class="form-control" th:placeholder="#{login.password}" required>
<div class="checkbox mb-3">
<label>
<input type="checkbox" value="remember-me" th:text="#{login.remember}">
</label>
</div>
<button class="btn btn-lg btn-primary btn-block" type="submit" th:text="#{login.btn}">Sign in</button>
<p class="mt-5 mb-3 text-muted">© 2017-2020</p>
<a class="=btn btn-sm" >中文</a>
<a class="=btn btn-sm" >English</a>
</form>
</body>
</html>
1.编写国际化配置文件
首先我们需要定义登录页面各语言对应的内容,目录如下
login.properties
# 登录按钮
login.btn=登录
# 账户输入框显示的信息
login.username=用户名/手机号/邮箱
# 密码输入框显示的信息
login.password=请输入18位以内的密码
# 勾选框后显示的信息
login.remember=记住我
# 登录页面提示信息
login.tip=请登录
login_en_US.properties
# 登录按钮
login.btn=Sign in
# 账户输入框显示的信息
login.username=Username/Phone Number/Email Address
# 密码输入框显示的信息
login.password=Please enter a password within 18 digits
# 勾选框后显示的信息
login.remember=Remember me
# 登录页面提示信息
login.tip=Please sign in
2.自定义springboot配置文件的路径
编写完国际化配置文件之后,怎么能够让springboot获得配置文件中的内容呢?这里我们需要引入一个类MessageSourceAutoConfiguration,它是消息源的自动装配类,部分源码如下:
/**
* {@link EnableAutoConfiguration Auto-configuration} for {@link MessageSource}.
* 用于给MessageSource对象自动装配
* @author Dave Syer
* @author Phillip Webb
* @author Eddú Meléndez
* @since 1.5.0
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ResourceBundleCondition.class)
@EnableConfigurationProperties
public class MessageSourceAutoConfiguration {
private static final Resource[] NO_RESOURCES = {};
@Bean
@ConfigurationProperties(prefix = "spring.messages") //指定了配置的前缀
//将前缀为spring.messages的配置文件与MessageSourceProperties对象绑定,对象中的属性都可以通过该配置中相同名称的键值对进行赋值
public MessageSourceProperties messageSourceProperties() {
return new MessageSourceProperties();
}
@Bean
public MessageSource messageSource(MessageSourceProperties properties) {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
if (StringUtils.hasText(properties.getBasename())) {
messageSource.setBasenames(StringUtils
.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename())));
}
if (properties.getEncoding() != null) {
messageSource.setDefaultEncoding(properties.getEncoding().name());
}
messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
Duration cacheDuration = properties.getCacheDuration();
if (cacheDuration != null) {
messageSource.setCacheMillis(cacheDuration.toMillis());
}
messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
return messageSource;
}
......
}
通过查看源码可以得知,SpringBoot通过引入前缀为spring.messages
的配置文件为MessageSourceProperties
进行配置,再根据MessageSourceProperties
来获得MessageSource
对象。进一步查看MessageSourceProperties
的源码可以看到,该类含有一个属性basename
,即为消息源配置的全限定类路径,该路径下包含了提供给消息源解析所需要的配置内容。
/**
* Configuration properties for Message Source.
*
* @author Stephane Nicoll
* @author Kedar Joshi
* @since 2.0.0
*/
public class MessageSourceProperties {
/**
* Comma-separated list of basenames (essentially a fully-qualified classpath
* location), each following the ResourceBundle convention with relaxed support for
* slash based locations. If it doesn't contain a package qualifier (such as
* "org.mypackage"), it will be resolved from the classpath root.
*/
private String basename = "messages";
/**
* Message bundles encoding.
*/
private Charset encoding = StandardCharsets.UTF_8;
/**
* Loaded resource bundle files cache duration. When not set, bundles are cached
* forever. If a duration suffix is not specified, seconds will be used.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration cacheDuration;
......
}
而通过查看MessageSource的源码可知,这是一个用于处理消息的策略接口,支持参数化和国际化消息。
/**
* Strategy interface for resolving messages, with support for the parameterization
* and internationalization of such messages.
*
* <p>Spring provides two out-of-the-box implementations for production:
* <ul>
* <li>{@link org.springframework.context.support.ResourceBundleMessageSource}: built
* on top of the standard {@link java.util.ResourceBundle}, sharing its limitations.
* <li>{@link org.springframework.context.support.ReloadableResourceBundleMessageSource}:
* highly configurable, in particular with respect to reloading message definitions.
* </ul>
*
* @author Rod Johnson
* @author Juergen Hoeller
* @see org.springframework.context.support.ResourceBundleMessageSource
* @see org.springframework.context.support.ReloadableResourceBundleMessageSource
*/
public interface MessageSource {
/**
* Try to resolve the message. Return default message if no message was found.
* @param code the message code to look up, e.g. 'calculator.noRateSet'.
* MessageSource users are encouraged to base message names on qualified class
* or package names, avoiding potential conflicts and ensuring maximum clarity.
* @param args an array of arguments that will be filled in for params within
* the message (params look like "{0}", "{1,date}", "{2,time}" within a message),
* or {@code null} if none
* @param defaultMessage a default message to return if the lookup fails
* @param locale the locale in which to do the lookup
* @return the resolved message if the lookup was successful, otherwise
* the default message passed as a parameter (which may be {@code null})
* @see #getMessage(MessageSourceResolvable, Locale)
* @see java.text.MessageFormat
*/
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
/**
* Try to resolve the message. Treat as an error if the message can't be found.
* @param code the message code to look up, e.g. 'calculator.noRateSet'.
* MessageSource users are encouraged to base message names on qualified class
* or package names, avoiding potential conflicts and ensuring maximum clarity.
* @param args an array of arguments that will be filled in for params within
* the message (params look like "{0}", "{1,date}", "{2,time}" within a message),
* or {@code null} if none
* @param locale the locale in which to do the lookup
* @return the resolved message (never {@code null})
* @throws NoSuchMessageException if no corresponding message was found
* @see #getMessage(MessageSourceResolvable, Locale)
* @see java.text.MessageFormat
*/
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
/**
* Try to resolve the message using all the attributes contained within the
* {@code MessageSourceResolvable} argument that was passed in.
* <p>NOTE: We must throw a {@code NoSuchMessageException} on this method
* since at the time of calling this method we aren't able to determine if the
* {@code defaultMessage} property of the resolvable is {@code null} or not.
* @param resolvable the value object storing attributes required to resolve a message
* (may include a default message)
* @param locale the locale in which to do the lookup
* @return the resolved message (never {@code null} since even a
* {@code MessageSourceResolvable}-provided default message needs to be non-null)
* @throws NoSuchMessageException if no corresponding message was found
* (and no default message was provided by the {@code MessageSourceResolvable})
* @see MessageSourceResolvable#getCodes()
* @see MessageSourceResolvable#getArguments()
* @see MessageSourceResolvable#getDefaultMessage()
* @see java.text.MessageFormat
*/
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
所以,为了能够使我们定义的国际化配置文件能够被解析,我们需要在application.properties中将spring.messages.basename修改为国际化配置文件的全限定路径
# 我们的配置文件的真实位置
spring.messages.basename=i18n.login
这样一来,我们的配置文件就可以被MessageSource获得并进行处理了。
3.自定义国际化处理
现在,我们的国际化配置文件也能被SpringBoot获取到了,那么该如何对这些配置内容进行处理从而实现客户端页面的国际化呢?我们知道SpringBoot帮我们自动注册了SpringMVC并实现了相应的功能,而我们也可以通过自定义一个被@Configure注解且继承WebMvcConfigurer接口的类,根据自己的需要重写WebMvcConfigurer接口中的某些方法实现MVC扩展。SpringBoot通过WebMvcAutoConfiguration类实现MVC的自动装配,而该类中含有一个LocaleResolver方法,用于获取处理本地化的对象。
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "spring.mvc", name = "locale")
public LocaleResolver localeResolver() {
if (this.mvcProperties.getLocaleResolver() == WebMvcProperties.LocaleResolver.FIXED) {
return new FixedLocaleResolver(this.mvcProperties.getLocale());
}
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(this.mvcProperties.getLocale());
return localeResolver;
}
从源码可以得知,该方法会返回一个LocalResolver
对象注入到SpringBoot容器中。该方法首先对MvcProperties对象的localeResolver
进行判断,如果是固定使用默认配置的locale,那么直接返回一个当前的默认localeResolver
。否则创建一个AcceptHeaderLocaleResolver
对象,将当前mvcProperties.getLocale()
的值设为其默认的locale
,并返回该对象,根据请求头所携带的语言信息自动进行国际化。@ConditionalOnMissingBean注解在一个@Bean方法上时,如果此时BeanFactory中已经包含@Bean方法返回值类型的bean,那么该方法不会被执行,如果此时BeanFactory中不包含@Bean方法返回值类型的bean,那么该方法会被执行。
而查看AcceptHeaderLocaleResolver
的源码可知,该类实现了LocaleResovler
接口,重写了其定义的两个方法。因此我们可以自己定义一个类实现LocaleResovler
接口并根据自己的需求重写其定义的方法即可实现自定义国际化处理。
HTML中定制的国际化信息,为跳转连接加上参数,地区解析器可以从请求中获取。
<!-- th:href="@{/index.html(locale='zh_CN')}"指定跳转页面以及携带的地区化参数,参数为括号内的键值对 -->
<a class="=btn btn-sm" th:href="@{/index.html(locale='zh_CN')}">中文</a>
<a class="=btn btn-sm" th:href="@{/index.html(locale='en_US')}">English</a>
自定义国际化/地区解析器 MyLocaleResolver
//自定义地区解析器
@Component("localeResolver")
public class MyLocaleResolver implements LocaleResolver {
//解析请求
@Override
public Locale resolveLocale(HttpServletRequest request) {
// 获取请求中携带的地区化参数:locale
String requestLocale = request.getParameter("locale");
// 获取该实例在JVM中的默认Locale值
Locale locale = Locale.getDefault();
System.out.println("Debug==>"+locale);
//如果请求的链接携带了地区化参数
if (StringUtils.hasLength(requestLocale)){
//将请求携带的地区化参数根据"_"分离,"zh_CN"=>"['zh','CN']"
String[] split = requestLocale.split("_");
//通过构造函数Locale(String language, String country)创建请求参数对应的Locale对象
locale = new Locale(split[0], split[1]);
}
//返回Locale
return locale;
}
@Override
public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
}
}
需要注意的是,这里@Component(" “)中的字符串必须为"localeResolver"
,这是因为在WebMvcAutoConfiguration进行SpringMVC自动装配时会向BeanFactory中注入其含有的被@Bean注解的public LocaleResolver localeResolver() 方法所返回的LocaleResolver对象。对象的bean id为"localeResolver"
,而SpringBoot只会去调用id为"localeResolver"
的LocaleResolver类型的bean。因此如果我们自定义的地区解析器的@Component(” ")名称不为"localeResolver"
,那么SpringBoot不会调用我们自定义的bean对象进行地区解析,而仍然会去调用其自动装配的LocaleResolver对象。只有让我们自定义的地区解析器将其自动装配的解析器进行覆盖,SpringBoot才会调用我们定义的MyLocaleResolver。
4.验证结果
启动springboot,打开浏览器进行验证。
点击 中文,页面跳转结果:
可以看到URL后缀已经变成了/index.html?locale=zh_CN,携带参数locale=zh_CN
点击 English,页面跳转结果:
可以看到,成功实现英文页面转换。URL也发生了变化。携带参数为locale=en_US.