当我们的项目涉及到多语言支持时,身为后端开发的我们,接口数据国际化
便是我们必须攻克的问题。
而SpringBoot
提供了强大的国际化(i18n)支持,允许开发者为不同的地区和语言提供定制的文本资源。
那么就让我们一起 “撕开接口数据国际化的面纱”,深入探讨如何在 SpringBoot
应用程序中实现国际化,以满足全球用户的多语言需求。
国际化,也叫i18n,为什么叫i18n呢?
这是因为国际化的英文单词是internationalization ,i和n之间包含了18个单词。 就如我们的k8s一样,k和s之间包含了8个单词(很随性🥰)
一个网站或应用
,它原本只面向国内用户提供服务,页面显示和各种操作提示都只有国语。后来随着它的业务扩大,功能越做越多,用户也越来越多,开始走向国际平台了,那么它的用户类型就不只是国内了,可能有法国、美国、日本等等国家的用户。
如果这时网站或应用
的显示和各种操作还是中文(或只有一国语言),那么其他国家用户可能完全看不懂网站或应用
或者操作困难。
那么它对客户的友好度是不是就会大大降低?是不是就会无法留住这类客户?
那么对于这种场景现在国际化
就非常重要。
对于我们的项目而言,国际化
可以分为前端和后端两个部分:
前端国际化:
前端国际化主要关注页面的显示和用户界面的本地化。它涉及将应用程序的界面元素,如文本、标签、按钮等,根据用户的语言和地区进行翻译和适配。前端国际化通常使用资源文件、语言包或翻译服务来存储和管理不同语言的文本。前端开发人员可以通过使用国际化框架或库,如React Intl、Vue I18n或Angular i18n等,来实现前端国际化功能。
后端国际化:
后端国际化主要关注处理与业务逻辑和数据相关的国际化问题。这包括但不限于日期和时间格式、货币符号、数字格式、排序规则、接口提示信息等。后端国际化的目标是确保应用程序能够适应不同的语言和地区,并提供正确的本地化数据。后端国际化可以通过使用国际化库或框架,如SpringBoot I18n,来实现后端国际化功能。
总之,前端国际化主要关注页面显示和用户界面的本地化,而后端国际化则处理与业务逻辑和数据相关的国际化问题。两者通常需要协同工作,以实现完整的国际化功能。
在开始实现后端接口国际化之前,我们先来了解一些小知识。
需要支持国际化,得先知道选择的是哪种地区的哪种语言,java中使用java.util.Locale
来表示地区语言,这个对象内部包含了国家和语言的信息。
Locale中最常用的构造方法:
public Locale(String language, String country) {
this(language, country, "");
}
构造方法有两个参数:language:语言、country:国家
这两个参数的值不是乱写的,国际上有统一的标准,如:zh-CN表示中国大陆地区的中文,zh-TW表示中国台湾地区的中文,en-US表示美国地区的英文,en-GB表示英国地区的英文等等。通过语言和国家构造Locale对象,比如
Locale locale = new Locale("zh", "CN");
,表示中国大陆地区的中文。
Locale类中已经创建好了很多常用的Locale对象,直接可以拿过来用,随便列几个看一下:
static public final Locale SIMPLIFIED_CHINESE = createConstant("zh", "CN");
static public final Locale TRADITIONAL_CHINESE = createConstant("zh", "TW");
static public final Locale FRANCE = createConstant("fr", "FR");
static public final Locale GERMANY = createConstant("de", "DE");
这是 Spring 国际化的核心接口,其定义如下:
public interface MessageSource {
/**
* 获取国际化信息
*/
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
/**
* 与上面的方法类似,只不过在找不到资源中对应的属性名时,直接抛出NoSuchMessageException异常
*/
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
/**
* @param MessageSourceResolvable 将属性名、参数数组以及默认信息封装起来,它的功能和第一个方法相同
*/
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
MessageSource
接口提供了三个获取国际化消息的方法,其主要是根据 Locale 信息获取对应的国际化消息的集合,然后根据 code 获取对应的消息,并且通过提供的参数 args 还可以对获取后的消息进行格式化。
具体参数含义如下:
参数名 | 含义 |
---|---|
code | 表示国际化资源中的属性名 |
args | 为消息中的参数填充的值 |
defaultMessage | 默认的消息,如果没有找到将返回默认消息 |
resolvable | 消息参数,封装了 code、args、defaultMessage |
locale | 表示本地化对象 |
常见3个实现类:
类名 | 含义 |
---|---|
ResourceBundleMessageSource | 这个是基于Java的ResourceBundle基础类实现,允许仅通过资源名加载国际化文件 |
ReloadableResourceBundleMessageSource | 这个功能和第一个类的功能类似,多了定时刷新功能,允许在不重启系统的情况下,更新国际化文件的信息 |
StaticMessageSource | 它允许通过编程的方式提供国际化信息。 |
重点:我们在项目中会创建 MessageSource接口,但不管使用哪个实现类或者我们自定义的类,都要将Bean名称设置为messageSource
加载ApplicationContext时,自动搜索上下文中定义的MessagesSource Bean(名称必须为messageSource)。 如果无法找到消息的任何源,则实例化一个空的messageSource。
这个接口是用来设置当前会话默认的国际化语言的,其定义如下:
public interface LocaleResolver {
/**
* 根据当前请求解析当前请求的本地化信息
*/
Locale resolveLocale(HttpServletRequest request);
/**
* 设置当前请求、响应的本地化信息
*/
void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}
resolveLocale
方法用于从当前request中解析对应出对应的Locale
对象,场景如:
比如一个请求发送到程序中(服务器),我们怎么知道它是哪个国家的呢?难道要通过请求拿到ip,然后根据ip去解析去对应的地区?不不,那样太麻烦了。
Spring提供LocaleResolver接口的做用是解析客戶端使用的地区,我们可以在请求头部或者请求url传递对应的语言,LocaleResolver便可根据规则创建对应的Locale对象
常见4个实现类:
类名 | 含义 |
---|---|
AcceptHeaderLocalResovler | 通过请求头里面的 |
CookieLocaleResovler | 根据用户在Cookie中设置的某参数来进行确定具体的本地化Locale实例 |
SessionLocaleResovler | 根据用户在HttpSession中设置某参数来进行确定具体的本地化Locale实例 |
FixedLocalResovler | 使用jdk自带的默认的Locale实例 |
项目中,在resources
目录下创建名为i18n
的文件目录,然后我们在i18n
目录创建国际化文件
格式为:名称_语言_地区.properties
我们先来创建两种语言,如:
message.properties
name=您的姓名
text=默认文本
这个文件名称没有指定Local信息,当系统找不到的时候会使用这个默认的
message_cn_ZH.properties:中文【中国】
name=姓名
text=文本
message_en_GB.properties:英文【英国】
name=Full name
text=text
我们通过MessageSource
接口的getMessage
方法传入对应的key
(如naem、text),便可以从国际化文件中取值。同时我们还可以指定Locale对象,便能找到对应的国际化文件然后取值。
在解决方案中,会采用同时从数据库和properties文件中读取国际化信息,达到国际化信息高灵活性。
在resources/i181n/messages
目录,分别创建三个文件(key的名称推荐大写加下划线的方式)
message.properties:
OK_NAME=您的姓名
OK_TEXT=默认文本
这个文件名称没有指定Local信息,当系统找不到的时候会使用这个默认的
message_cn_ZH.properties:中文【中国】
OK_NAME=姓名
OK_TEXT=文本
message_en_GB.properties:英文【英国】
OK_NAME=Full name
OK_TEXT=text
指定i18n国际化文件路径:
spring:
messages:
basename: i18n/messages
encoding: UTF-8
表内容(表就简单创建一个了,这里可以自行完善):
CREATE TABLE `ok_i18message` (
`code` varchar(255) NOT NULL COMMENT '属性名',
`locale` varchar(100) DEFAULT NULL COMMENT '国家代码',
`message` varchar(255) DEFAULT NULL COMMENT '对应内容',
PRIMARY KEY (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
表数据:
INSERT INTO `ok_i18message` (`code`, `locale`, `message`) VALUES ('OK_PASSWORD', 'cn_ZH', '密码');
INSERT INTO `ok_i18message` (`code`, `locale`, `message`) VALUES ('OK_PASSWORD', 'en_GB', 'password');
INSERT INTO `ok_i18message` (`code`, `locale`, `message`) VALUES ('OK_AGE', 'cn_ZH', '年龄');
INSERT INTO `ok_i18message` (`code`, `locale`, `message`) VALUES ('OK_AGE', 'en_GB', 'age');
实体类:
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 国际化表
* @ClassName I18message
* @Author Blue
* @Date 2023/11/5
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("ok_i18message")
@ApiModel(description = "国际化表")
public class I18message {
@ApiModelProperty("属性名")
private String code;
@ApiModelProperty("国家代码")
private String locale;
@ApiModelProperty("对应内容")
private String message;
}
mapper类:
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* 国际化表
* @ClassName AssetsLogMapper
* @Author Blue
* @Date 2023/11/5
*/
@Mapper
public interface I18messageMapper extends MPJBaseMapper<I18message> {
}
案例:使用
mybatis-plus
来完成对表的crud
我们使用自定义MessageSource类来整合国际化消息,在
3.2 MessageSource接口
中有说StaticMessageSource
实现类可以通过编程的方式提供国际化信息,那为何我们不直接使用它?而是自定义一个类?解释我放在最后了。
MessageSource代码:
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.context.support.AbstractMessageSource;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import javax.annotation.Resource;
import java.text.MessageFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;
// @Component("messageSource"): 也可以在此处指明bean的名称为 messageSource
public class MyMessageSource extends AbstractMessageSource implements InitializingBean {
// 注入查询接口对象
@Resource
private I18messageMapper i18messageMapper;
/**
* 这个是用来缓存数据库中获取到的配置的
* 数据库配置更改的时候可以调用reload方法重新加载
*/
private static final Map<String, Map<String, String>> LOCAL_CACHE = new ConcurrentHashMap<>();
/**
* 程序启动之后,会自动加载
*/
@Override
public void afterPropertiesSet() {
this.reload();
}
/**
* 重新加载消息到该类的Map缓存中
*/
public void reload() {
// 清除该类的缓存
LOCAL_CACHE.clear();
// 加载所有的国际化资源
LOCAL_CACHE.putAll(this.loadAllMessageResources());
}
/**
* 重点:加载所有的国际化消息资源
* 同时从数据库和properties文件中读取国际化信息
*/
private Map<String, Map<String, String>> loadAllMessageResources() {
// 从数据库中查询所有的国际化资源
List<I18message> allLocaleMessage = i18messageMapper.selectList(null);
if (ObjectUtils.isEmpty(allLocaleMessage)) {
allLocaleMessage = new ArrayList<>();
}
// 将查询到的国际化资源转换为 Map<地区码, Map<code, 信息>> 的数据格式
Map<String, Map<String, String>> localeMsgMap = allLocaleMessage
// stream流
.stream()
// 分组
.collect(Collectors.groupingBy(
// 根据国家地区分组
I18message::getLocale,
// 收集为Map,key为code,value为信息
Collectors.toMap(
I18message::getCode
, I18message::getMessage
)
));
// 获取国家地区List
List<Locale> localeList = localeMsgMap.keySet().stream().map(Locale::new).collect(Collectors.toList());
for (Locale locale : localeList) {
// 按照国家地区来读取本地的国际化资源文件,我们的国际化资源文件放在i18n文件夹之下
ResourceBundle resourceBundle = ResourceBundle.getBundle("i18n/messages", locale);
// 获取国际化资源文件中的key和value
Set<String> keySet = resourceBundle.keySet();
// 将 code=信息 格式的数据收集为 Map<code,信息> 的格式
Map<String, String> msgFromFileMap = keySet.stream()
.collect(
Collectors.toMap(
Function.identity(),
resourceBundle::getString
)
);
// 将本地的国际化信息和数据库中的国际化信息合并
Map<String, String> localeFileMsgMap = localeMsgMap.get(locale.getLanguage());
localeFileMsgMap.putAll(msgFromFileMap);
localeMsgMap.put(locale.getLanguage(), localeFileMsgMap);
}
return localeMsgMap;
}
/**
* 缓存Map中加载国际化资源
*/
private String getSourceFromCacheMap(String code, Locale locale) {
String language = ObjectUtils.isEmpty(locale)
? LocaleContextHolder.getLocale().getLanguage() : locale.getLanguage();
// 获取缓存中对应语言的所有数据项
Map<String, String> propMap = LOCAL_CACHE.get(language);
if (!ObjectUtils.isEmpty(propMap) && propMap.containsKey(code)) {
// 如果对应语言中能匹配到数据项,那么直接返回
return propMap.get(code);
}
// 如果找不到国际化消息,就直接返回code
return code;
}
/**
* 实现方法,供getMessage方法内部使用
*/
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
String msg = this.getSourceFromCacheMap(code, locale);
return new MessageFormat(msg, locale);
}
/**
* 实现方法,供getMessage方法内部使用
*/
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
return this.getSourceFromCacheMap(code, locale);
}
}
我们自定义了一个MyMessageSource
类继承了AbstractMessageSource
抽象类 和 实现了InitializingBean
接口,从而实现了从数据库获取到的国际化消息和本地properties文件中的国际化消息整合的功能。
名称 | 含义 |
---|---|
AbstractMessageSource | 抽象类继承了 HierarchicalMessageSource 接口,而HierarchicalMessageSource 接口继承了MessageSource。它是一个支持“配置文件”方式的抽象类,内部提供一个与区域设置无关的公共消息配置文件,消息代码为关键字。 |
InitializingBean | 为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候都会执行该方法 |
可能大家有注意到,我们在继承AbstractMessageSource
抽象类后重写了两个方法:resolveCode
、resolveCodeWithoutArguments
,这两个方法是供getMessage
方法内部使用的。
我们进入到AbstractMessageSource
源码,查看getMessage
方法,在调用它时传入code
和locale
,它会调用resolveCode
方法或者调用resolveCodeWithoutArguments
方法去获取消息,最终返回对应的国际化消息。
而这两个方法已经被我们重写,它的数据都从我们自定义的MyMessageSource
类的LOCAL_CACHE(map对象)
中获取,
LocaleResolver:用来设置当前会话默认的国际化语言
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Locale;
/**
* 区域设置解析程序
* @ClassName MyLocaleResolver
* @Author Blue
* @Date 2023/11/6
*/
@Configuration
public class MyLocaleResolver implements LocaleResolver {
/**
* 根据当前请求解析当前请求的本地化信息
*/
@Override
public Locale resolveLocale(HttpServletRequest httpServletRequest) {
// 请求url参数
String l = httpServletRequest.getParameter("lang");
// 请求头参数
String header = httpServletRequest.getHeader("lang");
// 国际化,每一个locale对象都代表一个特定的政治文化,地区和创建方法
Locale locale = null;
// 判断请求url参数中是否有带lang参数(优先)
if (!StrUtil.isEmpty(l)) {
// 根据下划线分割
String[] split = l.split("_");
// 创建国际化对象
locale = new Locale(split[0], split[1]);
// 判断请求头参数中是否有带lang参数
} else if(!StrUtil.isEmpty(header)) {
// 替换为空
header = header.replaceAll("\"","");
// 根据下划线分割
String[] split = header.split("_");
// 创建国际化对象
locale = new Locale(split[0], split[1]);
}
return locale;
}
/**
* 设置当前请求、响应的本地化信息
*/
@Override
public void setLocale(HttpServletRequest httpServletRequest, @Nullable HttpServletResponse
httpServletResponse, @Nullable Locale locale) {
}
}
这里的重点便在于:将我们自定义的MyMessageSource
类,交给让spring容器进行创建和管理,并且名称设置为messageSource
。
/**
* Web Mvc 配置程序
* 解决跨域问题、添加拦截器、配置视图解析器、静态资源处理、国际化等等
* @ClassName ApplicationConfig
* @Author Blue
* @Date 2023/11/5
*/
@Configuration
public class ApplicationConfig implements WebMvcConfigurer {
/**
* 区域设置解析器
*/
@Bean
public LocaleResolver localeResolver() {
return new MyLocaleResovel();
}
/**
* Spring在启动的时候,加载上下文的时候,会查询查询是否存在容器名称为messageSource的bean
* 如果没有就会创建一个名为messageSource的bean,然后放在上下文中
* 我们手动创建一个名为messageSource的bean,替代Spring为我们自动创建
*/
@Bean
public MessageSource messageSource() {
return new MyMessageSource();
}
}
对MessageSource
进行封装,方便我们使用
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
import java.util.Locale;
/**
* 区域设置消息源服务
* @ClassName LocaleMessageSourceService
* @Author Blue
* @Date 2022/11/14
*/
@Component
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class LocaleMessageSourceService {
private final MessageSource messageSource;
/**
* @param code 对应messages配置的key
* @return String
*/
public String getMessage(String code){
return getMessage(code,null);
}
/**
* @param code 对应messages配置的key
* @param args 数组参数
* @return String
*/
public String getMessage(String code,Object[] args){
return getMessage(code, args,"");
}
/**
* @param code 对应messages配置的key
* @param args 数组参数
* @param defaultMessage 没有设置key的时候的默认值
* @return String
*/
public String getMessage(String code,Object[] args,String defaultMessage){
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, defaultMessage, locale);
}
}
使用场景和方式可以有很多,如配合参数校验validator、全局异常、接口信息返回等等,真实项目可能会更复杂,所以我这里简易使用,让大家可以自行发挥。
/**
* 全局异常处理程序
* @ClassName GlobalExceptionHandler
* @Author Blue
* @Date 2023/11/6
*/
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class GlobalExceptionHandler {
private final LocaleMessageSourceService localeMessageSourceService;
/**
* 自定义验证异常处理
* 国际化安全提示-validation-jsr303
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Object handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error(e.getMessage(), e);
// 获取安全参数判断对应的key值
String message = e.getBindingResult().getFieldError().getDefaultMessage();
return Result.fail(message);
}
/**
* 自定义验证异常处理
* Assert - 异常处理
*/
@ExceptionHandler(IllegalArgumentException.class)
public Result handleIllegalArgumentException(IllegalArgumentException e) {
log.error(e.getMessage(), e);
// getMessage方法的参数就填写对应 国际化文件 或 国际化表 中的code就行
return Result.fail(localeMessageSourceService.getMessage("OK_NAME"));
}
/**
* 自定义验证异常处理
* 邮箱格式错误
*/
@ExceptionHandler({MailSendException.class,SendFailedException.class,SMTPAddressFailedException.class})
public Result handleMailSendException(Exception e) {
log.error(e.getMessage(), e);
// getMessage方法的参数就填写对应 国际化文件 或 国际化表 中的code就行
return Result.fail(localeMessageSourceService.getMessage("OK_AGE"));
}
/**
* 自定义验证异常处理
* 有可能是WebSocketServer发送消息错误
*/
@ExceptionHandler(IOException.class)
public Result handleIOException(IOException e) {
log.error(e.getMessage(), e);
// getMessage方法的参数就填写对应 国际化文件 或 国际化表 中的code就行
return Result.fail(localeMessageSourceService.getMessage("OK_WS_ERROR"));
}
/**
* 所有异常处理
*/
@ExceptionHandler
public Result handlerException(Exception e) {
log.error(e.getMessage(), e);
return Result.fail(localeMessageSourceService.getMessage("OK_ERROR"));
}
}
@Slf4j
@CrossOrigin
@RefreshScope
@RestController
@Api(tags = "测试接口返回国际化信息")
@RequestMapping("/test")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final LocaleMessageSourceService localeMessageSourceService;
private final MyMessageSource myMessageSource;
@ApiOperation("参数安全")
@PostMapping("/is")
public Result is(String password,Integer age) {
if (StrUtil.isBlank(password)) {
return Result.fail(localeMessageSourceService.getMessage("OK_PASSWORD"));
}
if (ObjectUtil.isEmpty(age)) {
return Result.fail(localeMessageSourceService.getMessage("OK_AGE"));
}
// 业务...
return Result.succeed("succeed");
}
@ApiOperation("重新加载国际化消息")
@PostMapping("/reloadMessage")
public void reloadMessage() {
myMessageSource.reload();
}
}
当然参数安全可以不用我这么去判断,太长了,我只是为了更直观的表达场景,大家可以去结合参数校验validator
,一个注解便可以完成参数安全国际化提示。
这里我就只编写两种使用场景,大家可以动手试试,结合自己业务和思想,让代码更加强大好用!
在4.3 自定义MessageSource类
中我们为什么要自定义MessageSource类呢?StaticMessageSource
类明明可以它允许通过编程的方式提供国际化信息。
原因如下:
StaticMessageSource
类也能实现同样的功能,但是不推荐在生产环境中使用,并且不支持国际化消息删除StaticMessageSource
适合国际化消息测试,支持硬编码的方法添加国际化消息我们查看它的源码,可以看到:
源码的注释也提到了:Intended for testing rather than for use in production systems.
翻译为中文就是用于测试而不是用于生产系统
并且所有的国际化消息最终都会缓存到messageMap中,由于StaticMessageSource并没有提供清除map数据的方法,因此只有当程序重启,数据库删除的国际化消息才能被反映到messageMap中
好了,至此,我们的从零玩转后端接口数据交互国际化
完结,希望大家能有所收获!
如果您对本文有任何疑问或需要帮助,请在评论区留言,我会尽力解答。如果本文对您有帮助,请给个赞以示支持,非常感谢!💗
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。