前言
忽然来了个需求让我搞国际化配置,通过添加header确定返回哪种语言信息。个人认知里信息国际化无非是常量信息的分类,根据相应环境进行返回,大致的返回流程就两步:
- 获取信息key
- 根据当前语言环境与信息key返回相应语言信息
根据该流程想出了两种实现方式:
- 通过类进行语言划分,各语言信息与key都写进常量类中,通过Map映射返回
- 将信息都写到配置文件中,根据环境与key读取文件返回,这块SpringBoot也有相应的支持
本来想着项目代码这么烂,要不就按第一种算了,都是常量信息的整理,都是无法缩减的硬工作量,相比用SpringBoot的方式可以少一些配置。但尝试开展时发现了以下问题:
- 信息枚举类里存各种语言信息属性
- 做两层映射,各国语言常量:<信息key:各信息枚举>
但存在以下问题:
- 语言扩展很不方便,设信息类包含了各种语言的信息,但有新语言时就要修改硬编码在信息枚举类中添加新语言信息属性(虽然个人不认为不会再有扩展)
- 信息枚举类会很难看(预料之中)
虽然我不认为项目语言还会有扩展,但编码上的可扩展性一直都是个人的看重点,于是放弃了语言信息存类中的想法,转而考虑存文件中。
SpringBoot提供了LocaleResolver
来根据语言解析相应properties文件返回语言信息的功能,hibernate参数校验返回相应的语言信息便是据此实现,但需创建相应语言的资源文件,而个人期望放到一个或少量文件中,且有更好的想法,于是放弃了常见的SpringBoot i18n实现方式。
实现思路
信息国际化只是针对语言与信息key进行信息分类映射返回,而Spring Boot 2.0起后就支持通过配置文件进行Map
属性的注入,于是个人就以Map
属性注入方式创建了所需的国际化类:
- (核心)项目初始化时将key与各国语言信息的映射注入到
Map
,后端不维护语言类型常量,由前端header传参决定 - 业务异常
ApiException
抛出时抛出的是key而非message - 业务拦截器
GlobalExceptionHandler
根据key与language header取相应message
由此添加了以下i18n
类:
- 接口
I18NKey
:返回信息key。所有信息key枚举需实现的接口,枚举根据业务进行分类 - 组件类
I18NMessage
:国际化信息类,根据配置文件进行信息自动注入,根据I18NKey
与语言类型返回相应信息
此外,需添加通过I18NKey
构造ApiException
方法,更改拦截器GlobalExceptionHandler
的信息返回处理。
示例与相关类
请求示例流程:
- 前端携带语言类型header请求
- 后端业务逻辑通过
ApiAssert
断言到异常时抛出message为I18NKey
中key字符串的ApiException
(ApiException
中的message一律视为i18n字符串key处理) -
GlobalExceptionHandler
获取ApiException
中message
,通过注入的I18NMessage
根据语言类型header将该message/key转化为实际所需的message,如果I18NMessage
中的信息映射没匹配到该key,则视为message直接返回
i18n信息配置文件 application-i18n.yaml
业务的划分可以通过在配置文件中添加注释或key添加前缀实现,可随意扩展语言信息
i18n:
# 若前端无header传参则返回中文信息
default-lang: cn
message:
# admin
password_not_null:
en: Password can't be null
cn: 密码不能为空
password_cannot_same:
en: The old and new passwords cannot be the same
cn: 新旧密码不能相同
xx: 随意扩展语言,啥都行,前端header值能匹配上就行
i18n信息类I18NMessage
@Data
@Component
@ConfigurationProperties("i18n")
public class I18NMessage {
/**
* message-key:
*/
private Map> message;
/**
* Default language setting (Default "cn").
*/
private String defaultLang = "cn";
/**
* get i18n message
*
* @param key
* @param language
* @return
*/
public String message(I18NKey key, String language) {
return Optional.ofNullable(message.get(key.key()))
.map(map -> map.get(language == null ? defaultLang : language))
.orElse(key.key());
}
/**
* get i18n message
*
* @param key
* @param language
* @return
*/
public String message(String key, String language) {
return Optional.ofNullable(message.get(key))
.map(map -> map.get(language == null ? defaultLang : language))
.orElse(key);
}
}
I18NKey
public interface I18NKey {
/**
* get i18n message key
*
* @return
*/
String key();
}
AdminI18NKey
@AllArgsConstructor
public enum AdminI18NKey implements I18NKey {
/**
* admin i18n message key enum
* 秘诀:IDEA CTRL+SHIFT+U可转大小写
*/
PASSWORD_NOT_NULL("password_not_null") ,
NEW_OLD_PASSWORD_CANNOT_SAME("password_cannot_same") ;
// 需与配置文件中的信息key值一致
private final String key;
@Override
public String key() {
return key;
}
}
业务异常类ApiException
@Getter
public class ApiException extends RuntimeException {
private final Integer code;
......
public ApiException(I18NKey key) {
super(key.key());
this.code = HttpCodeMsg.SERVER_ERROR.code();
}
......
}
ApiException
断言类ApiAssert
/**
* Assert to throw ApiException
* @author Wilson
*/
public final class ApiAssert {
......
/**
* Check is a not equals to b.
*
* @param a
* @param b
* @param key
* @throws ApiException collection is empty
*/
public static void notEquals(Object a, Object b, I18NKey key) {
if (Objects.equals(a, b)) {
throw new ApiException(key.key());
}
}
}
全局异常处理器GlobalExceptionHandler
@Slf4j
@ResponseBody
@RestControllerAdvice
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@AllArgsConstructor
public class GlobalExceptionHandler {
private final I18NMessage i18NMessage;
private final HttpServletRequest request;
private static final String I18N_HEADER = "Lang";
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = ApiException.class)
public ServerResponse> businessExceptionHandler(ApiException e) {
log.error("业务错误: {}", e.getMessage());
// 通过I18NMessage进行消息映射返回
return ServerResponse.of(e.getCode(), i18NMessage.message(e.getMessage(),request.getHeader(I18N_HEADER)));
}
......
}
AdminController
示例
@RestController
@RequestMapping("/admin")
public class AdminController {
@PutMapping("/password")
public ServerResponse> changePassword(@RequestParam String old, @RequestParam String newPassword,
@RequestHeader(value = "Lang", required = false) String lang) {
ApiAssert.notEquals(old, newPassword, AdminI18NKey.NEW_OLD_PASSWORD_CANNOT_SAME);
return ServerResponse.success();
}
}
总结
- SpringBoot的
Map
注入用处很广泛的,你甚至可以用来一直套娃,这里就有两层示例了 - 没啥技术难度,思路骚点就行,就是常量的映射处理罢了,没啥额外知识带来的负担(躺平最爱)
- yaml文件的配置分类可比properties香多了,拒绝反驳。不搞啥不同语言不同properties文件,全部放一个不香吗?业务信息多了根据业务分类不香吗?
application-i18n-admin.yaml
看起来不比admin_en.properties + admin_zn_cn.properties + .....
简便吗?方便CV与CTRL+D