章
目
录
现在很多企业业务系统可能要支持多语言情形,你是否有考虑过Java后端如何实现动态数据国际化多语言自由切换技术方案?以下是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。用户在页面上选择语言后,在请求头中携带相应语言的标识,后端会根据这个标识来进行处理。下面,我将介绍两种方案供参考,特别是第二种方案。
方案一:根据语言标识过滤字段进行查询
后端在每个需要实现动态数据国际化的接口中,获取请求头中的语言标识。然后,在查询数据时,根据语言标识过滤掉不匹配的字段,只查询与当前语言标识匹配的字段和公用字段。例如,用户选择了中文简体,请求头中的语言标识就是CN。在查询数据时,只查询带有CN前缀的字段和业务所需的公用字段。这样,用户看到的数据就是中文简体的。对于英文也是同样的方法。
然而,这个方案的缺点是会导致大量冗余代码,许多与业务无关的代码会混入业务逻辑中,增加维护难度和开发成本。开发人员不仅需要关注业务逻辑,还得关注语言切换。虽然实现相对简单,但不太推荐这种方案。
方案二:使用拦截器和JsonNode树模型进行统一处理
创建一个名为ResponseAdvice的类,实现ResponseBodyAdvice接口,用于拦截响应数据。在beforeBodyWrite方法中,通过JsonNode树模型对所有需要国际化处理的数据进行解析和处理,递归遍历响应结构。
在代码中维护一个语言前缀列表,使用时将其转换为小写,用于后面过滤带有语言前缀的字段,使用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 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()中递归遍历所有字段,移除带有指定语言前缀的字段并新建目标字段,将当前语言标识的字段对应的值赋值给目标字段,方便前端处理。
/**
* <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 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(英文)时,结果如下所示
总结
这样就完成了动态数据的国际化处理,根据不同的语言前缀返回对应语言的数据。我提出的方案主要适用于中小型项目。其核心思想是在数据表中为不同语言建立相应的字段,然后通过语言前缀对响应数据进行处理。然而,在大型项目中,维护大量字段可能变得复杂,而且响应数据的统一处理可能成为性能瓶颈,因此不太推荐这种方案。对于其他的解决方案,比如Spring AOP、第三方实时翻译、多语言库切换等,可能会更有兴趣和试验价值。