1 概述
本篇文章以 Spring Boot
为根底,从以下三个方向讲述了如何设计一个优良的后端接口体系:
- 参数校验:波及
Hibernate Validator
的各种注解,疾速失败模式,分组,组序列以及自定义注解 /Validator
- 异样解决:波及
ControllerAdvice
/@RestControllerAdvice
以及@ExceptionHandler
- 数据响应:波及如何设计一个响应体以及如何包装响应体
有了一个优良的后端接口体系,不仅有了标准,同时扩大新的接口也很容易,本文演示了如何从零一步步构建一个优良的后端接口体系。
2 新建工程
关上相熟的 IDEA,抉择依赖:
首先创立如下文件:
TestController.java
:
@RestController
@RequestMapping("/")
@CrossOrigin(value = "http://localhost:3000")
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final TestService service;
@PostMapping("test")
public String test(@RequestBody User user)
{return service.test(user);
}
}
应用了 @RequiredArgsConstructor
代替@Autowired
,因为笔者应用 Postwoman 测试,因而须要加上跨域注解@CrossOrigin
,默认 3000 端口(Postwoman 端口)。
TestService.java
:
@Service
public class TestService {public String test(User user)
{if(StringUtils.isEmpty(user.getEmail()))
return "邮箱不能为空";
if(StringUtils.isEmpty(user.getPassword()))
return "明码不能为空";
if(StringUtils.isEmpty(user.getPhone()))
return "电话不能为空";
// 长久化操作
return "success";
}
}
业务层首先进行了参数校验,这里省略了长久化操作。
User.java
:
@Data
public class User {
private String phone;
private String password;
private String email;
}
3 参数校验
首先来看一下参数校验,下面的例子中在业务层实现参数校验,这是没有问题的,然而,还没进行业务操作就须要进行这么多的校验显然这不是很好,更好的做法是,应用Hibernate Validator
。
3.1 Hibernate Validator
3.1.1 介绍
JSR
是 Java Specification Requests
的缩写,意思是 Java 标准提案,是指向 JCP(Java Community Process)
提出新增一个标准化技术规范的正式申请。JSR-303
是 Java EE6
中的一项子标准,叫作 Bean Validation
,Hibernate Validator
是Bean Validator
的参考实现,除了实现所有 JSR-303
标准中的内置 constraint
实现,还有附加的constraint
,具体如下:
@Null
:被注解元素必须为null
(为了节俭篇幅上面用“元素”代表“被注解元素必须为”)@NotNull
:元素不为null
@AssertTrue
:元素为true
@AssertFalse
:元素为false
@Min(value)
:元素大于或等于指定值@Max(value)
:元素小于或等于指定值@DecimalMin(value)
:元素大于指定值@DecimalMax(value)
:元素小于指定值@Size(max,min)
:元素大小在给定范畴内@Digits(integer,fraction)
:元素字符串中的整数位数规定最大integer
位,小数位数规定最大fraction
位@Past
:元素是一个过来日期@Future
:元素是未来日期@Pattern
:元素须要合乎正则表达式
其中 Hibernate Validator
附加的 constraint
如下:
@Eamil
:元素为邮箱@Length
:字符串大小在指定范畴内@NotEmpty
:字符串必须非空(目前最新的6.1.5
版本已弃用,倡议应用规范的@NotEmpty
)@Range
:数字在指定范畴内
而在 Spring
中,对 Hibernate Validation
进行了二次封装,增加了主动校验,并且校验信息封装进了特定的 BindingResult
中。上面看看如何应用。
3.1.2 应用
在各个字段加上@NotEmpty
,并且邮箱加上@Email
,电话加上 11 位限度,并且在各个注解加上message
,示意对应的提示信息:
@Data
public class User {@NotEmpty(message = "电话不能为空")
@Length(min = 11,max = 11,message = "电话号码必须 11 位")
private String phone;
@NotEmpty(message = "明码不能为空")
@Length(min = 6,max = 20,message = "明码必须为 6 -20 位")
private String password;
@NotEmpty(message = "邮箱不能为空")
@Email(message = "邮箱格局不正确")
private String email;
}
对于 String
来说有时候会应用 @NotNull
或@NotBlank
,它们的区别如下:
@NotEmpty
:不能为null
并且长度必须大于 0,除了String
外,对于Collection
/Map
/ 数组也实用@NotBlank
:只用于String
,不能为null
,并且调用trim()
后,长度必须大于 0,也就是必须有除空分外的理论字符@NotNull
:不能为null
接着把业务层的参数校验操作删除,并把管制层批改如下:
@PostMapping("test")
public String test(@RequestBody @Valid User user, BindingResult bindingResult)
{if(bindingResult.hasErrors())
{for(ObjectError error:bindingResult.getAllErrors())
return error.getDefaultMessage();}
return service.test(user);
}
在须要校验的对象上加上 @Valid
,并且加上BindingResult
参数,能够从中获取错误信息并返回。
3.1.3 测试
全副都应用谬误的参数设置,返回”邮箱格局不正确“:
第二次测试中除了明码都应用正确的参数,返回”明码必须为 6 -20 位“:
第三次测试全副应用正确的参数,返回”success“:
3.2 校验模式设置
Hibernate Validator
有两种校验模式:
- 一般模式:默认模式,会校验所有属性,而后返回所有的验证失败信息
- 疾速失败模式:只有有一个验证失败就返回
应用疾速失败模式须要通过 HibernateValidateConfiguration
以及 ValidateFactory
创立 Validator
,并且应用Validator.validate()
进行手动验证。
首先增加一个生成 Validator
的类:
@Configuration
public class FailFastValidator<T> {
private final Validator validator;
public FailFastValidator()
{
validator = Validation
.byProvider(HibernateValidator.class).configure()
.failFast(true).buildValidatorFactory()
.getValidator();}
public Set<ConstraintViolation<T>> validate(T user)
{return validator.validate(user);
}
}
批改管制层的代码,通过 @RequiredArgsConstructor
注入 FailFastValidator<User>
,并把原来的在User
上的 @Valid
去掉,在办法体进行手动验证:
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class TestController {
private final TestService service;
private final FailFastValidator<User> validator;
@PostMapping("test")
public String test(@RequestBody User user, BindingResult bindingResult)
{Set<ConstraintViolation<User>> message = validator.validate(user);
message.forEach(t-> System.out.println(t.getMessage()));
// if(bindingResult.hasErrors())
// {// bindingResult.getAllErrors().forEach(t->System.out.println(t.getDefaultMessage()));
// for(ObjectError error:bindingResult.getAllErrors())
// return error.getDefaultMessage();
// }
return service.test(user);
}
}
测试(间断三次校验的后果):
如果是一般模式(批改.failFast(false)
),一次校验便会间断输入三个信息:
3.3 @Valid
与@Validated
@Valid
是 javax.validation
包外面的,而 @Validated
是org.springframework.validation.annotation
外面的,是 @Valid
的一次封装,相当于是 @Valid
的增强版,供 Spring 提供的校验机制应用,相比起 @Valid
,@Validated
提供了分组以及组序列的性能。上面别离进行介绍。
3.4 分组
当须要在不同的状况下应用不同的校验形式时,能够应用分组校验。比方在注册时不须要校验id
,批改信息时须要校验id
,然而默认的校验形式在两种状况下全副都校验,这时就须要应用分组校验。
上面以不同的组别校验电话号码长度的不同进行阐明,批改 User
类如下:
@Data
public class User {@NotEmpty(message = "电话不能为空")
@Length(min = 11,max = 11,message = "电话号码必须 11 位",groups = {GroupA.class})
@Length(min = 12,max = 12,message = "电话号码必须 12 位",groups = {GroupB.class})
private String phone;
@NotEmpty(message = "明码不能为空")
@Length(min = 6,max = 20,message = "明码必须为 6 -20 位")
private String password;
@NotEmpty(message = "邮箱不能为空")
@Email(message = "邮箱格局不正确")
private String email;
public interface GroupA{}
public interface GroupB{}}
在 @Length
中退出了组别,GroupA
示意电话须要为 11 位,GroupB
示意电话须要为 12 位,GroupA
/GroupB
是 User
中的两个空接口,而后批改管制层:
public String test(@RequestBody @Validated({User.GroupB.class}) User user, BindingResult bindingResult)
{if(bindingResult.hasErrors())
{bindingResult.getAllErrors().forEach(t->System.out.println(t.getDefaultMessage()));
for(ObjectError error:bindingResult.getAllErrors())
return error.getDefaultMessage();}
return service.test(user);
}
在 @Validated
中指定为GroupB
,电话须要为 12 位,测试如下:
3.5 组序列
默认状况下,不同组别的束缚验证的无序的,也就是说,对于上面的 User
类:
@Data
public class User {@NotEmpty(message = "电话不能为空")
@Length(min = 11,max = 11,message = "电话号码必须 11 位")
private String phone;
@NotEmpty(message = "明码不能为空")
@Length(min = 6,max = 20,message = "明码必须为 6 -20 位")
private String password;
@NotEmpty(message = "邮箱不能为空")
@Email(message = "邮箱格局不正确")
private String email;
}
每次进行校验的程序不同,三次测试后果如下:
有些时候程序并不重要,而有些时候程序很重要,比方:
- 第二个组中的束缚验证依赖于一个稳固状态运行,而这个稳固状态由第一个组来进行验证
- 某个组的验证比拟耗时,CPU 和内存的使用率绝对较大,最优的抉择是将其放在最初进行验证
因而在进行组验证的时候须要提供一种有序的验证形式,一个组能够定义为其余组的序列,这样就能够固定每次验证的程序而不是随机程序,另外如果验证组序列中,后面的组验证失败,则前面的组不会验证。
例子如下,首先批改 User
类并定义组序列:
@Data
public class User {@NotEmpty(message = "电话不能为空",groups = {First.class})
@Length(min = 11,max = 11,message = "电话号码必须 11 位",groups = {Second.class})
private String phone;
@NotEmpty(message = "明码不能为空",groups = {First.class})
@Length(min = 6,max = 20,message = "明码必须为 6 -20 位",groups = {Second.class})
private String password;
@NotEmpty(message = "邮箱不能为空",groups = {First.class})
@Email(message = "邮箱格局不正确",groups = {Second.class})
private String email;
public interface First{}
public interface Second{}
@GroupSequence({First.class,Second.class})
public interface Group{}}
定义了两个空接口 First
和Second
示意程序,同时在 Group
中应用 @GroupSequence
指定了程序。
接着批改管制层,在 @Validated
中定义组:
public String test(@RequestBody @Validated({User.Group.class}) User user, BindingResult bindingResult)
这样就能依照固定的程序进行参数校验了。
3.6 自定义校验
只管 Hibernate Validator
中的注解实用状况很广了,然而有时候须要特定的校验规定,比方明码强度,人为断定弱明码还是强明码。也就是说,此时须要增加自定义校验的形式,有两种解决办法:
- 自定义注解
- 自定义
Validator
首先来看一下自定义注解的办法。
3.6.1 自定义注解
这里增加一个断定弱明码的注解WeakPassword
:
@Documented
@Constraint(validatedBy = WeakPasswordValidator.class)
@Target({ElementType.METHOD,ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface WeakPassword{String message() default "请应用更加强健的明码";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};}
同时增加一个实现了 ConstraintValidator<A,T>
的WeakPasswordValidator
,当明码长度大于 10 位时才符合条件,否则返回 false
示意校验不通过:
public class WeakPasswordValidator implements ConstraintValidator<WeakPassword,String> {
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {return s.length() > 10;
}
@Override
public void initialize(WeakPassword constraintAnnotation) {}}
接着能够批改 User
如下,在对应的字段加上自定义注解@WeakPassword
:
@Data
public class User {
//...
@WeakPassword(groups = {Second.class})
private String password;
//...
}
测试如下:
3.6.2 自定义Validator
除了自定义注解之外,还能够自定义 Validator
来实现自定义的参数校验,须要实现 Validator
接口:
@Component
public class WeakPasswordValidator implements Validator{
@Override
public boolean supports(Class<?> aClass) {return User.class.equals(aClass);
}
@Override
public void validate(Object o, Errors errors) {ValidationUtils.rejectIfEmpty(errors,"password","password.empty");
User user = (User)o;
if(user.getPassword().length() <= 10)
errors.rejectValue("password","Password is not strong enough!");
}
}
实现其中的 supports
以及validate
:
support
:能够验证该类是否是某个类的实例validate
:当supports
返回true
后,验证给定对象o
,当呈现谬误时,向errors
注册谬误
ValidationUtils.rejectIfEmpty
校验当对象 o
中某个字段属性为空时,向其中的 errors
注册谬误,留神并不会中断语句的运行,也就是即便 password
为空,user.getPassword()
还是会运行,这时会抛出空指针异样。上面的 errors.rejectValue
同样情理,并不会中断语句的运行,只是注册了错误信息,中断的话须要手动抛出异样。
批改管制层中的返回值,改为getCode()
:
if(bindingResult.hasErrors())
{bindingResult.getAllErrors().forEach(t-> System.out.println(t.getCode()));
for(ObjectError error:bindingResult.getAllErrors())
return error.getCode();}
return service.test(user);
测试:
4 异样解决
到这里参数校验就实现了,下一步是解决异样。
如果将参数校验中的 BindingResult
去掉,就会将整个后端异样返回给前端:
//public String test(@RequestBody @Validated({User.Group.class}) User user, BindingResult bindingResult)
public String test(@RequestBody @Validated({User.Group.class}) User user)
这样尽管后端是不便了,不须要每一个接口都加上BindingResult
,然而前端不好解决,整个异样都返回了,因而后端须要捕获这些异样,然而,不能手动去捕获每一个,这样还不如之前应用BindingResult
,这种状况下就须要用到全局的异样解决。
4.1 根本应用
解决全局异样的步骤如下:
- 创立全局异样解决的类:加上
@ControllerAdvice
/@RestControllerAdvice
注解(取决于管制层用的是@Controller
/@RestController
,@Controller
能够跳转到相应页面,返回JSON
等加上@ResponseBody
即可,而@RestController
相当于@Controller
+@ResponseBody
,返回JSON
无需加上@ResponseBody
,然而视图解析器无奈解析jsp
以及html
页面) - 创立异样解决办法:加上
@ExceptionHandler
指定想要解决的异样类型 - 解决异样:在对应的解决异样办法中解决异样
这里减少一个全局异样解决类GlobalExceptionHandler
:
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(MethodArgumentNotValidException.class)
public String methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{ObjectError error = e.getBindingResult().getAllErrors().get(0);
return error.getDefaultMessage();}
}
首先加上@RestControllerAdvice
,并在异样解决办法上加上@ExceptionHandler
。
接着批改管制层,去掉其中的BindingResult
:
@PostMapping("test")
public String test(@RequestBody @Validated({User.Group.class}) User user)
{return service.test(user);
}
而后就能够进行测试了:
全局异样解决相比起原来的每一个接口都加上 BindingResult
不便很多,而且能够集中处理所有异样。
4.2 自定义异样
很多时候都会用到自定义异样,这里新增一个测试异样TestException
:
@Data
public class TestException extends RuntimeException{
private int code;
private String msg;
public TestException(int code,String msg)
{super(msg);
this.code = code;
this.msg = msg;
}
public TestException()
{this(111,"测试异样");
}
public TestException(String msg)
{this(111,msg);
}
}
接着在方才的全局异样解决类中增加一个解决该异样的办法:
@ExceptionHandler(TestException.class)
public String testExceptionHandler(TestException e)
{return e.getMsg();
}
在管制层进行测试:
@PostMapping("test")
public String test(@RequestBody @Validated({User.Group.class}) User user)
{throw new TestException("出现异常");
// return service.test(user);
}
后果如下:
5 数据响应
在解决好了参数校验以及异样解决之后,下一步就是要设置对立的规范化的响应数据,一般来说无论响应胜利还是失败都会有一个状态码,响应胜利还会携带响应数据,响应失败则携带相应的失败信息,因而,第一步是设计一个对立的响应体。
5.1 对立响应体
对立响应体须要创立响应体类,一般来说,响应体须要蕴含:
- 状态码:
String
/int
- 响应信息:
String
- 响应数据:
Object
/T
(泛型)
这里简略的定义一个对立响应体Result
:
@Data
@AllArgsConstructor
public class Result<T> {
private String code;
private String message;
private T data;
}
接着批改全局异样解决类:
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(error.getCode(),"参数校验失败",error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{return new Result<>(e.getCode(),"失败",e.getMsg());
}
}
应用 Result<String>
封装返回值,测试如下:
能够看到返回了一个比拟敌对的信息,无论是响应胜利还是响应失败都会返回同一个响应体,当须要返回具体的用户数据时,能够批改管制层接口间接返回Result<User>
:
@PostMapping("test")
public Result<User> test(@RequestBody @Validated({User.Group.class}) User user)
{return service.test(user);
}
测试:
5.2 响应码枚举
通常来说能够把响应码做成枚举类:
@Getter
public enum ResultCode {SUCCESS("111","胜利"),FAILED("222","失败");
private final String code;
private final String message;
ResultCode(String code,String message)
{
this.code = code;
this.message = message;
}
}
枚举类封装了状态码以及信息,这样在返回后果时,只须要传入对应的枚举值以及数据即可:
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(ResultCode.FAILED,error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{return new Result<>(ResultCode.FAILED,e.getMsg());
}
}
5.3 全局包装响应体
对立响应体是个很好的想法,然而还能够再深刻一步去优化,因为每次返回之前都须要对响应体进行包装,尽管只是一行代码然而每个接口都须要包装一下,这是个很麻烦的操作,为了更进一步“偷懒”,能够抉择实现 ResponseBodyAdvice<T>
来进行全局的响应体包装。
批改原来的全局异样解决类如下:
@RestControllerAdvice
public class GlobalExceptionHandler implements ResponseBodyAdvice<Object> {@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e)
{ObjectError error = e.getBindingResult().getAllErrors().get(0);
return new Result<>(ResultCode.FAILED,error.getDefaultMessage());
}
@ExceptionHandler(TestException.class)
public Result<String> testExceptionHandler(TestException e)
{return new Result<>(ResultCode.FAILED,e.getMsg());
}
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {return !methodParameter.getParameterType().equals(Result.class);
}
@Override
public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {return new Result<>(o);
}
}
实现了ResponseBodyAdvice<Object>
:
supports
办法:判断是否反对控制器返回办法类型,能够通过supports
判断哪些类型须要包装,哪些不须要包装间接返回beforeBodyWrite
办法:当supports
返回true
后,对数据进行包装,这样在返回数据时就无需应用Result<User>
手动包装,而是间接返回User
即可
接着批改管制层,间接返回实体类 User
而不是响应体包装类Result<User>
:
@PostMapping("test")
public User test(@RequestBody @Validated({User.Group.class}) User user)
{return service.test(user);
}
测试输入如下:
5.4 绕过全局包装
尽管依照下面的形式能够使后端的数据全副依照对立的模式返回给前端,然而有时候并不是返回给前端而是返回给其余第三方,这时候不须要 code
以及 msg
等信息,只是须要数据,这样的话,能够提供一个在办法上的注解来绕过全局的响应体包装。
比方增加一个 @NotResponseBody
注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface NotResponseBody {}
接着须要在解决全局包装的类中,在 supports
中进行判断:
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
return !(methodParameter.getParameterType().equals(Result.class)
||
methodParameter.hasMethodAnnotation(NotResponseBody.class)
);
}
最初批改管制层,在须要绕过的办法上增加自定义注解 @NotResponseBody
即可:
@PostMapping("test")
@NotResponseBody
public User test(@RequestBody @Validated({User.Group.class}) User user)
6 总结
7 源码
间接 clone 下来应用 IDEA 关上即可,每一次优化都做了一次提交,能够看到优化的过程,喜爱的话欢送给个 star:
- Github
- 码云
8 参考
1、UncleChen 的博客 -SpringBoot 自定义申请参数校验
2、简书 -@Valid 和 @Validated 的总结辨别
3、博客园 -@Controller 与 @RestController 的区别
4、简书 -【我的项目实际】-SpringBoot 三招组合拳,手把手教你打出优雅的后端接口
5、简书 -【我的项目实际】后端接口对立标准的同时,如何优雅得扩大标准