大家好,我是Bivin,最近项目遇到了一个需求:为了更好的发展海外业务,平台的动态数据需要支持中文简体、中文繁体、英语三种语言的自由切换,我一想这不就是所谓的国际化么?虽然在某些大型平台见过,但是却从没真正自己设计过,瞬间压力到了我这边,还好我冥思苦想终于想出了一种动态数据国际化的方案,这篇文章就带大家看看我是如何设计的,嘿嘿!
说在前面
既然是做国际化,那么必然会涉及到静态数据国际化和动态数据国际化,由于Bivin这边主要是负责后端,且我们的研发模式一直都是前后端分离,所以我就讲讲我是如何设计动态数据的国际化!
动态数据国际化设计
假设我们系统中有一个场景是用户写完文章需要选择文章类型是原创还是转载,文章类型列表是存储在数据表中的,由后端通过接口返回,那么文章类型就涉及到多语言切换了,我们这篇文章就使用文章类型来演示如何做动态数据的国际化。
数据库设计
方案一:不支持多语言的设计
如果类型名称不支持多语言则数据表的设计是这样的:
列名 | 类型 | 备注 |
---|---|---|
aid | int(11) | 自增aid |
name | varchar(20) | 类型名称 |
create_time | datatime | 创建时间 |
update_time | datatime | 更新时间 |
方案二:支持多语言的设计
如果类型名称需要支持多语言,中文简体环境下显示中文名称,中文繁体环境下支持中文繁体名称,英文环境下显示英文名称,结构又该怎么设计呢?对于类型名称分别设计三个字段存储不同语言的数据,假设CN为前缀的表示中文简体,TC为前缀的表示中文繁体,EN为前缀的表示英文,数据表的设计是这样的:
列名 | 类型 | 备注 |
---|---|---|
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树模型,递归遍历响应结构。
- 在代码中维护一个语言前缀列表,使用时将其转换为小写,用于后面过滤带有语言前缀的字段,使用HttpServletRequest获取到请求头中的语言前缀language,将其从语言前缀列表中移除,再将body转换为JsonNode,递归调用自定义的方法responseDataParseAndRemove()进行字段移除和重组。
Java
@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;
}
- 在自定义方法responseDataParseAndRemove()中递归遍历所有字段,移除带有指定语言前缀的字段并新建目标字段,将当前语言标识的字段对应的值赋值给目标字段,方便前端处理。
Java
/**
* <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("非容器节点,不需要任何处理");
}
}
下面是完整代码,仔细阅读才能理解其中的思想
java
@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("非容器节点,不需要任何处理");
}
}
}
开始测试
新建一个响应实体类,代码如下所示
java
@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,其它不带有语言前缀的字段,会正常返回。
java
@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(英文)时,结果如下所示
总结一下
这样就完成了动态数据的国际化处理,根据不同的语言前缀返回对应语言的数据,我这个方案比较适合中小型项目,本质上就是需要在数据表中建立不同语言的字段,再根据语言前缀对响应数据进行处理,如果是大型项目,维护的字段可能会超级多,且响应数据的统一处理可能会成为性能瓶颈,所以不太推荐。其他的方案比如SpringAop、第三方实时翻译、多语言库切换等感兴趣的可以试试。