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

@Servicepublic 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

@Datapublic class User {    private String phone;    private String password;    private String email;}

3 参数校验

首先来看一下参数校验,下面的例子中在业务层实现参数校验,这是没有问题的,然而,还没进行业务操作就须要进行这么多的校验显然这不是很好,更好的做法是,应用Hibernate Validator

3.1 Hibernate Validator

3.1.1 介绍

JSRJava Specification Requests的缩写,意思是Java标准提案,是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式申请。JSR-303Java EE6中的一项子标准,叫作Bean ValidationHibernate ValidatorBean 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,示意对应的提示信息:

@Datapublic 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的类:

@Configurationpublic 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

@Validjavax.validation包外面的,而@Validatedorg.springframework.validation.annotation外面的,是@Valid的一次封装,相当于是@Valid的增强版,供Spring提供的校验机制应用,相比起@Valid@Validated提供了分组以及组序列的性能。上面别离进行介绍。

3.4 分组

当须要在不同的状况下应用不同的校验形式时,能够应用分组校验。比方在注册时不须要校验id,批改信息时须要校验id,然而默认的校验形式在两种状况下全副都校验,这时就须要应用分组校验。

上面以不同的组别校验电话号码长度的不同进行阐明,批改User类如下:

@Datapublic 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/GroupBUser中的两个空接口,而后批改管制层:

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类:

@Datapublic 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类并定义组序列:

@Datapublic 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{}}

定义了两个空接口FirstSecond示意程序,同时在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

@Datapublic class User {    //...    @WeakPassword(groups = {Second.class})    private String password;    //...}

测试如下:

3.6.2 自定义Validator

除了自定义注解之外,还能够自定义Validator来实现自定义的参数校验,须要实现Validator接口:

@Componentpublic 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

@RestControllerAdvicepublic 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

@Datapublic 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@AllArgsConstructorpublic class Result<T> {    private String code;    private String message;    private T data;}

接着批改全局异样解决类:

@RestControllerAdvicepublic 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 响应码枚举

通常来说能够把响应码做成枚举类:

@Getterpublic 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;    }}

枚举类封装了状态码以及信息,这样在返回后果时,只须要传入对应的枚举值以及数据即可:

@RestControllerAdvicepublic 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>来进行全局的响应体包装。

批改原来的全局异样解决类如下:

@RestControllerAdvicepublic 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中进行判断:

@Overridepublic boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {    return !(        methodParameter.getParameterType().equals(Result.class)         ||        methodParameter.hasMethodAnnotation(NotResponseBody.class)    );}

最初批改管制层,在须要绕过的办法上增加自定义注解@NotResponseBody即可:

@PostMapping("test")@NotResponseBodypublic 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、简书-【我的项目实际】后端接口对立标准的同时,如何优雅得扩大标准