程序员必备利器:多语言参数校验的实用指南!

685 阅读4分钟

前言

  在此之前,写过在两篇文章,是关于如何在 SpringBoot 内实现统一参数校验和自定义校验注解的。毕竟作为后端来讲,对于前端传来的数据,需要保持高度的警惕。避免出现异常数据,导致系统异常。统一参数校验和自定义校验注解,可以帮助我们更加优雅和严格的完成参数校验,减少出错的概率。

    /**
     * 账户名
     */
    @Email(message = "邮箱格式有误")
    @NotBlank(message = "账户名称不能为空")
    @ApiModelProperty(notes = "账户名", required = true)
    private String accountName;

  随着业务的发展碰上了多语言,多区域,原本的参数错误提示语就不太够用了。当 APP 切换到别的区域,比如美国,接口出错提示语还是中文这就不太行了。所以我们今天就要解决它。

准备工作

  我们可以用 idea 初始化一个最基本的项目,然后配置一下统一参数校验。如下图所示:

LoginBo

@Data
public class LoginBo {

    /**
     * 账户名
     */
    @NotBlank(message = "账户名称不能为空")
    private String accountName;

    /**
     * 密码
     */
    @NotBlank(message = "密码不能为空")
    private String password;

}

ResultVo

public class ResultVo<T> {

    private String code;
    private String msg;
    private T data;


    public ResultVo() {
    }

    public ResultVo(String code, String msg) {
        this(code, msg, null);
    }

    public ResultVo(String code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    // 省略一些方法
}

TestController

@RestController
@RequestMapping("/test")
public class TestController {

    @PostMapping("/demo")
    public ResultVo<Void> demo(@RequestBody @Validated LoginBo bo) {
        System.out.println(bo);
        return ResultVo.success();
    }
}

GlobalExceptionHandler

@Component
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 参数校验不通过
     *
     * @param e BindException
     * @return ResultVo<Void>
     */
    @ExceptionHandler(BindException.class)
    public ResultVo<Void> handlerBindException(BindException e) {
        return ResultVo.failure(this.buildMsg(e.getBindingResult()));
    }

    /**
     * 参数校验不通过
     *
     * @param e MethodArgumentNotValidException
     * @return ResultVo<Void>
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultVo<Void> handlerMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        return ResultVo.failure(buildMsg(e.getBindingResult()));
    }

    /**
     * 构建参数错误提示信息
     *
     * @param bindingResult BindingResult
     * @return String
     */
    private String buildMsg(BindingResult bindingResult) {
        StringBuilder builder = new StringBuilder(32);
        for (FieldError error : bindingResult.getFieldErrors()) {
            builder.append(", [").append(error.getField()).append(":").append(error.getDefaultMessage()).append("]");
        }
        return builder.substring(2);
    }

}

实现方式

  首先明确一下我们的需求点:就是针对不同的语言,接口对应的错误提示语要不一样。这就意味着错误提示语是动态的不能写死。实现思路如下:

  1. 我们可以先针对不同的语言,翻译好对应的错误提示语,并生成相应的配置文件。
  2. 让注解内的 message 指向对应文件内的错误提示语。

配置文件

  这里其实是使用了 Spring Boot 提供的国际化支持来配置多语言提示语。首先,在资源文件中创建多个语言的属性文件,例如 messages.properties 表示默认的英文提示语,messages_zh_CN.properties 表示中文提示语。

  1. 中文配置文件:messages_zh_CN
account.name=账户名称不能为空
password=密码不能为空

2. 英文配置文件:messages_en_US

account.name=account name cannot be empty
password=password cannot be empty

错误提示语指向配置文件

@Data
public class LoginBo {

    /**
     * 账户名
     */
    @NotBlank(message = "{account.name}")
    private String accountName;

    /**
     * 密码
     */
    @NotBlank(message = "{password}")
    private String password;

}

演示一下

  理想很丰满,现实很骨感。貌似并没有生效,而是把我们的占位符,直接当提示语输出了。

  不要慌,这个其实是没有指定对应的配置文件,让我们配置一下,先设置为中文的。

@Configuration
public class ValidationConfig {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        // 先设置中文
        messageSource.setBasename("messages_zh_CN");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource);
        return validatorFactoryBean;
    }
}

再次尝试

  直接就成功了,那改成英文,让我们再试一下。

@Configuration
public class ValidationConfig {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages_en_US");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource);
        return validatorFactoryBean;
    }
}

改进一下

  通过上面的示例可以看到,虽然是实现了动态,但是还不够优雅。切换的时候,需要修改对应的配置代码。所以让我们改进下,把这部分也做成配置,在启动的时候进行指定就好了,这样方便在部署不同区域的时候可以动态进行配置。

  1. 增加默认语言配置
# 服务端口
server:
  port: 10000

# 配置默认语言
app:
  default:
    language: zh_CN
  1. 读取配置文件的默认语言
@Slf4j
@Configuration
public class ValidationConfig {

    @Value("${app.default.language}")
    private String defaultLanguage;

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages_" + defaultLanguage);
        messageSource.setDefaultEncoding("UTF-8");
        log.info("Message Source init suc -> lang:{}", defaultLanguage);
        return messageSource;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource);
        return validatorFactoryBean;
    }
}

  再次测试一下,结果如下:

敲黑板

  虽然上面实现了功能,但是其实是违反了设计原则的,为什么这样说呢?我们可以看看setBasename的注释,看看它是如何使用的。如下图所示:

译文

  从注释总可以发现,basename 需要遵循不指定文件扩展名或语言代码的基本 ResourceBundle 约定。很明显我们违反了。

@Bean
public MessageSource messageSource() {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasename("messages_en_US");
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

  所以正确的方式应该是这样的。

@Bean
public MessageSource messageSource() {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasename("messages");
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

  这个时候你可能会有疑问。如果不进行指定,那系统咋知道选用哪个配置文件呢?这个问题的答案就是,上面提到的 basename 需要遵循 ResourceBundle 约定。

ResourceBundle

  ResourceBundle 是 Java 标准库中的一个类,用于加载和管理国际化资源。它提供了一种机制来加载不同语言和区域的资源文件,并根据当前的 Locale 进行国际化处理。它提供了以下主要功能:

  1. 选择合适的资源文件:根据给定的 Locale,ResourceBundle 可以选择最匹配的资源文件。如果找不到完全匹配的资源文件,它会尝试找到默认的资源文件或向上回退到更通用的语言环境。
  2. 加载资源文件:ResourceBundle 会负责加载属性文件,并将其缓存在内存中,以便在需要时进行快速访问。
  3. 获取国际化消息:通过资源文件中定义的键,您可以使用 ResourceBundle 获取相应的国际化消息。ResourceBundle 将根据当前的 Locale 自动选择正确的资源文件,并返回与给定键对应的消息。

Locale

  在 Spring Boot 中,默认的 Locale 是根据操作系统的默认语言环境来确定的。它是通过调用 Locale.getDefault() 方法获取的。Locale.getDefault() 方法返回的是 JVM 运行环境的默认 Locale。

  如果您在操作系统中设置了特定的默认语言,那么 Spring Boot 应用程序将使用该默认语言作为默认的 Locale。如果操作系统没有明确设置默认语言,那么它可能会使用 JVM 的默认语言设置。请注意,如果您在 Spring Boot 应用程序中显式设置了其他的 Locale,它将覆盖操作系统的默认设置。

小结一下

  看到这里,我们可以对上面的问题小结一下了。为什么只需要设置 basename 即可?由于 basename 会遵循 ResourceBundle 约定。ResourceBundle 会根据 Spring Boot 获取到 Locale 选择来匹配资源文件。

  并且由于 ResourceBundle 的特点,如果找不到完全匹配的资源文件,它会尝试找到默认的资源文件或向上回退到更通用的语言环境。如果还找不到,那就只能把{xx.xxx}当提示语输出了,也不会影响系统运行。所以最后配置就变成这样了:

@Slf4j
@Configuration
public class ValidationConfig {


    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource);
        return validatorFactoryBean;
    }

}

  到了这里,貌似已经差不多了。但是有个问题,spring boot 默认是取操作系统的 Locale,如果取不到再取 JVM 的。假如服务器配置的是英文,接口需要返回中文,这不就有问题了吗。毕竟找运维大哥去修改还不如自己通过代码处理。

  处理方式如下:我们可以从配置文件读取默认语言配置,然后生成一个LocaleResolver

@Slf4j
@Configuration
public class ValidationConfig {

    @Value("${app.default.language}")
    private String defaultLanguage;

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

    @Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver resolver = new SessionLocaleResolver();
        resolver.setDefaultLocale(new Locale(defaultLanguage));
        return resolver;
    }

    @Bean
    public LocalValidatorFactoryBean validator(MessageSource messageSource) {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource);
        return validatorFactoryBean;
    }

}

  再次测试一下,结果如我们所愿:

总结

  该功能的实现主要依托于Spring Boot多语言。实现思路是:预先生成好对应的多语言配置文件,在需要实现多语言的地方跟配置文件进行关联,然后在设置对应Locale即可。

  当前我们只是实现了一个简单的案例。适用的场景是:服务部署在不同的区域,返回对应区域语言的提示语。

  假如我们的需求在进阶一点呢?在同一个区域,需要根据请求头内x-lang的标记语言类型,动态返回呢?并且配置文件不想写死在本地,比如放在nacos或者mysql内实现热更新呢?我们下期继续聊。

结尾

  如果觉得对你有帮助,可以多多评论,多多点赞哦,也可以到我的主页看看,说不定有你喜欢的文章,也可以随手点个关注哦,谢谢。

  我是不一样的科技宅,每天进步一点点,体验不一样的生活。我们下期见!