全网最骚SpringBoot国际化i18n配置

前言

忽然来了个需求让我搞国际化配置,通过添加header确定返回哪种语言信息。个人认知里信息国际化无非是常量信息的分类,根据相应环境进行返回,大致的返回流程就两步:

  1. 获取信息key
  2. 根据当前语言环境与信息key返回相应语言信息

根据该流程想出了两种实现方式:

  • 通过类进行语言划分,各语言信息与key都写进常量类中,通过Map映射返回
  • 将信息都写到配置文件中,根据环境与key读取文件返回,这块SpringBoot也有相应的支持

本来想着项目代码这么烂,要不就按第一种算了,都是常量信息的整理,都是无法缩减的硬工作量,相比用SpringBoot的方式可以少一些配置。但尝试开展时发现了以下问题:

  1. 信息枚举类里存各种语言信息属性
  2. 做两层映射,各国语言常量:<信息key:各信息枚举>

但存在以下问题:

  • 语言扩展很不方便,设信息类包含了各种语言的信息,但有新语言时就要修改硬编码在信息枚举类中添加新语言信息属性(虽然个人不认为不会再有扩展)
  • 信息枚举类会很难看(预料之中)

虽然我不认为项目语言还会有扩展,但编码上的可扩展性一直都是个人的看重点,于是放弃了语言信息存类中的想法,转而考虑存文件中。
SpringBoot提供了LocaleResolver来根据语言解析相应properties文件返回语言信息的功能,hibernate参数校验返回相应的语言信息便是据此实现,但需创建相应语言的资源文件,而个人期望放到一个或少量文件中,且有更好的想法,于是放弃了常见的SpringBoot i18n实现方式。

实现思路

信息国际化只是针对语言与信息key进行信息分类映射返回,而Spring Boot 2.0起后就支持通过配置文件进行Map属性的注入,于是个人就以Map属性注入方式创建了所需的国际化类:

  1. (核心)项目初始化时将key与各国语言信息的映射注入到Map,后端不维护语言类型常量,由前端header传参决定
  2. 业务异常ApiException抛出时抛出的是key而非message
  3. 业务拦截器GlobalExceptionHandler根据key与language header取相应message

由此添加了以下i18n类:

  • 接口I18NKey:返回信息key。所有信息key枚举需实现的接口,枚举根据业务进行分类
  • 组件类I18NMessage:国际化信息类,根据配置文件进行信息自动注入,根据I18NKey与语言类型返回相应信息

此外,需添加通过I18NKey构造ApiException方法,更改拦截器GlobalExceptionHandler的信息返回处理。

示例与相关类

请求示例流程:

  1. 前端携带语言类型header请求
  2. 后端业务逻辑通过ApiAssert断言到异常时抛出message为I18NKey中key字符串的ApiExceptionApiException中的message一律视为i18n字符串key处理)
  3. GlobalExceptionHandler获取ApiExceptionmessage,通过注入的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();
    }
}
example

总结

  1. SpringBoot的Map注入用处很广泛的,你甚至可以用来一直套娃,这里就有两层示例了
  2. 没啥技术难度,思路骚点就行,就是常量的映射处理罢了,没啥额外知识带来的负担(躺平最爱)
  3. yaml文件的配置分类可比properties香多了,拒绝反驳。不搞啥不同语言不同properties文件,全部放一个不香吗?业务信息多了根据业务分类不香吗?application-i18n-admin.yaml看起来不比admin_en.properties + admin_zn_cn.properties + ..... 简便吗?方便CV与CTRL+D

你可能感兴趣的:(全网最骚SpringBoot国际化i18n配置)