文章目录
大家好,我是Bivin,最近项目中遇到了动态数据多语言切换的需求,平台的动态数据需要支持中文简体、中文繁体、英语三种语言的自由切换,也就是所谓的国际化,我也是头一次接到这样的需求,去网上搜了搜发现对应的方案寥寥无几,算了,自己干吧,就这样总结出了三种我觉得可行的方案,凑合看吧!
说在前面
本文只讲解动态数据的国际化,且只做中文简体、中文繁体、英文三种语言的动态数据切换,加其他语言也是一样的思路,所有方案均采用文章类型表(article_type)作为例子。
方案一:单库单表多字段
此方案的思路是在一个表中使用不同的字段存储不同语言的数据,比如我们的文章类型名称需要支持的语言是中文简体(CN)、中文繁体(TC)和英文(EN),那么就设计三个不同语言的name字段,来分别存储这三种语言下的类型名称,代码层面通过统一响应处理器结合JackSon的树模型递归遍历移除和替换字段,返回用户所指定语言对应的数据。
关键词:单表、多字段、统一响应处理、JackSon树模型
数据表设计
列名 | 类型 | 备注 |
---|---|---|
aid | int(11) | 自增aid |
cn_name | varchar(20) | 名称(中文简体) |
tc_name | varchar(20) | 名称(中文繁体) |
en_name | varchar(20) | 名称(英文) |
create_time | datatime | 创建时间 |
update_time | datatime | 更新时间 |
如果还有其他字段需要支持多种语言,也可以这么来设计数据表的结构。
代码实现
此方案代码实现的思路是首先前后端统一约定好语言标识,中文简体:CN、中文繁体:TC、英文:EN,用户在页面上选择什么语言就在请求头中将该语言对应的标识携带到后端,后端获取到语言标识后再对其进行处理,这里我梳理了两种可实现的方案供各位参考,尤其是第二种方案。
方式一:查询时根据语言标识进行字段过滤
后端在每个需要实现动态数据国际化的接口中获取到请求头中的语言前缀language,在查询时过滤掉带其他语言标识的字段,只查询将当前语言标识作为前缀的字段和公用的字段,比如当前用户选择的是中文简体,那么从请求头中获取到的语言标识就是CN,所以在查询字段时就只查询含有CN前缀的字段和业务上需要使用到的公共字段即可,这样展示在用户面前的就是中文简体的数据,如果用户选择的是英文也是一样的方法。
此方案的缺点:冗余代码会特别多,很多跟业务无关的代码会直接侵入业务代码中,维护困难且开发成本高,开发者不仅要关注业务逻辑本身,还得关注语言的切换。优点就是实现起来相对简单一些,总之不是很推荐这种方案。
方式二:拦截响应数据结合JsonNode树模型统一处理
新建一个ResponseAdvice类,实现ResponseBodyAdvice接口拦截响应数据,在其beforeBodyWrite方法中对所有需要进行国际化处理的数据进行统一解析处理,这其中使用到了JackSon的JsonNode树模型,递归遍历响应结构。
1. 在代码中维护一个语言前缀列表,使用时将其转换为小写,用于后面过滤带有语言前缀的字段,使用HttpServletRequest获取到请求头中的语言前缀language,将其从语言前缀列表中移除,再将body转换为JsonNode,递归调用自定义的方法responseDataParseAndRemove()进行字段移除和重组。
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 通过HttpServletRequest获取到用户当前的语言环境
ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
if (servletRequestAttributes != null) {
HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest();
// 本地维护一个语言前缀列表
List<String> languageList = new ArrayList<>();
languageList.add("CN");
languageList.add("TC");
languageList.add("EN");
// 将响应数据body序列化为JsonNode
ObjectMapper objectMapper = new ObjectMapper();
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsString(body));
String language = httpServletRequest.getHeader("language");
languageList.remove(language);
responseDataParseAndRemove(node, languageList, language);
return node;
}
return body;
}
2. 在自定义方法responseDataParseAndRemove()中递归遍历所有字段,移除带有指定语言前缀的字段并新建目标字段,将当前语言标识的字段对应的值赋值给目标字段,方便前端处理。
/**
* <p> 指定前缀字段移除</p>
*
* @param node 响应数据节点
* @param toRemoveFieldPrefixList 待移除的字段前缀数组
* @description: 通过解析Json结构的响应数据、移除指定前缀字段、以达到动态数据在不同语言环境下的动态切换
**/
public static void responseDataParseAndRemove(JsonNode node, List<String> toRemoveFieldPrefixList, String language) {
// 节点只有两种:容器节点和非容器节点
if (node.isContainerNode()) {
// 判断该节点是对象还是数组
if (node.isObject()) {
List<String> currentLanguageFieldNameList = new ArrayList<>();
// 如果JsonNode是对象
ObjectNode objectNode = (ObjectNode) node;
// 待移除的字段列表初始化,本列表的作用是暂存所有满足条件待移除的字段
List<String> toRemoveFieldList = new ArrayList<>();
Iterator<Map.Entry<String, JsonNode>> nodeFieldList = objectNode.fields();
while (nodeFieldList.hasNext()) {
Map.Entry<String, JsonNode> nodeField = nodeFieldList.next();
String fieldName = nodeField.getKey();
JsonNode fieldValue = nodeField.getValue();
// 如果当前字段名的前缀属于待移除前缀列表中的任何一个元素,说明当前这个字段需要移除,加入到toRemoveFieldList
for (String toRemoveFieldPrefix : toRemoveFieldPrefixList) {
if (fieldName.startsWith(toRemoveFieldPrefix.toLowerCase())) {
toRemoveFieldList.add(fieldName);
}
}
// 继续判断字段值是不是一个对象,如果是则递归调用当前方法进行处理;如果是数组则循环递归调用当前方法进行处理
if (fieldValue.isObject()) {
responseDataParseAndRemove(fieldValue, toRemoveFieldPrefixList, language);
} else if (fieldValue.isArray()) {
ArrayNode arrayNode = (ArrayNode) fieldValue;
for (JsonNode element : arrayNode) {
responseDataParseAndRemove(element, toRemoveFieldPrefixList, language);
}
}
// 需要将当前语言的值替换到目标字段,所以维护一个含有当前语言前缀的字段和目标字段的
if (fieldName.startsWith(language.toLowerCase())) {
currentLanguageFieldNameList.add(fieldName);
}
}
// 一次性移除所有待移除的字段
objectNode.remove(toRemoveFieldList);
// 一次性替换所有字段
for (String fieldName : currentLanguageFieldNameList) {
// 新建一个目标字段:规则为原字段去掉当前语言前缀再转换为小驼峰,比如当前语言是cn,原字段是cnName,去掉前缀后就是再将首字母转换为小写就可以得到name
String targetFiledName = fieldName.substring(language.length());
targetFiledName = targetFiledName.substring(0, 1).toLowerCase() + targetFiledName.substring(1);
// 获取到原字段的值
JsonNode fieldValue = objectNode.get(fieldName);
// 将目标字段添加到树模型中,原字段的值作为目标字段的值
objectNode.set(targetFiledName, fieldValue);
// 移除原字段
objectNode.remove(fieldName);
}
} else if (node.isArray()) {
// 如果JsonNode是数组
ArrayNode arrayNode = (ArrayNode) node;
for (JsonNode element : arrayNode) {
responseDataParseAndRemove(element, toRemoveFieldPrefixList, language);
}
}
} else {
// 非容器节点
log.info("非容器节点,不需要任何处理");
}
}
下面是完整代码,仔细阅读才能搞明白
@Slf4j
@SuppressWarnings("all")
@RestControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 通过HttpServletRequest获取到用户当前的语言环境
ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
if (servletRequestAttributes != null) {
HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest();
// 本地维护一个语言前缀列表
List<String> languageList = new ArrayList<>();
languageList.add("CN");
languageList.add("TC");
languageList.add("EN");
// 将响应数据body序列化为JsonNode
ObjectMapper objectMapper = new ObjectMapper();
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsString(body));
String language = httpServletRequest.getHeader("language");
languageList.remove(language);
responseDataParseAndRemove(node, languageList, language);
return node;
}
return body;
}
/**
* <p> 响应数据解析和指定前缀字段移除</p>
*
* @param node 响应数据节点
* @param toRemoveFieldPrefixList 待移除的字段前缀数组
* @description: 通过解析Json结构的响应数据、移除指定前缀字段、以达到动态数据在不同语言环境下的动态切换
**/
public static void responseDataParseAndRemove(JsonNode node, List<String> toRemoveFieldPrefixList, String language) {
// 节点只有两种:容器节点和非容器节点
if (node.isContainerNode()) {
// 判断该节点是对象还是数组
if (node.isObject()) {
List<String> currentLanguageFieldNameList = new ArrayList<>();
// 如果JsonNode是对象
ObjectNode objectNode = (ObjectNode) node;
// 待移除的字段列表初始化,本列表的作用是暂存所有满足条件待移除的字段
List<String> toRemoveFieldList = new ArrayList<>();
Iterator<Map.Entry<String, JsonNode>> nodeFieldList = objectNode.fields();
while (nodeFieldList.hasNext()) {
Map.Entry<String, JsonNode> nodeField = nodeFieldList.next();
String fieldName = nodeField.getKey();
JsonNode fieldValue = nodeField.getValue();
// 如果当前字段名的前缀属于待移除前缀列表中的任何一个元素,说明当前这个字段需要移除,加入到toRemoveFieldList
for (String toRemoveFieldPrefix : toRemoveFieldPrefixList) {
if (fieldName.startsWith(toRemoveFieldPrefix.toLowerCase())) {
toRemoveFieldList.add(fieldName);
}
}
// 继续判断字段值是不是一个对象,如果是则递归调用当前方法进行处理;如果是数组则循环递归调用当前方法进行处理
if (fieldValue.isObject()) {
responseDataParseAndRemove(fieldValue, toRemoveFieldPrefixList, language);
} else if (fieldValue.isArray()) {
ArrayNode arrayNode = (ArrayNode) fieldValue;
for (JsonNode element : arrayNode) {
responseDataParseAndRemove(element, toRemoveFieldPrefixList, language);
}
}
// 需要将当前语言的值替换到目标字段,所以维护一个含有当前语言前缀的字段和目标字段的
if (fieldName.startsWith(language.toLowerCase())) {
currentLanguageFieldNameList.add(fieldName);
}
}
// 一次性移除所有待移除的字段
objectNode.remove(toRemoveFieldList);
// 一次性替换所有字段
for (String fieldName : currentLanguageFieldNameList) {
// 新建一个目标字段:规则为原字段去掉当前语言前缀再转换为小驼峰,比如当前语言是cn,原字段是cnName,去掉前缀后就是再将首字母转换为小写就可以得到name
String targetFiledName = fieldName.substring(language.length());
targetFiledName = targetFiledName.substring(0, 1).toLowerCase() + targetFiledName.substring(1);
// 获取到原字段的值
JsonNode fieldValue = objectNode.get(fieldName);
// 将目标字段添加到树模型中,原字段的值作为目标字段的值
objectNode.set(targetFiledName, fieldValue);
// 移除原字段
objectNode.remove(fieldName);
}
} else if (node.isArray()) {
// 如果JsonNode是数组
ArrayNode arrayNode = (ArrayNode) node;
for (JsonNode element : arrayNode) {
responseDataParseAndRemove(element, toRemoveFieldPrefixList, language);
}
}
} else {
// 非容器节点
log.info("非容器节点,不需要任何处理");
}
}
}
开始测试
新建一个响应实体类,代码如下所示
@Data
@Accessors(chain = true)
public class GetArticleTypeListResponse {
/**
* 自增aid
*/
private Integer id;
/**
* 中文简体名称
*/
private String cnName;
/**
* 中文繁体名称
*/
private String tcName;
/**
* 英文名称
*/
private String enName;
}
我这里为了测试方便,直接在controller中写一个方法进行测试,我的响应类中有cnName(中文简体名称)、tcName(中文繁体名称)和enName(英文名称),经过统一响应处理,返回到前端就只会是一个name,其它不带有语言前缀的字段,会正常返回。
@GetMapping("/get-article-type-list")
public CommonResponse getArticleTypeList() {
List<GetArticleTypeListResponse> responseList = new ArrayList<>();
GetArticleTypeListResponse responseOriginal = new GetArticleTypeListResponse();
responseOriginal.setId(1);
responseOriginal.setCnName("原创");
responseOriginal.setTcName("原創");
responseOriginal.setEnName("original");
responseList.add(responseOriginal);
GetArticleTypeListResponse responseForward = new GetArticleTypeListResponse();
responseForward.setId(2);
responseForward.setCnName("转发");
responseForward.setTcName("轉發");
responseForward.setEnName("forward");
responseList.add(responseForward);
return CommonResponse.success(responseList);
}
当language等于CN(中文简体)时,结果如下所示
当language等于TC(中文繁体)时,结果如下所示
当language等于EN(英文)时,结果如下所示
总结一下
此方案的优点:设计简单、实现也不算复杂、单表数据量不会因为语言的增加而增加。
此方案的缺点:可扩展性差、增加一种语言就需要去修改一次表结构,维护困难。
适用的场景:系统数据量小、团队规模小、支持的语言基本固定
方案二:单库单表多记录
此方案的思路是在一个表中存储不同语言的记录,比如我们需要支持的语言是中文简体(CN)、中文繁体(TC)和英文(EN),那么就在article_type表中存储三条不同语言的数据数据并标识出每条数据所属语言,再给这三条语言一个唯一标识,方便其他表引用。查询时将用户所选择的语言作为查询条件即可。
关键词:单表、多记录、唯一标识
数据表设计
列名 | 类型 | 备注 |
---|---|---|
aid | int(11) | 自增aid |
uuid | char(32) | 唯一标识 |
name | varchar(20) | 名称 |
language | char(2) | 所属语言 |
代码实现
实现思路:定义一个LanguageUtil类,在拦截器中拦截所有请求,将前端通过Header携带过来的language获取到并通过LanguageUtil存储到ThreadLocal中,在需要使用的地方取出来作为条件即可。
创建LanguageUtil类,用于临时存储用户选择的语言
public class LanguageUtil {
private final static ThreadLocal<String> LANGUAGE_THREAD_LOCAL = new ThreadLocal<>();
public void setLanguage(String language) {
LANGUAGE_THREAD_LOCAL.set(language);
}
public String getLanguage() {
return LANGUAGE_THREAD_LOCAL.get();
}
public void cleanLanguage() {
LANGUAGE_THREAD_LOCAL.remove();
}
}
创建RequestInterceptor类作为拦截器,用于拦截请求并获取language
@Component
public class RequestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求头中的language
String language = request.getHeader("language");
LanguageUtil.setLanguage(language);
return true;
}
}
创建InterceptorConfig类用于注册拦截器
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private RequestInterceptor requestInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration interceptorRegistration = registry.addInterceptor(requestInterceptor);
// 指定拦截匹配模式
interceptorRegistration.addPathPatterns("/**");
}
}
开始测试
数据表里面模拟了三条不同语言的数据
创建一个ExampleController类用于测试
@RestController
public class ExampleController {
@Resource
private ArticleTypeMapper articleTypeMapper;
@GetMapping("/get-article-type-list")
public List<ArticleType> getArticleTypeList() {
// 通过LanguageUtil获取到语言
String language = LanguageUtil.getLanguage();
LambdaQueryWrapper<ArticleType> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(ArticleType::getLanguage, language);
return articleTypeMapper.selectList(lambdaQueryWrapper);
}
}
使用ApiFox发起请求
当language等于CN时得到的结果如下所示
当language等于TC时得到的结果如下所示
当language等于EN时得到的结果如下所示
总结一下
此方案的优点:实现简单、具有可扩展性、如果后期增加其他语言,则只需要添加对应的记录即可。
此方案的缺点:单表数据量会很大、维护困难、随着业务增长数据量变大,查询性能会降低,使用UUID作为唯一标识,存储空间占用大、查询性能也受影响。
适用的场景:数据量小、支持的语言相对稳定的系统。
方案三:多库结合动态数据源切换
此方案的思路是根据语言的种类建立多个库,比如我们需要支持的语言是中文简体(CN)、中文繁体(TC)和英文(EN),那么就需要建立三个数据库,一个数据库存储一种语言的数据,三个库的表结构完全一样,查询时通过动态数据源切换来操作指定的库即可。
关键词:多个库、动态数据源切换
数据表设计
三个数据库分别为中文简体数据库(cn_db)、中文繁体数据库(tc_db)、英文数据库(en_db),每个库里面都设计文章类型表(article_type),结构如下所示:
列名 | 类型 | 备注 |
---|---|---|
aid | int(11) | 自增aid |
name | varchar(20) | 名称 |
代码实现
实现思路:配置多个数据源,在拦截器中获取用户所选择的language将其拼接为数据源名称,再通过DataSourceUtil存储到ThreadLocal中实现动态切换数据源。
多数据源application.yml文件配置
server:
port: 8090
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# 简体中文数据库 cn_db
cn_db:
jdbc-url: jdbc:mysql://localhost:3306/cn_db?useSLL=false&createDatabaseIfNotExist=true&useSSL=false&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
password: 密码
username: root
driver-class-name: com.mysql.cj.jdbc.Driver
# 英文数据库 en_db
en_db:
jdbc-url: jdbc:mysql://localhost:3306/en_db?useSLL=false&createDatabaseIfNotExist=true&useSSL=false&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
password: 密码
username: root
driver-class-name: com.mysql.cj.jdbc.Driver
#中文繁体数据库 tc_db
tc_db:
jdbc-url: jdbc:mysql://localhost:3306/en_db?useSLL=false&createDatabaseIfNotExist=true&useSSL=false&characterEncoding=UTF-8&allowMultiQueries=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
password: 密码
username: root
driver-class-name: com.mysql.cj.jdbc.Driver
创建一个数据库配置类DataSourceConfig
@Configuration
public class DataSourceConfig {
/**
* <p> 中文简体数据源cn_db </p>
**/
@Bean(name = "cn-db")
@ConfigurationProperties(prefix = "spring.datasource.cn-db")
public DataSource dataSourceCn() {
return DataSourceBuilder.create().build();
}
/**
* <p> 中文繁体数据源tc-db </p>
**/
@Bean(name = "tc-db")
@ConfigurationProperties(prefix = "spring.datasource.tc-db")
public DataSource dataSourceTc() {
return DataSourceBuilder.create().build();
}
/**
* <p> 英文数据库en_db </p>
**/
@Bean(name = "en-db")
@ConfigurationProperties(prefix = "spring.datasource.en-db")
public DataSource dataSourceEn() {
return DataSourceBuilder.create().build();
}
@Primary
@Bean(name = "dynamicDataSource")
public DataSource dynamicDataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
// 设置默认数据源
dynamicDataSource.setDefaultTargetDataSource(dataSourceCn());
// 配置多数据源
Map<Object, Object> dbMap = new HashMap<Object, Object>(3);
dbMap.put("cn-db", dataSourceCn());
dbMap.put("tc-db", dataSourceTc());
dbMap.put("en-db", dataSourceEn());
dynamicDataSource.setTargetDataSources(dbMap);
return dynamicDataSource;
}
/**
* <p> 重新配置事务管理器 </p>
**/
@Bean
public PlatformTransactionManager transactionManager() {
return new DataSourceTransactionManager(dynamicDataSource());
}
}
创建一个DynamicDataSource类并继承AbstractRoutingDataSource类
public class DynamicDataSource extends AbstractRoutingDataSource {
protected Object determineCurrentLookupKey() {
return DataSourceUtil.getDb();
}
}
关键点在于determineCurrentLookupKey方法,该方法返回需要使用的DataSource的key值,然后我们根据这个Key从resolvedDataSources这个map里面取出对应的DataSource,如果找不到就使用默认的数据源。
创建一个DataSourceUtil类,使用ThreadLocal存储请求需要使用的数据源
public class DataSourceUtil {
public static final ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void setDb(String db) {
threadLocal.set(db);
}
public static String getDb() {
return threadLocal.get();
}
public static void cleanDb() {
threadLocal.remove();
}
}
至此动态数据源切换的功能就实现了
拦截器统一切换数据源
假设我们的语言前缀是前端从Header携带到后端的,名称为language,对于需要指定语言环境的查询我们有两种实现方案
方式一:在每个查询controller方法中都通过HttpServletRequest获取到请求头中的language,指定对应的数据源
优点:实现简单
缺点:代码冗余、侵入性比较高且维护困难,开发人员不仅要关注业务本身,还得关注语言切换问题
方式二:拦截器拦截所有请求统一指定数据源
优点:不侵入业务代码、统一处理、方便维护、可扩展性强、开发人员关注业务本身即可
缺点:实现相对要复杂一些
创建RequestInterceptor类作为拦截器拦截请求并根据language切换数据源
@Component
public class RequestInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求头中的language
String language = request.getHeader("language");
// 拼接成数据源名称
String db = language.toLowerCase() + "-db";
// 指定本次请求需要的数据源
DataSourceUtil.setDb(db);
return true;
}
}
创建InterceptorConfig类用于注册拦截器并配置拦截路径
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Resource
private RequestInterceptor requestInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration interceptorRegistration = registry.addInterceptor(requestInterceptor);
// 指定拦截匹配模式
interceptorRegistration.addPathPatterns("/**");
}
}
代码测试
我往三个库的文章类型信息表中都加入了一条数据用于测试
创建一个用于测试的ExampleController类,使用MyBatisPlus查询数据
@RestController
public class ExampleController {
@Resource
private ArticleTypeMapper articleTypeMapper;
@GetMapping("/get-article-type-list")
public List<ArticleType> getArticleTypeList() {
return articleTypeMapper.selectList(null);
}
}
使用ApiFox进行测试
当language等于CN时结果如下所示
当language等于EN时结果如下所示
当language等于TC时结果如下所示
总结一下
此方案的优点:扩展性很强、业务后期如果有新增的语言只需要加一个对应的库即可,且有利于不同语言数据的维护,从性能的角度来说这种方式将数据库的压力分散开来,使得单个数据库的负载降低,从而提升性能。
此方案的缺点:代价大、维护成本高、系统整体的稳定性会降低、高并发场景下动态切换数据源可能会成为性能瓶颈。
适用的场景:随着业务的发展有可能不断增加新语言的场景。