Api-Specification
一、前言
一个后端接口大致分为四个部分组成:接口地址(url)、接口请求方式(get、post等)、请求数据(request)、响应数据(response)。虽然说后端接口的编写并没有统一规范要求,而且如何构建这几个部分每个公司要求都不同, 没有什么“一定是最好的”标准,但其中最重要的关键点就是看是否规范。
二、环境说明
因为讲解的重点是后端接口,所以需要导入一个 spring-boot-starter-web 包,而 lombok 作用是简化类,前端显示则使用了 knife4j,具体使用在 Spring Boot 整合 knife4j 实现 API 文档已写明。另外从 springboot-2.3 开始,校验包被独立成了一个 starter 组件,所以需要引入如下依赖:
<dependency>
<!--新版框架没有自动引入需要手动引入-->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<!--在引用时请在maven中央仓库搜索最新版本号-->
<version>2.0.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
三、参数校验
3.1 介绍
一个接口一般对参数(请求数据)都会进行安全校验,参数校验的重要性自然不必多说,那么如何对参数进行校验就有讲究了。一般来说有三种常见的校验方式,我们使用了最简洁的第三种方法:
业务层校验 Validator + BindResult 校验 Validator + 自动抛出异常
业务层校验无需多说,即手动在 Java 的 Service 层进行数据校验判断。不过这样太繁琐了,光校验代码就会有很多。
而使用 Validator+ BindingResult 已经是非常方便实用的参数校验方式了,在实际开发中也有很多项目就是这么做的,不过这样还是不太方便,因为你每写一个接口都要添加一个 BindingResult 参数,然后再提取错误信息返回给前端(简单看一下)。
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user, BindingResult bindingResult) {
// 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
List<ObjectError> allErrors = bindingResult.getAllErrors();
if(!allErrors.isEmpty()){
return allErrors.stream()
.map(o->o.getDefaultMessage())
.collect(Collectors.toList()).toString();
}
// 返回默认的错误信息
// return allErrors.get(0).getDefaultMessage();
return validationService.addUser(user);
}
3.2 Validator + 自动抛出异常(使用)
内置参数校验如下:
图片
首先,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 注解(去掉 BindingResult 后会自动引发异常,异常发生了自然而然就不会执行业务逻辑):
@RestController
@RequestMapping("user")
public class ValidationController {
@Autowired
private ValidationService validationService;
@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user) {
return validationService.addUser(user);
}
}
现在我们进行测试,打开 knife4j 文档地址,当输入的请求数据为空时,Validator 会将所有的报错信息全部进行返回,所以需要与全局异常处理一起使用。
// 使用form data方式调用接口,校验异常抛出 BindException
// 使用 json 请求体调用接口,校验异常抛出 MethodArgumentNotValidException
// 单个参数校验异常抛出ConstraintViolationException
// 处理 json 请求体调 用接口校验失败抛出的异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO<String> MethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
return new ResultVO(ResultCode.VALIDATE_FAILED, collect);
}
// 使用form data方式调用接口,校验异常抛出 BindException
@ExceptionHandler(BindException.class)
public ResultVO<String> BindException(BindException e) {
List<FieldError> fieldErrors = e.getBindingResult().getFieldErrors();
List<String> collect = fieldErrors.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
return new ResultVO(ResultCode.VALIDATE_FAILED, collect);
}
图片
3.3 分组校验和递归校验
分组校验有三个步骤:
- 定义一个分组类(或接口)
- 在校验注解上添加 groups 属性指定分组
- Controller 方法的 @Validated 注解添加分组类
public interface Update extends Default{
}
@Data
public class User {
@NotNull(message = "用户id不能为空",groups = Update.class)
private Long id;
......
}
@PostMapping("update")
public String update(@Validated({Update.class}) User user) {
return "success";
}
如果Update不继承Default,@Validated({Update.class})就只会校验属于Update.class分组的参数字段;如果继承了,会校验了其他默认属于Default.class分组的字段。
对于递归校验(比如类中类),只要在相应 属性类上增加@Valid注解即可实现(对于集合同样适用)
3.4 自定义校验
Spring Validation 允许用户自定义校验,实现很简单,分两步:
- 自定义校验注解
- 编写校验者类
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {HaveNoBlankValidator.class})// 标明由哪个类执行校验逻辑
public @interface HaveNoBlank {
// 校验出错时默认返回的消息
String message() default "字符串中不能含有空格";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
/**
* 同一个元素上指定多个该注解时使用
*/
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
public @interface List {
NotBlank[] value();
}
}
public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// null 不做检验
if (value == null) {
return true;
}
// 校验失败
return !value.contains(" ");
// 校验成功
}
}
四、全局异常处理
参数校验失败会自动引发异常,我们当然不可能再去手动捕捉异常进行处理。但我们又不想手动捕捉这个异常,又要对这个异常进行处理,那正好使用 SpringBoot 全局异常处理来达到一劳永逸的效果!
4.1 基本使用
首先,我们需要新建一个类,在这个类上加上 @ControllerAdvice 或 @RestControllerAdvice 注解,这个类就配置成全局处理类了。
这个根据你的 Controller 层用的是 @Controller 还是 @RestController 来决定。
然后在类中新建方法,在方法上加上 @ExceptionHandler 注解并指定你想处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理!我们现在就来演示一下对参数校验失败抛出的 MethodArgumentNotValidException 全局处理:
package com.csdn.demo1.global;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@ResponseBody
public class ExceptionControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public String MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
// 从异常对象中拿到ObjectError对象
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
// 然后提取错误提示信息进行返回
return objectError.getDefaultMessage();
}
/**
* 系统异常 预期以外异常
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public ResultVO<?> handleUnexpectedServer(Exception ex) {
log.error("系统异常:", ex);
// GlobalMsgEnum.ERROR是我自己定义的枚举类
return new ResultVO<>(GlobalMsgEnum.ERROR);
}
/**
* 所以异常的拦截
*/
@ExceptionHandler(Throwable.class)
@ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
public ResultVO<?> exception(Throwable ex) {
log.error("系统异常:", ex);
return new ResultVO<>(GlobalMsgEnum.ERROR);
}
}
我们再次进行测试,这次返回的就是我们制定的错误提示信息!我们通过全局异常处理优雅的实现了我们想要的功能!
以后我们再想写接口参数校验,就只需要在入参的成员变量上加上 Validator 校验规则注解,然后在参数上加上 @Valid 注解即可完成校验,校验失败会自动返回错误提示信息,无需任何其他代码!
图片
4.2 自定义异常
在很多情况下,我们需要手动抛出异常,比如在业务层当有些条件并不符合业务逻辑,而使用自定义异常有诸多优点:
自定义异常可以携带更多的信息,不像这样只能携带一个字符串。 项目开发中经常是很多人负责不同的模块,使用自定义异常可以统一了对外异常展示的方式。 自定义异常语义更加清晰明了,一看就知道是项目中手动抛出的异常。
我们现在就来开始写一个自定义异常:
package com.csdn.demo1.global;
import lombok.Getter;
@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;
}
}
然后在刚才的全局异常类中加入如下:
//自定义的全局异常
@ExceptionHandler(APIException.class)
public String APIExceptionHandler(APIException e) {
return e.getMsg();
}
这样就对异常的处理就比较规范了。当然还可以添加对 Exception 的处理,这样无论发生什么异常我们都能屏蔽掉然后响应数据给前端,不过建议最后项目上线时这样做,能够屏蔽掉错误信息暴露给前端,在开发中为了方便调试还是不要这样做。
另外,当我们抛出自定义异常的时候全局异常处理只响应了异常中的错误信息 msg 给前端,并没有将错误代码 code 返回。这还需要配合数据统一响应。
如果在多模块使用,全局异常等公共功能抽象成子模块,则在需要的子模块中需要将该模块包扫描加入,@SpringBootApplication(scanBasePackages = com.xxx)。
五、数据统一响应
统一数据响应是我们自己自定义一个响应体类,无论后台是运行正常还是发生异常,响应给前端的数据格式是不变的!这里我包括了响应信息代码 code 和响应信息说明 msg,首先可以设置一个枚举规范响应体中的响应码和响应信息。
@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;
}
}
自定义响应体:
package com.csdn.demo1.global;
import lombok.Getter;
@Getter
public class ResultVO<T> {
/**
* 状态码,比如1000代表响应成功
*/
private int code;
/**
* 响应信息,用来说明响应情况
*/
private String msg;
/**
* 响应的具体数据
*/
private T data;
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;
}
}
最后需要修改全局异常处理类的返回类型:
@RestControllerAdvice
public class ExceptionControllerAdvice {
@ExceptionHandler(APIException.class)
public ResultVO<String> APIExceptionHandler(APIException e) {
// 注意哦,这里传递的响应码枚举
return new ResultVO<>(ResultCode.FAILED, e.getMsg());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO<String> MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
// 注意哦,这里传递的响应码枚举
return new ResultVO<>(ResultCode.VALIDATE_FAILED, objectError.getDefaultMessage());
}
}
最后,在 controller 层进行接口信息数据的返回:
@GetMapping("/getUser")
public ResultVO<User> getUser() {
User user = new User();
user.setId(1L);
user.setAccount("12345678");
user.setPassword("12345678");
user.setEmail("123@qq.com");
return new ResultVO<>(user);
}
经过测试,这样响应码和响应信息只能是枚举规定的那几个,就真正做到了响应数据格式、响应码和响应信息规范化、统一化!
图片
还有一种全局返回类如下:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Msg {
//状态码
private int code;
//提示信息
private String msg;
//用户返回给浏览器的数据
private Map<String,Object> data = new HashMap<>();
public static Msg success() {
Msg result = new Msg();
result.setCode(200);
result.setMsg("请求成功!");
return result;
}
public static Msg fail() {
Msg result = new Msg();
result.setCode(400);
result.setMsg("请求失败!");
return result;
}
public static Msg fail(String msg) {
Msg result = new Msg();
result.setCode(400);
result.setMsg(msg);
return result;
}
public Msg(ReturnResult returnResult){
code = returnResult.getCode();
msg = returnResult.getMsg();
}
public Msg add(String key,Object value) {
this.getData().put(key, value);
return this;
}
}
六、全局处理响应数据(可选择)
接口返回统一响应体 + 异常也返回统一响应体,其实这样已经很好了,但还是有可以优化的地方。要知道一个项目下来定义的接口搞个几百个太正常不过了,要是每一个接口返回数据时都要用响应体来包装一下好像有点麻烦,有没有办法省去这个包装过程呢?
当然是有的,还是要用到全局处理。但是为了扩展性,就是允许绕过数据统一响应(这样就可以提供多方使用),我们可以自定义注解,利用注解来选择是否进行全局响应包装。
首先,创建自定义注解,作用相当于全局处理类开关:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD}) // 表明该注解只能放在方法上
public @interface NotResponseBody {
}
其次,创建一个类并加上注解使其成为全局处理类。然后继承 ResponseBodyAdvice 接口重写其中的方法,即可对我们的 controller 进行增强操作,具体看代码和注释:
package com.csdn.demo1.global;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
@RestControllerAdvice(basePackages = {"com.scdn.demo1.controller"}) // 注意哦,这里要加上需要扫描的包
public class ResponseControllerAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> aClass) {
// 如果接口返回的类型本身就是ResultVO那就没有必要进行额外的操作,返回false
// 如果方法上加了我们的自定义注解也没有必要进行额外的操作
return !(returnType.getParameterType().equals(ResultVO.class) || returnType.hasMethodAnnotation(NotResponseBody.class));
}
@Override
public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest request, ServerHttpResponse response) {
// String类型不能直接包装,所以要进行 些特别的处理
if (returnType.getGenericParameterType().equals(String.class)) {
ObjectMapper objectMapper = new ObjectMapper();
try {
// 将数据包装在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 方法中,我们可以直接在该方法里包装数据,这样就不需要每个接口都进行数据包装了,省去了很多麻烦。此时 controller 只需这样写就行了:
@GetMapping("/getUser")
//@NotResponseBody //是否绕过数据统一响应开关
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;
}
七、接口版本控制
7.1 简介
在 SpringBoot 项目中,如果要进行 restful 接口的版本控制一般有以下几个方向:
- 基于 path 的版本控制
- 基于 header 的版本控制
在 Spring MVC下,url 映射到哪个 method 是由 RequestMappingHandlerMapping 来控制的,那么我们也是通过 RequestMappingHandlerMapping 来做版本控制的。
7.2 Path 控制实现
首先定义一个注解:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
// 默认接口版本号1.0开始,这里我只做了两级,多级可在正则进行控制
String value() default "1.0";
}
ApiVersionCondition 用来控制当前 request 指向哪个 method:
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final Pattern VERSION_PREFIX_PATTERN = Pattern.compile("v(\\d+\\.\\d+)");
private final String version;
public ApiVersionCondition(String version) {
this.version = version;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
return new ApiVersionCondition(other.getApiVersion());
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
Matcher m = VERSION_PREFIX_PATTERN.matcher(httpServletRequest.getRequestURI());
if (m.find()) {
String pathVersion = m.group(1);
// 这个方法是精确匹配
if (Objects.equals(pathVersion, version)) {
return this;
}
// 该方法是只要大于等于最低接口version即匹配成功,需要和compareTo()配合
// 举例:定义有1.0/1.1接口,访问1.2,则实际访问的是1.1,如果从小开始那么排序反转即可
// if(Float.parseFloat(pathVersion)>=Float.parseFloat(version)){
// return this;
// }
}
return null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
return 0;
// 优先匹配最新的版本号,和getMatchingCondition注释掉的代码同步使用
// return other.getApiVersion().compareTo(this.version);
}
public String getApiVersion() {
return version;
}
}
PathVersionHandlerMapping 用于注入 Spring 用来管理:
public class PathVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType,ApiVersion.class);
return createCondition(apiVersion);
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
ApiVersion apiVersion = AnnotationUtils.findAnnotation(method,ApiVersion.class);
return createCondition(apiVersion);
}
private RequestCondition<ApiVersionCondition>createCondition(ApiVersion apiVersion) {
return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
}
}
WebMvcConfiguration 配置类让 Spring 来接管:
@Configuration
public class WebMvcConfiguration implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
return new PathVersionHandlerMapping();
}
}
最后,controller进行测试,默认是 v1.0,如果方法上有注解,以方法上的为准(该方法 vx.x 在路径任意位置出现都可解析)。
@RestController
@ApiVersion
@RequestMapping(value = "/{version}/test")
public class TestController {
@GetMapping(value = "one")
public String query(){
return "test api default";
}
@GetMapping(value = "one")
@ApiVersion("1.1")
public String query2(){
return "test api v1.1";
}
@GetMapping(value = "one")
@ApiVersion("3.1")
public String query3(){
return "test api v3.1";
}
}
7.3 header 控制实现
总体原理与 Path 类似,修改 ApiVersionCondition 即可,之后访问时在 header 带上 X-VERSION 参数即可。
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private static final String X_VERSION = "X-VERSION";
private final String version ;
public ApiVersionCondition(String version) {
this.version = version;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
return new ApiVersionCondition(other.getApiVersion());
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
String headerVersion = httpServletRequest.getHeader(X_VERSION);
if(Objects.equals(version,headerVersion)){
return this;
}
return null;
}
@Override
public int compareTo(ApiVersionCondition apiVersionCondition, HttpServletRequest httpServletRequest) {
return 0;
}
public String getApiVersion() {
return version;
}
}