Java后端实现动态数据国际化多语言自由切换技术方案

Java技术 潘老师 10个月前 (08-06) 1598 ℃ (0) 扫码查看

现在很多企业业务系统可能要支持多语言情形,你是否有考虑过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、第三方实时翻译、多语言库切换等,可能会更有兴趣和试验价值。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/java/7674.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】