1 概述

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

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

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

2 新建工程

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

springboot后端架构 springboot后端接口开发_Spring Boot

首先创建如下文件:

springboot后端架构 springboot后端接口开发_Spring Boot_02

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 介绍

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,表示对应的提示信息:

@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后端架构 springboot后端接口开发_后端_03

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

springboot后端架构 springboot后端接口开发_Java_04

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

springboot后端架构 springboot后端接口开发_springboot后端架构_05

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后端架构 springboot后端接口开发_springboot后端架构_06

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

springboot后端架构 springboot后端接口开发_后端_07

3.3 @Valid@Validated

@Validjavax.validation包里面的,而@Validatedorg.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/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位,测试如下:

springboot后端架构 springboot后端接口开发_Spring Boot_08

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后端接口开发_Java_09

springboot后端架构 springboot后端接口开发_后端_10

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

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

定义了两个空接口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

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

测试如下:

springboot后端架构 springboot后端接口开发_Java_11

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后端架构 springboot后端接口开发_后端接口_12

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后端架构 springboot后端接口开发_后端_13


这样虽然后端是方便了,不需要每一个接口都加上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后端架构 springboot后端接口开发_Spring Boot_14

全局异常处理相比起原来的每一个接口都加上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后端架构 springboot后端接口开发_后端接口_15

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后端架构 springboot后端接口开发_Spring Boot_16

可以看到返回了一个比较友好的信息,无论是响应成功还是响应失败都会返回同一个响应体,当需要返回具体的用户数据时,可以修改控制层接口直接返回Result<User>

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

测试:

springboot后端架构 springboot后端接口开发_后端接口_17

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后端架构 springboot后端接口开发_后端接口_18

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后端架构 springboot后端接口开发_Spring Boot_19

7 源码

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