如何设计一个“正确”的后端接口

欢迎访问个人博客 德鲁大叔撸代码
一个后端接口正常情况下会包含①接口地址url②接口的请求方式(get/post)③请求数据④相应数据

在此记录一下如何构建一个完整的后端接口的过程
无论一个简单还是复杂的接口,无论是对外开放的接口还是http接口,参数校验是比不可少的,因为调用者每次调用传入的参数是未知的,那如果他传进来的参数是不符合规则的,我们就没必要去执行后面的业务逻辑,只有在客户(调用者)传入的参数是合法且有效的,才必要去执行业务逻辑,这样在一定程度上也减少了程序的压力。

参数校验

1、最基础的直接在业务层进行参数校验
比如下面一个新增用户的接口,直接在业务层进行参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping("add")
public String inserSysUser(SysUser sysUser){
if(StringUtils.isBlank(sysUser.getName()) ){
return "用户名不能为空";
}
if(sysUser.getName().length()<8 || sysUser.getName().length()>12){
return "账号必须是8~11个字符";
}
if(!Pattern.matches("^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$", sysUser.getEmail())){
return "邮箱格式不正确";
}
sysUserService.insert(sysUser);
log.info("添加用户成功");
return "success";
}

当我们输入可预知的参数时,运行结果图下图所示:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这样做的弊端在于,太过繁琐,当接口数量少且每个接口的请求参数数量少的时候,假如有100个接口并且每个接口有三四十个字段需要校验,如果用这种校验方法的话,会有很对的还重复代码,还没写业务逻辑呢就一大片的参数校验代码,使得我们的接口很繁琐。那有什么方法可以简化我们的代码呢。请看下面的Spring Validator和Hibernate Validator这两套Validator来进行参数校验

2、Validator + BindingResult进行校验
Validator可以自定义校验规则并且自动完成参数校验,想要它帮我们进行参数校验的前提是在需要被校验的字段上加上注解,当然了每个注解所对应的校验规则是不同的,与此同时,我们可以自定义验证失败后的提示信息
在使用它之前,先了解一下在使用它做参数校验的时候常用的注解有哪些:

@Null 限制只能为null
@NotNull 限制必须不为null
@AssertFalse 限制必须为false
@AssertTrue 限制必须为true
@DecimalMax(value) 限制必须为一个不大于指定值的数字
@DecimalMin(value) 限制必须为一个不小于指定值的数字
@Digits(integer,fraction) 限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction
@Future 限制必须是一个将来的日期
@Max(value) 限制必须为一个不大于指定值的数字
@Min(value) 限制必须为一个不小于指定值的数字
@Past 限制必须是一个过去的日期
@Pattern(value) 限制必须符合指定的正则表达式
@Size(max,min) 限制字符长度必须在min到max之间
@Past 验证注解的元素值(日期类型)比当前时间早
@NotEmpty 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@NotBlank 验证注解的元素值不为空(不为null、去除首位空格后长度为0),不同于@NotEmpty,@NotBlank只应用于字符串且在比较时会去除字符串的空格
@Email 验证注解的元素值是Email,也可以通过正则表达式和flag指定自定义的email格式

知道了常用的校验注解,下面是具体的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Data
public class SysUser implements Serializable {
private static final long serialVersionUID = 854274168569296410L;

private Long id;

@NotNull(message = "用户名不能为空")
@Size(min = 8,max =11,message ="用户名长度必须为8~11字符")
private String name;

@NotNull(message = "年龄不能为空")
private Integer age;

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

}

校验规则和错误信息提示配置完成后,需要在接口中需要校验的参数上加上@Valid注解,并添加BindingResult参数就可完成参数的校验:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("addSysUser")
public String inserSysUser(@Valid SysUser sysUser, BindingResult bindingResult){

// 有参数校验失败 将错误信息封装在BindingResult对象中返回
for(ObjectError error:bindingResult.getAllErrors()){
return error.getDefaultMessage();
}
sysUserService.insert(sysUser);
log.info("添加用户成功");
return "success";
}

这样当请求数据传递到接口的时候Validator就自动完成校验了,校验的结果就会封装到BindingResult中去,如果有错误信息我们就直接返回给前端,业务逻辑代码也根本没有执行下去。
测试传一个错误的参数(邮箱给一个错误的格式)
运行效果:
在这里插入图片描述

同直接在业务代码中做参数校验相比,利用Validator进行参数校验有以下好处:
1.使用方便,只需要一个注解就可以完成参数校验并指定校验失败的提示信息
2.简化代码,有眼可见的简洁,避免了一大堆的if代码块
3.减少耦合度,使用Validator让业务层的代码只关注与业务逻辑,剔除基本的参数校验

使用Validator + BindingResult大部分情况下已经可以满足我们的需求了,不过这样做的话,需要在每个接口中后都添加一个BindingResult参数,
然后通过BindingResult把错误的信息提示给前端。这样你会发现,每个接口都有这个参数,又有一大堆的重复代码,那可否想一种解决办法去掉每个接口中的BindingResult这个参数呢?请往下看!!

3、Validator+自动抛出异常
在上面的代码中去掉BindingResult参数,去掉之后会发生什么事儿呢?还是上面的校验方法,给邮箱一个错误的格式,运行代码,效果如下:

1
2
3
4
5
6
@PostMapping("addSysUser")
public String insert(@Valid SysUser sysUser){
sysUserService.insert(sysUser);
log.info("添加用户成功");
return "success";
}

在这里插入图片描述
这样就已经达到我们想要的效果了,参数校验不通过自然就不执行接下来的业务逻辑,去掉BindingResult后会自动引发异常,异常发生了自然而然就不会执行业务逻辑。也就是说,我们完全没必要添加相关BindingResult相关操作。不过事情还没有完,异常是引发了,可我们并没有编写返回错误信息的代码,那参数校验失败了会响应什么数据给前端呢?

我们来看一下刚才异常发生后接口响应的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"timestamp": "2020-04-08T06:26:30.305+0000",
"status": 400,
"error": "Bad Request",
"errors": [
{
"codes": [
"Email.sysUser.email",
"Email.email",
"Email.java.lang.String",
"Email"
],
"arguments": [
{
"codes": [
"sysUser.email",
"email"
],
"arguments": null,
"defaultMessage": "email",
"code": "email"
},
[],
{
"arguments": null,
"defaultMessage": ".*",
"codes": [
".*"
]
}
],
"defaultMessage": "邮箱格式不正确",
"objectName": "sysUser",
"field": "email",
"rejectedValue": "1111",
"bindingFailure": false,
"code": "Email"
}
],
"message": "Validation failed for object='sysUser'. Error count: 1",
"trace": "org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors\nField error in object 'sysUser' on field 'email': rejected value [1111]; codes [Email.sysUser.email,Email.email,Email.java.lang.String,Email]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [sysUser.email,email]; arguments []; default message [email],[Ljavax.validation.constraints.Pattern$Flag;@fe2742e,.*]; default message [邮箱格式不正确]\r\n\tat org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:164)\r\n\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)\r\n\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:167)\r\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:134)\r\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106)\r\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:879)\r\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:793)\r\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\r\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)\r\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)\r\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\r\n\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)\r\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:660)\r\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\r\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:741)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\r\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\r\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:109)\r\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\r\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\r\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\r\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\r\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)\r\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)\r\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)\r\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\r\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\r\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)\r\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:367)\r\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\r\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:868)\r\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1639)\r\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\r\n\tat java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)\r\n\tat java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)\r\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\r\n\tat java.lang.Thread.run(Thread.java:745)\r\n",
"path": "/sysUser/addSysUser"
}

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

全局异常处理

参数校验失败会自动引发异常,我们当然不可能再去手动捕捉异常进行处理,不然还不如用之前BindingResult方式呢。又不想手动捕捉这个异常,又要对这个异常进行处理,那正好使用SpringBoot全局异常处理来达到一劳永逸的效果!

首先需要创建一个新的类,在类上添加@ControllerAdvice或者@RestControllerAdvice注解,该类就配置成了全局处理类了
然后再类中新建方法,给方法加上@ExceptionHandler注解并指定我们想要处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestControllerAdvice
@Slf4j
public class ExceptionControllerAdvice {

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

}

自定义异常

在很多情况下,我们需要手动抛出异常,比如在业务层当有些条件不符合业务逻辑的时候,我们就可以手动抛出异常。
自定义一个异常类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
@Getter
public class BizException extends RuntimeException {

private String code;
private String desc;

public BizException(String code,String desc){

this.code = code;
this.desc = desc;
}

}

在上面的全局异常处理类中添加对自定义异常类的处理:

1
2
3
4
@ExceptionHandler(BizException.class)
public String BizException(BizException e){
return e.getMessage();
}

数据统一响应

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

1、自定义统一响应体
统一数据响应第一步肯定要做的就是我们自己自定义一个响应体类,无论后台是运行正常还是发生异常,响应给前端的数据格式是不变的!那么如何定义响应体呢?关于异常的设计:如何更优雅的设计异常可以参考我们自定义异常类,也来一个响应信息代码code和响应信息说明desc。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Getter
public class ResultVO<T> {
private String code;
private String desc;
//响应数据
private T data;
public ResultVO(T data){
this("1000","success",data);
}
public ResultVO(String code, String desc, T data) {
this.code = code;
this.desc = desc;
this.data = data;
}
}

有了上面的统一响应数据,只需要把之前的全局异常中的返回值做如下修改即可:

1
2
3
4
@ExceptionHandler(BizException.class)
public ResultVO BizException(BizException e){
return new ResultVO(e.getCode(),"响应失败",e.getDesc());
}

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

1
2
3
4
5
6
@PostMapping("addSysUser")
public ResultVO<SysUser> insert(@Valid SysUser sysUser){
sysUserService.insert(sysUser);
log.info("添加用户成功");
return new ResultVO<>(sysUser);
}

在这里插入图片描述
这样无论是正确响应还是发生异常,响应数据的格式都是统一的,十分规范!
数据格式是规范了,不过响应码code和响应信息desc还没有规范呀!大家发现没有,无论是正确响应,还是异常响应,响应码和响应信息是想怎么设置就怎么设置,要是10个开发人员对同一个类型的响应写10个不同的响应码,那这个统一响应体的格式规范就毫无意义!所以,必须要将响应码和响应信息给规范起来。那接下来就该请出响应码枚举了!

响应码枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Getter
public enum ResultEnum {
SUCCESS("1000","请求成功"),
FAILED("1001","请求失败"),
PARAM_FAIL("1002","参数校验失败"),
ERROR("1003","请求异常"),
;
private String code;
private String desc;
ResultEnum(String code,String desc){
this.code = code;
this.desc = desc;
}
}

然后同时修改全局异常处理的响应码设置方式:

1
2
3
4
@ExceptionHandler(BizException.class)
public ResultVO BizException(BizException e){
return new ResultVO(ResultEnum.PARAM_FAIL,e.getDesc());
}

同时需要修改统一响应方法如下:

1
2
3
4
5
public ResultVO(ResultEnum resultEnum,T data){
this.code = resultEnum.getCode();
this.desc = resultEnum.getDesc();
this.data = data;
}

这样响应码和响应信息只能是枚举规定的那几个,就真正做到了响应数据格式、响应码和响应信息规范化、统一化!这些可以参考:Java项目构建基础:统一结果,统一异常,统一日志

全局处理响应数据

接口返回统一响应体 + 异常也返回统一响应体,其实这样已经很好了,但还是有可以优化的地方。要知道一个项目下来定义的接口搞个几百个太正常不过了,要是每一个接口返回数据时都要用响应体来包装一下好像有点麻烦,有没有办法省去这个包装过程呢?当然是有滴,还是要用到全局处理。
首先,先创建一个类加上注解使其成为全局处理类。然后继承ResponseBodyAdvice接口重写其中的方法,即可对我们的controller进行增强操作,具体看代码和注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RestControllerAdvice(basePackages = {"com.kyriemtx.easycode.controller"})
public class ResponseControllerAdvice implements ResponseBodyAdvice<Object> {

@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// 如果接口返回的类型本身就是ResultVO那就没有必要进行额外的操作,返回false
return !methodParameter.getGenericParameterType().equals(ResultVO.class);
}

@Override
public Object beforeBodyWrite(Object data, MethodParameter methodParameter, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
// String类型不能直接包装,所以要进行些特别的处理
if (methodParameter.getGenericParameterType().equals(String.class)) {
ObjectMapper objectMapper = new ObjectMapper();
try {
// 将数据包装在ResultVO里后,再转换为json字符串响应给前端
return objectMapper.writeValueAsString(new ResultVO<>(data));
} catch (JsonProcessingException e) {
throw new BizException("返回String类型错误");
}
}
// 将原本的数据包装在ResultVO里
return new ResultVO<>(data);
}
}

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

1
2
3
4
@GetMapping("selectOne")
public SysUser selectOne(Long id) {
return this.sysUserService.queryById(id);
}

在这里插入图片描述

坚持原创技术分享,您的支持将鼓励我继续创作!