SpringBoot-设计优秀的后端接口

197 阅读2分钟

springboot/web项目优秀的后端接口体系,看一篇就够了
项目构建-统一参数校验,统一结果响应,统一异常处理,统一错误处理,统一日志记录,统一生成api文档

1. 前言
一个后端接口大致分为四个部分组成:接口地址(url)、接口请求方式(get、post等)、请求数据(request)、响应数据(response)。
本文主要演示如何构建起一个优秀的后端接口体系,体系构建好了自然就有了规范,同时再构建新的后端接口也会十分轻松。

2. 所需依赖包
这里用的是SpringBoot配置项目,本文讲解的重点是后端接口,所以只需要导入一个spring-boot-starter-web包就可以了:

pom.xml:


4.0.0

org.springframework.boot
spring-boot-starter-parent
2.2.4.RELEASE


com.rudecrab
validation-and-exception-handler
0.0.1-SNAPSHOT
validation-and-exception-handler
Demo project for Spring Boot

1.8 org.springframework.boot spring-boot-starter-web com.github.xiaoymin knife4j-spring-boot-starter 2.0.1 org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test org.junit.vintage junit-vintage-engine org.springframework.boot spring-boot-maven-plugin

本文还用了swagger来生成API文档,lombok来简化类,logback来生成日志。都不是必须的,可用可不用。

3.统一参数校验
一个接口一般对参数(请求数据)都会进行安全校验,参数校验的重要性自然不必多说,那么如何对参数进行校验就有讲究了。

业务层校验
首先我们来看一下最常见的做法,就是在业务层进行参数校验:

public String addUser(User user) {
if (user == null || user.getId() == null || user.getAccount() == null || user.getPassword() == null || user.getEmail() == null) {
return "对象或者对象字段不能为空";
}
if (StringUtils.isEmpty(user.getAccount()) || StringUtils.isEmpty(user.getPassword()) || StringUtils.isEmpty(user.getEmail())) {
return "不能输入空字符串";
}
if (user.getAccount().length() < 6 || user.getAccount().length() > 11) {
return "账号长度必须是6-11个字符";
}
if (user.getPassword().length() < 6 || user.getPassword().length() > 16) {
return "密码长度必须是6-16个字符";
}
if (!Pattern.matches("^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$", user.getEmail())) {
return "邮箱格式不正确";
}
// 参数校验完毕后这里就写上业务逻辑
return "success";
}

这还没有进行业务操作呢光是一个参数校验就已经这么多行代码,实在不够优雅。

使用Spring Validator 和 Hibernate Validator,这两套Validator来进行方便的参数校验!
(这两套Validator依赖包已经包含在前面所说的web依赖包里了,所以可以直接使用。)

3.1 Validator + BindResult进行校验
Validator可以非常方便的制定校验规则,并自动帮你完成校验。首先在入参里需要校验的字段加上注解,每个注解对应不同的校验规则,并可制定校验失败后的信息:

@Data
public class User {
@NotNull(message = "用户id不能为空")
private Long id;

@NotNull(message = "用户账号不能为空")
@Size(min = 6, max = 11, message = "账号长度必须是6-11个字符")
private String account;

@NotNull(message = "用户密码不能为空")
@Size(min = 6, max = 11, message = "密码长度必须是6-16个字符")
private String password;

@NotNull(message = "用户邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
}

校验规则和错误提示信息配置完毕后,接下来只需要在接口需要校验的参数上加上@Valid注解,并添加BindResult参数 即可方便完成验证:

@RestController
@RequestMapping("user")
public class UserController {
@Autowired
private UserService userService;

@PostMapping("/addUser")
public String addUser(@RequestBody @Valid User user, BindingResult bindingResult) {
// 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
for (ObjectError error : bindingResult.getAllErrors()) {
return error.getDefaultMessage();
}
return userService.addUser(user);
}
}

这样当请求数据传递到接口的时候Validator就自动完成校验了,校验的结果就会封装到BindingResult中去,如果有错误信息我们就直接返回给前端,业务逻辑代码也根本没有执行下去。
此时,传统的那种业务层里的校验代码就已经不需要了:

public String addUser(User user) {
// 直接编写业务逻辑
return "success";
}

现在可以看一下参数校验效果。我们故意给这个接口传递一个不符合校验规则的参数,先传递一个错误数据给接口,故意将password这个字段不满足校验条件:

{
"account": "12345678",
"email": "123@qq.com",
"id": 0,
"password": "123"
}

再来看一下接口的响应数据:

使用Validator+ BindingResult已经是非常方便实用的参数校验方式了,在实际开发中也有很多项目就是这么做的,不过这样还是不太方便,因为你每写一个接口都要添加一个BindingResult参数,然后再提取错误信息返回给前端。这样有点麻烦,并且重复代码很多(尽管可以将这个重复代码封装成方法)。

我们能否去掉BindingResult这一步呢?当然是可以的!

3.2 Validator + 自动抛出异常
我们完全可以将BindingResult这一步给去掉:

@PostMapping("/addUser")
public String addUser(@RequestBody @Valid User user) {
return userService.addUser(user);
}

去掉之后会发生什么事情呢?直接来试验一下,还是按照之前一样故意传递一个不符合校验规则的参数给接口。

此时我们观察控制台可以发现接口引发MethodArgumentNotValidException异常:

异常是引发了,可我们并没有编写返回错误信息的代码呀,那参数校验失败了会响应什么数据给前端呢? 我们来看一下刚才异常发生后接口响应的数据:

没错,是直接将整个错误对象相关信息都响应给前端了!这样就很难受,不过解决这个问题也很简单,就是我们接下来要讲的全局异常处理!

4. 统一异常处理(全局异常处理)
(全局异常处理也叫统一异常处理)
参数校验失败会自动引发异常,又不想手动捕捉这个异常,又要对这个异常进行处理,那正好使用SpringBoot全局异常处理来达到一劳永逸的效果!

4.1 @ControllerAdvice注解
该注解为spirngboot中统一异常处理的核心。

是一种作用于控制层的切面通知(Advice),该注解能够将通用的@ExceptionHandler、@InitBinder和@ModelAttributes方法收集到一个类型,并应用到所有控制器上。

该类中的设计思路:

使用@ExceptionHandler注解捕获指定或自定义的异常;
使用@ControllerAdvice集成@ExceptionHandler的方法到一个类中;
必须定义一个通用的异常捕获方法,便于捕获未定义的异常信息;
自定一个异常类,捕获针对项目或业务的异常;
异常的对象信息补充到统一结果枚举中;
4.2 全局异常处理类
首先,需要新建一个类,在这个类上加上 @ControllerAdvice或@RestControllerAdvice注解 , 这个类就配置成全局处理类了。(根据你的Controller层用的是 @Controller还是@RestController 来决定选哪个注解) 。
然后在类中新建方法,在方法上加上 @ExceptionHandler注解 并指定你想处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理!
我们现在就来演示一下对参数校验失败抛出的 MethodArgumentNotValidException 全局处理。

ExceptionControllerAdvice.java:

@RestControllerAdvice
public class ExceptionControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
// 从异常对象中拿到ObjectError对象
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
// 然后提取错误提示信息进行返回
return objectError.getDefaultMessage();
}
}

我们再来看下这次校验失败后的响应数据:

没错,这次返回的就是我们制定的错误提示信息!

以后我们再想写接口参数校验,就只需要在传参的成员变量上加上Validator校验规则的注解(比如 @NotNull(message = “用户id不能为空”)
,然后在参数上加上@Valid注解即可完成校验,校验失败会自动返回错误提示信息,无需任何其他代码!

4.3 自定义全局异常类
在很多情况下,有一些异常我们需要手动抛出,比如在业务层中有些条件并不符合业务逻辑,这时候就可以手动抛出异常从而触发事务回滚。
那手动抛出异常最简单的方式就是throw new RuntimeException(“异常信息”)了,不过使用自定义异常 规范会更好一些。

我们现在就来开始写一个自定义异常:继承 RuntimeException。

APIException.java:

@Getter //只要getter方法,无需setter
public class APIException extends RuntimeException {
private int code;
private String msg;

public APIException() {
this(1001, "接口错误");
}
public APIException(String msg) {
this(1001, msg);
}
public APIException(int code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
}
在刚才的全局异常处理类ExceptionControllerAdvice.java 中添加对我们自定义异常的声明(类似于注册原理):

@ExceptionHandler(APIException.class)
public String APIExceptionHandler(APIException e) {
return e.getMsg();
}

这样就无论发生什么异常我们都能屏蔽掉,然后响应数据给前端。

5. 统一结果响应
现在我们规范好了参数校验方式和全局异常,自定义异常的处理方式,然而还没有规范响应数据!

比如我要获取一个分页信息数据,获取成功了呢,自然就返回的数据列表,获取失败了后台就会响应异常信息,即一个字符串,就是说前端开发者压根就不知道后端响应过来的数据会是啥样的!所以,统一响应数据是前后端规范中必须要做的!

5.1 自定义统一响应体
在目前的前后端分离架构下,后端主要是一个RESTful API的数据接口。但是HTTP的状态码数量有限,而随着业务的增长,HTTP状态码无法很好地表示业务中遇到的异常情况。那么可以通过修改响应返回的JSON数据,让其带上一些固有的字段
其中关键属性的用途如下:

code为返回结果的状态码
msg为返回结果的消息
data为返回的业务数据
这3个属性为固有属性,每次响应结果都会有带有它们。

统一数据响应第一步肯定要做的就是我们自己自定义一个响应体类,无论后台是运行正常还是发生异常,响应给前端的数据格式是不变的!

自定义一个统一的响应体类,ResultVO.java:

@Getter
public class ResultVO {
/**
* 状态码,比如1000代表响应成功
*/
private int code;
/**
* 响应信息,用来说明响应情况
*/
private String msg;
/**
* 响应的具体数据
*/
private T data;

public ResultVO(T data) {
this(1000, "success", data);
}

public ResultVO(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}

然后我们修改一下全局异常处理那的return的返回值:

@ExceptionHandler(APIException.class)
public ResultVO APIExceptionHandler(APIException e) {
// 注意哦,这里返回类型是自定义响应体
return new ResultVO<>(e.getCode(), "响应失败", e.getMsg());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
// 注意哦,这里返回类型是自定义响应体
return new ResultVO<>(1001, "参数校验失败", objectError.getDefaultMessage());
}

我们再来看一下此时如果发生异常了会响应什么数据给前端:

(凡是你写了一个自定义的异常,就别忘了到接口那修改返回类型。)

OK,这个异常信息响应就非常好了,状态码和响应说明还有错误提示数据都返给了前端,并且是所有异常都会返回相同的格式!异常这里搞定了,别忘了我们到接口那也要修改返回类型。

5.2 控制层返回测试
新增一个接口好来看看效果,视图层使用统一结果。
在controller中新增方法:

@GetMapping("/getUser")
public ResultVO getUser() {
User user = new User();
user.setId(1L);
user.setAccount("12345678");
user.setPassword("12345678");
user.setEmail("123@qq.com");

return new ResultVO<>(user);
}

看一下如果响应正确返回的是什么效果:

这样无论是正确响应还是发生异常,响应数据的格式都是统一的,十分规范!

数据格式是规范了,不过响应码code和响应信息msg还没有规范呀!

6. 响应码枚举
要规范响应体中的响应码和响应信息用枚举简直再恰当不过了,我们现在就来创建一个响应码枚举类:

ResultCode.java:

@Getter
public enum ResultCode {

SUCCESS(1000, "操作成功"),
FAILED(1001, "响应失败"),
VALIDATE_FAILED(1002, "参数校验失败"),
ERROR(5000, "未知错误");

private int code;
private String msg;

ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
}

然后修改响应体的构造方法,让其只准接受响应码枚举来设置响应码和响应信息:

在ResultVO.java类中:

// 返回具体数据
public ResultVO(T data) {
this(ResultCode.SUCCESS, data);
}

// 返回对应的枚举响应码和响应信息,以及具体的数据。
public ResultVO(ResultCode resultCode, T data) {
this.code = resultCode.getCode();
this.msg = resultCode.getMsg();
this.data = data;
}

// 返回对应的枚举响应码和响应信息。
public ResultVO(ResultCode resultCode) {
this.code = resultCode.getCode();
this.msg = resultCode.getMsg();
}
然后同时修改全局异常处理的响应码设置方式:

@ExceptionHandler(APIException.class)
public ResultVO APIExceptionHandler(APIException e) {
// 注意哦,这里传递的响应码枚举
return new ResultVO<>(ResultCode.FAILED, e.getMsg());
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
// 注意哦,这里传递的响应码枚举
return new ResultVO<>(ResultCode.VALIDATE_FAILED, objectError.getDefaultMessage());
}

这样响应码和响应信息只能是枚举规定的那几个,就真正做到了响应数据格式、响应码和响应信息规范化、统一化!

7. 全局处理响应数据
接口返回统一响应体 + 异常也返回统一响应体,其实这样已经很好了,但还是有可以优化的地方。
要知道一个项目下来定义的接口搞个几百个太正常不过了,要是每一个接口返回数据时都要用响应体来包装一下(return new ResultVO<>)好像有点麻烦,有没有办法省去这个包装过程呢?当然是有滴,还是要用到全局处理。

7.1 响应增强类
Conrtoller增强的统一响应体处理类。

首先,先创建一个类加上注解使其成为全局处理类。然后继承ResponseBodyAdvice接口重写其中的方法,即可对我们的controller进行增强操作,具体看代码和注释。

新建ResponseControllerAdvice.java类:

package com.rudecrab.demo.config;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rudecrab.demo.enums.ResultCode;
import com.rudecrab.demo.exception.APIException;
import com.rudecrab.demo.vo.ResultVO;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import java.util.Map;

/**
* @description 全局处理响应数据--响应增强类
* 接口返回统一响应体 + 异常也返回统一响应体,
* 其实这样已经很好了,但还是有可以优化的地方。
*
* 先创建一个类加上注解使其成为全局处理类。
* 然后继承ResponseBodyAdvice接口重写其中的方法,
* 即可对我们的controller进行增强操作
*/

// 可以改为@ControllerAdvice注解来拦截所有Controller的处理结果
@RestControllerAdvice(basePackages = {"com.rudecrab.demo.controller"}) // 注意哦,这里要加上需要扫描的包
public class ResponseControllerAdvice implements ResponseBodyAdvice {

// 1.关于哪些请求要执行beforeBodyWrite,返回true执行,返回false不执行
@Override
public boolean supports(MethodParameter returnType, Class> aClass) {
// 如果接口返回的类型本身就是ResultVO那就没有必要进行额外的操作,返回false
System.out.println("=================="+!returnType.getParameterType().equals(ResultVO.class));
return !returnType.getParameterType().equals(ResultVO.class);
}

// 2.如果接口返回的类型本身不是ResultVO,那就将原本的数据包装在ResultVO里再返回
@Override
public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType mediaType, Class> aClass, ServerHttpRequest request, ServerHttpResponse response) {
// String类型不能直接包装,所以要进行些特别的处理
if (returnType.getParameterType().equals(String.class)) {
System.out.println(data instanceof String);
ObjectMapper objectMapper = new ObjectMapper();
try {
System.out.println("===========进入了包装数据======");
// 将String转换,再将数据包装在ResultVO里,再转换为json字符串响应给前端
return objectMapper.writeValueAsString(new ResultVO<>(data));
} catch (JsonProcessingException e) {
throw new APIException("返回String类型错误");
}
}
// 将原本的数据包装在ResultVO里
return new ResultVO<>(data);
}
}

注意:重写的这两个方法是用来在controller将数据进行返回前进行增强操作,supports方法要返回为true才会执行beforeBodyWrite方法,所以如果有些情况不需要进行增强操作可以在supports方法里进行判断。对返回数据进行真正的操作还是在beforeBodyWrite方法中,我们可以直接在该方法里包装数据,这样就不需要每个接口都进行数据包装了,省去了很多麻烦。

我们可以现在去掉接口的数据包装来看下效果:

@GetMapping("/getUser")
public User getUser() {
User user = new User();
user.setId(1L);
user.setAccount("12345678");
user.setPassword("12345678");
user.setEmail("123@qq.com");
// 注意哦,这里是直接返回的User类型,并没有用ResultVO进行包装
return user;
}
@ApiOperation("获得所有用户")
@GetMapping("/getAllUser")
public Map<String, List> getAllUser() {

User user1 = new User();
user1.setId(1L);
user1.setAccount("12345678");
user1.setPassword("12345678");
user1.setEmail("123@qq.com");

User user2 = new User();
user2.setId(2L);
user2.setAccount("9877986");
user2.setPassword("adasdasd");
user2.setEmail("456@qq.com");

List list = new ArrayList<>();
list.add(user1);
list.add(user2);

Map<String, List> map = new HashMap<>();
map.put("items", list);

Set keys = map.keySet(); //获取所有的key值
for(String key: keys){
System.out.println(key);
}
//用ResultVO进行包装了
//return new ResultVO<>(user);
// 这里是直接返回的map,没用ResultVO进行包装,beforeBodyWrite会自动增强操作,给我们包装起来。
return map;
}

然后我们来看下响应数据:

成功对数据进行了包装!

getUser功能流程:
前端发出接口请求》》controller处理请求》》同时ResponseControllerAdvice中添加了扫描控制层的全局异常处理类》》beforeBodyWrite规定了返回统一的结果体》》结果体ResultVO中定义了其内容包含有code,msg,data》》所以controller中处理的结果被增强操作,封装成了ResultVO中的格式》》由此达到了统一返回。

注意:上面方法里,beforeBodyWrite方法里包装数据无法对String类型的数据直接进行强转,所以要进行特殊处理。(注:只能捕获到Controller类里方法返回的结果)

特殊处理原因:
对于String类型springboot中默认会用org.springframework.http.converter.StringHttpMessageConverter处理,所以对String类型的数据是特殊情况,需特殊处理:

对于一个Controller中的方法来说,其返回值Data会被哪种MessageConverter处理取决于Data的类型:

对于@RestController下的方法来说,通常(除了String)的类型其返回的content-type=application/json,beforeBodyWrite返回值data将被converterType=MappingJackson2HttpMessageConverter处理

当Data为String类型时是特例,其content-type=text/plain,
converterType=StringHttpMessageConverter

可见,Data是String时converterType为StringHttpMessageConverter,若直接在beforeBodyWrite里将其包装为ResultVO类型的data,则会报错。

解决:如示例代码所示,根据converterType确定是否将data转为String类型。

8. 统一错误处理
SpringBoot 根据 HTTP 的请求头信息进行了不同的响应处理。

如果我们想,Web端发的请求则跳转到404,500或error等自定义页面,非Web端发的请求则返回统一JSON的结果。

8.1 自定义错误页面:
浏览器访问一个不存在的页面时,springboot会默认返回一个错误页面。

如果我们需要自定义错误页面,就只需要在模版文件夹Template下的 error 文件夹下放4xx 或 5xx.html的网页即可。

比如:

\[\[${status}\]\]

错误码:\[\[${status}\]\]

信息:\[\[${message}\]\]

时间:\[\[${#dates.format(timestamp,'yyyy-MM-dd hh:mm:ss ')}\]\]

请求路径:\[\[${path}\]\]