搞定SpringBoot难题!设计优秀的后端接口?轻松解决

1 概述

本篇文章以Spring Boot为基础,从如下三个方向讲述了如何设计一个优秀的后端接口体系:html

  • 参数校验:涉及Hibernate Validator的各类注解,快速失败模式,分组,组序列以及自定义注解/Validator
  • 异常处理:涉及ControllerAdvice/@RestControllerAdvice以及@ExceptionHandler
  • 数据响应:涉及如何设计一个响应体以及如何包装响应体

有了一个优秀的后端接口体系,不只有了规范,同时扩展新的接口也很容易,本文演示了如何从零一步步构建一个优秀的后端接口体系。前端

2 新建工程

打开熟悉的IDEA,选择依赖:java

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

首先建立以下文件:git

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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端口)。spring

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。app

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 测试

所有都使用错误的参数设置,返回”邮箱格式不正确“:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

第二次测试中除了密码都使用正确的参数,返回”密码必须为6-20位“:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

第三次测试所有使用正确的参数,返回”success“:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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);
    }
}

测试(连续三次校验的结果):

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

若是是普通模式(修改.failFast(false)),一次校验便会连续输出三个信息:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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位,测试以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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

每次进行校验的顺序不一样,三次测试结果以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

有些时候顺序并不重要,而有些时候顺序很重要,好比:

  • 第二个组中的约束验证依赖于一个稳定状态运行,而这个稳定状态由第一个组来进行验证
  • 某个组的验证比较耗时,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中定义组:

 

这样就能按照固定的顺序进行参数校验了。

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;
    //...
}

测试以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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);

测试:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

4 异常处理

到这里参数校验就完成了,下一步是处理异常。

若是将参数校验中的BindingResult去掉,就会将整个后端异常返回给前端:

//public String test(@RequestBody @Validated({User.Group.class}) User user, BindingResult bindingResult)
public String test(@RequestBody @Validated({User.Group.class}) User user)
复制代码

 

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

这样虽而后端是方便了,不须要每个接口都加上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);
}

而后就能够进行测试了:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

全局异常处理相比起原来的每个接口都加上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);
}

结果以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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>封装返回值,测试以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

能够看到返回了一个比较友好的信息,不管是响应成功仍是响应失败都会返回同一个响应体,当须要返回具体的用户数据时,能够修改控制层接口直接返回Result<User>:

@PostMapping("test")
public Result<User> test(@RequestBody @Validated({User.Group.class}) User user)
{
    return service.test(user);
}

测试:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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);
}

测试输出以下:

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

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 总结

 

搞定SpringBoot难题!设计优秀的后端接口?轻松解决

 

7 源码

直接clone下来使用IDEA打开便可,每一次优化都作了一次提交,能够看到优化的过程,喜欢的话欢迎给个star:

  • Github
  • 码云

做者:氷泠 连接:https://juejin.im/post/6860404263143604232 来源:掘金