这是我参与更文挑战的第 29 天,活动详情查看: 更文挑战
hibernate-validator最全国际化方案
为了实现hibernate-validator国际化差点要了老命,最近在研究hibernate-validator以及国际化,在墙里墙外找了很多很久,可能是因为版本的更新迭代,找到的资料基本都用不了了。自己折腾了半天,终于琢磨出来了,特此记录。
对hibernate-validator的具体使用细节本文不再赘述, 环境信息:
1、原生SpringBoot环境
环境信息
- SpringBoot : 2.3.8.RELEASE
- hibernate-validator : 6.1.7.Final
验证
hibernate-validator主要用于验证前段请求过来的参数是否满足条件
controller层:
@RestController
@RequestMapping("valid")
public class TestController {
@PostMapping("/test")
public Person test(@Valid @RequestBody Person person) {
return person;
}
}
复制代码
实体类
public class Person {
@NotBlank()
private String name;
@Range(min = 2, max = 100)
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
复制代码
从实体类中可以看出主要有两个验证条件
- person.name不能为空
- person.age在2~100之间
如果在此时用不满足条件的数据直接调用接口会返回404,看不到验证出错信息,这是因为Spring拦截了验证异常,直接返回了404
想要不返回404需要自定义异常拦截器,把验证异常自己来处理掉
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler({BindException.class})
public String bindExceptionHandler(final BindException e) {
String message = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(" ; "));
return "{\"errors\":\"" + message + "\"}";
}
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(MethodArgumentNotValidException.class)
public String handler(final MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(" ; "));
return "{\"errors\":\"" + message + "\"}";
}
@ResponseStatus(HttpStatus.OK)
@ExceptionHandler(ConstraintViolationException.class)
public String handler(final ConstraintViolationException e) {
String message = e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(" ; "));
return "{\"errors\":\"" + message + "\"}";
}
}
复制代码
验证异常就是这三种情况:
- BindException
- MethodArgumentNotValidException
- ConstraintViolationException
增加全局异常处理后,再次调用接口:
这种处理后报错信息存在两个问题
- 如何国际化,返回英文和中文
- 提示信息不明确,能不能自定义
国际化问题
Spring提供了i18N解决方式,跟断点也能发现校验失败后也是走的这套流程,核心类为AcceptHeaderLocaleResolver
从代码中可以看到语言是通过获取header中的Accept-Language切换的,这里也是固定写死的,如果要切换成其他方式或者其他header值,可以通过继承LocaleChangeInterceptor 覆盖preHandle方法,装备自定义bean使用
自定义校验信息
1、如果不考虑国际化
这种情况最简单,直接在验证Bean中修改
@NotBlank(message = "名字不能为空吆")
private String name;
@Range(min = 2, max = 100, message = "年龄需要在2~100岁之间")
private Integer age;
复制代码
2、考虑国际化
这种情况就不能简单的写死message了,需要通过hibernate-valid提供的国际化方式
通过hibernate源码可以看到国际化信息都存在一系列的properties文件中,这就给我提供了覆盖的机会,通过在resource下定义同名的国际化文件
首先,修改bean的message为el表达式方式
@NotBlank(message = "{person.name}")
private String name;
@Range(min = 2, max = 100, message = "{person.age}")
private Integer age;
复制代码
然后,把key定义在自己的国际化文件中:(一定要注意编码格式)
调用接口后结果:
如果此时需要自定义国际化文件名和位置,则需要增加如下配置类
@Configuration
public class MessageConfig {
public ResourceBundleMessageSource getMessageSource() throws Exception {
ResourceBundleMessageSource resourceBundle = new ResourceBundleMessageSource();
resourceBundle.setDefaultEncoding(StandardCharsets.UTF_8.name());
//指定国际化文件路径
resourceBundle.setBasenames("i18n/valid");
return resourceBundle;
}
@Bean
public Validator getValidator() throws Exception {
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.setValidationMessageSource(getMessageSource());
return validator;
}
@Bean
public MethodValidationPostProcessor validationPostProcessor() throws Exception {
MethodValidationPostProcessor processor = new MethodValidationPostProcessor();
//指定请求验证器
processor.setValidator(getValidator());
return processor;
}
}
复制代码
通过上述配置就可以达到使用指定国际化文件实现验证消息自定义的效果
2、DropWizard工程
DropWizard是一个轻量级的微服务开发框架,本身对hibernate-valid也做了一层封装,但是没有国际化方案
需要使用hibernate-valid原始国际化方式,通过设置默认语言来实现,
如果不需要默认方式,可以跳过此章节
实体类:
public class Person {
@NotBlank()
private String name;
@Range(min = 2, max = 100)
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
复制代码
资源类
@Service
@Path("/test")
public class TestResource {
@POST
@Path("/test")
@Consumes({APPLICATION_JSON})
@Produces({APPLICATION_JSON})
public String test(Person person, @Context final HttpServletRequest request) {
Set<ConstraintViolation<Person>> validate = getValidator().validate(person);
if (!validate.isEmpty()) {
StringJoiner message = new StringJoiner(";");
for (Object aValidate : validate) {
message.add(((ConstraintViolation) aValidate).getMessage());
}
return "{\"result\":\"" + message.toString() + "\"}";
}
return "{\"result\":\"ok\"}";
}
}
复制代码
1、只需要自定义消息体
这种场景只需要在实体类中修改message即可
public class Person {
@NotBlank(message = "姓名不能为空")
private String name;
@Range(min = 2, max = 100, message = "年龄需要在2~100之间")
private Integer age;
}
复制代码
调用接口会就会返回固定的信息:
2、只需要国际化
这种场景时使用hibernate-valid自带的国际化消息,可以通过header中Accept-Language来控制语言类型。比如en-US,zh-CN
然后再后端获取header后,设置语言
修改实体类
public class Person {
@NotBlank()
private String name;
@Range(min = 2, max = 100)
private Integer age;
}
复制代码
修改资源类
@POST
@Path("/test")
@Consumes({APPLICATION_JSON})
@Produces({APPLICATION_JSON})
public String test(Person person, @Context final HttpServletRequest request) {
//获取header中语言
String language = request.getHeader("Accept-Language");
Set<ConstraintViolation<Person>> validate = getValidator(language).validate(person);
if (!validate.isEmpty()) {
StringJoiner message = new StringJoiner(";");
for (Object aValidate : validate) {
message.add(((ConstraintViolation) aValidate).getMessage());
}
return "{\"result\":\"" + message.toString() + "\"}";
}
return "{\"result\":\"ok\"}";
}
private static Validator getValidator(String language) {
// 设置语言
Locale.setDefault(language.contains("zh") ? Locale.CHINA : Locale.ENGLISH);
return Validation.byDefaultProvider().configure()
.buildValidatorFactory()
.getValidator();
}
复制代码
调用接口效果:
3、需要国际化并自定义消息体
这种场景需要增加配置类指定国际化文件
private static Validator getValidator(String language) {
Locale.setDefault(new Locale(language.contains("zh") ? "zh" : "en"));
return Validation.byDefaultProvider().configure()
//国际化文件配置
.messageInterpolator(new MyResourceBundleMessageInterpolator(new PlatformResourceBundleLocator("i18n/valid")))
.buildValidatorFactory()
.getValidator();
}
复制代码
java中properties配置文件默认的编码为:ISO-8859-1,是不支持中文的,所以会乱码,需要做转码:
public class MyResourceBundleMessageInterpolator extends ResourceBundleMessageInterpolator {
public MyResourceBundleMessageInterpolator(ResourceBundleLocator userResourceBundleLocator) {
super(userResourceBundleLocator);
}
@Override
public String interpolate(String message, Context context) {
String result = super.interpolate(message, context);
try {
return new String(result.getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);
} catch (Exception e) {
return result;
}
}
}
复制代码
修改实体类message为el表达式类型:
public class Person {
@NotBlank(message = "{person.name}")
private String name;
@Range(min = 2, max = 100, message = "{person.age}")
private Integer age;
}
复制代码
在resouce下面增加国际化文件
使用Accept-Language来控制语言类型,调用接口效果:
3、SpringBoot工程
由于Jersey和普通SpringMVC不一样,所以不能传统的方式实现国际化, 如果不了解或者项目中没有使用Jersey,可以跳过此章节
实体类:
public class Person {
@NotBlank()
private String name;
@Range(min = 2, max = 100)
private Integer age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
复制代码
资源类
@Component
@Path("/test")
public class TestResource {
@Autowired
private Validator validate;
@POST
@Path("/test")
@Consumes({APPLICATION_JSON})
@Produces({APPLICATION_JSON})
public String test(Person person, @Context final HttpServletRequest request) {
Set<ConstraintViolation<Person>> errorResults = validate.validate(person);
if (!errorResults.isEmpty()) {
StringJoiner message = new StringJoiner(";");
for (Object result : errorResults) {
message.add(((ConstraintViolation) result).getMessage());
}
return "{\"result\":\"" + message.toString() + "\"}";
}
return "{\"result\":\"ok\"}";
}
复制代码
1、只需要自定义消息体
这种场景只需要在实体类中修改message即可
public class Person {
@NotBlank(message = "姓名不能为空")
private String name;
@Range(min = 2, max = 100, message = "年龄需要在2~100之间")
private Integer age;
}
复制代码
调用接口会就会返回固定的信息:
2、只需要国际化
这种场景时使用hibernate-valid自带的国际化消息,使用Spring的i18n国际化规则
通过header中Accept-Language来控制语言类型。比如en-US,zh-CN
修改实体类
public class Person {
@NotBlank()
private String name;
@Range(min = 2, max = 100)
private Integer age;
}
复制代码
调用接口效果:
3、需要国际化并自定义消息体
这种场景需要增加配置类指定国际化文件
@Configuration
public class MessageConfig {
public MessageSource getMessageSource() throws Exception {
ResourceBundleMessageSource rbms = new ResourceBundleMessageSource();
rbms.setDefaultEncoding(StandardCharsets.UTF_8.toString());
//指定国际化文件路径
rbms.setBasenames("i18n/valid");
return rbms;
}
@Bean
public LocalValidatorFactoryBean getValidator() throws Exception {
LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
validator.setValidationMessageSource(getMessageSource());
return validator;
}
}
复制代码
修改实体类message为el表达式类型:
public class Person {
@NotBlank(message = "{person.name}")
private String name;
@Range(min = 2, max = 100, message = "{person.age}")
private Integer age;
}
复制代码
在resouce下面增加国际化文件, 一定要注意文件编码格式要与配置中一致
通样通过Spring的国际化方式,使用Accept-Language来控制语言类型,调用接口效果:
小结
Hibernate-valid国际化相对来说还是比较麻烦,需要深入到源码才能了解如何配置,连官网都没有文档,难道有什么隐藏bug?哈哈
现在多学一样本事,将来就少说一句求人的话,你我皆加油