前言
一个后端接口大致分为四个部分组成:接口地址(url)、接口请求方式(get、post等)、请求数据(request)、响应数据(response)。如何构建这几个部分每个公司要求都不同,没有什么“一定是最好的”标准,但一个优秀的后端接口和一个糟糕的后端接口对比起来差异还是蛮大的,其中最重要的关键点就是看是否规范!
此文就一步一步演示如何构建起一个优秀的后端接口体系,体系构建好了自然就有了规范,同时再构建新的后端接口也会十分轻松。
开发环境
引入 POM 依赖:
新建一个 SpringBoot 工程,由于重点是讲解后端接口,所以只需要导入一个spring-boot-starter-web
包就可以了。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
需要统一做的步骤
需要统一做的步骤:
Validator
+ 自动抛出异常统一入参校验
一个接口一般对参数(请求数据)都会进行安全校验,参数校验的重要性自然不必多说,那么如何对参数进行校验就有讲究了。
传统的业务层校验
首先我们来看一下最常见的做法,就是在业务层进行参数校验:
public String addUser(UserVo userVo) {
if (null == userVo || null == userVo.getName() || null == userVo.getTelPhone()) {
return "对象或对象字段不能为空";
}
if (StringUtils.isEmpty(userVo.getName()) || StringUtils.isEmpty(userVo.getTelPhone())) {
return "不能输入空字符串";
}
if (!RegularUtil.isLegalTelPhone(userVo.getTelPhone())) {
return "输入的手机号不合法";
}
// 参数校验完毕后这里就写上业务逻辑
return "success";
}
这样做当然是没有什么错的,而且格式排版整齐也一目了然,不过这样太繁琐了,这还没有进行业务操作呢。仅是一个参数校验就已经这么多行代码,实在不够优雅。
我们来改进一下,使用 Spring Validator
和 Hibernate Validator
这两套 Validator 来进行方便的参数校验!这两套 Validator 依赖包已经包含在前面所说的 web 依赖包里了,所以可以直接使用。
Validator + BindResult 进行校验
Validator
可以非常方便的制定校验规则,并自动帮你完成校验。首先在入参里需要校验的字段加上注解,每个注解对应不同的校验规则,并可制定校验失败后的信息:
@Data
public class UserVo {
// 主键
@NotNull(message = "用户id不能为空")
private String id;
// 名称
@NotNull(message = "用户名称不能为空")
@Size(min = 6, max = 11, message = "名称长度必须是6-11个字符")
private String name;
// 联系方式
@NotNull(message = "联系方式不能为空")
private String telPhone;
}
校验规则和错误提示信息配置完毕后,接下来只需要在接口需要校验的参数上加上 @Valid
注解,并添加 BindResult
参数即可方便完成验证:
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
// 添加用户信息
@PostMapping("/addUser")
public String addUser(@RequestBody @Valid UserVo userVo, BindingResult bindingResult) {
// 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
for (ObjectError objectError : bindingResult.getAllErrors()) {
return objectError.getDefaultMessage();
}
return userService.addUser(userVo);
}
}
这样,当请求数据传递到接口的时候,Validator
就自动完成校验了,校验的结果就会封装到 BindingResult
中去。如果有错误信息,我们就直接返回给前端,业务逻辑代码也根本没有执行下去。
此时,业务层里的校验代码就已经不需要了,直接进行逻辑处理:
@Override
public String addUser(UserVo userVo) {
return "success";
}
现在可以看一下参数校验效果。我们故意给这个接口传递一个不符合校验规则的参数,先传递一个错误数据给接口,故意将 sName
这个字段不满足校验条件:
{
"sId": "1",
"sName": "zzc",
"sTelPhone": "13217"
}
再来看一下接口的响应数据:
这样是不是方便很多?不难看出使用Validator校验有如下几个好处:
使用 Validator+ BindingResult
已经是非常方便实用的参数校验方式了,在实际开发中也有很多项目就是这么做的,不过这样还是不太方便,因为你每写一个接口都要添加一个 BindingResult
参数,然后再提取错误信息返回给前端。
这样有点麻烦,并且重复代码很多(尽管可以将这个重复代码封装成方法)。我们能否去掉 BindingResult
这一步呢?当然是可以的!
Validator + 自动抛出异常
我们完全可以将BindingResult这一步给去掉:
@PostMapping("/addUser")
public String addUser(@RequestBody @Valid UserVo userVo) {
return userService.addUser(userVo);
}
去掉之后会发生什么事情呢?直接来试验一下,还是按照之前一样故意传递一个不符合校验规则的参数给接口。此时我们观察控制台可以发现接口已经引发 MethodArgumentNotValidException
异常了:
其实,这样就已经达到我们想要的效果了,参数校验不通过自然就不执行接下来的业务逻辑,去掉 BindingResult
后会自动引发异常。异常发生了后,自然而然地就不会执行业务逻辑。也就是说,我们完全没必要添加相关 BindingResult
相关操作。
不过事情还没有完,异常是引发了,可我们并没有编写返回错误信息的代码呀,那参数校验失败了会响应什么数据给前端呢?
我们来看一下刚才异常发生后接口响应的数据:
没错,是直接将整个错误对象相关信息都响应给前端了!这样就很难受,不过解决这个问题也很简单,就是我们接下来要讲的全局异常处理!
统一异常处理
参数校验失败会自动引发异常,我们当然不可能再去手动捕捉异常进行处理,不然还不如用之前 BindingResult
方式呢。
又不想手动捕捉这个异常,又要对这个异常进行处理,那正好使用SpringBoot
全局异常处理 来达到一劳永逸的效果!
基本使用
首先,我们需要新建一个类,在这个类上加上 @ControllerAdvice或@RestControllerAdvice
注解,这个类就配置成全局处理类了。(这个根据你的 Controller 层用的是 @Controller
还是 @RestController
来决定)
然后在类中新建方法,在方法上加上 @ExceptionHandler
注解并指定你想处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理!
我们现在就来演示一下对参数校验失败抛出的 MethodArgumentNotValidException
全局处理:
@RestControllerAdvice
public class UserExceptionControllerAdvice {
// 用户类入参校验异常
@ExceptionHandler(MethodArgumentNotValidException.class)
public String MethodArgumentNotValidException(MethodArgumentNotValidException exception) {
ObjectError objectError = exception.getBindingResult().getAllErrors().get(0);
return objectError.getDefaultMessage();
}
}
我们再来看下这次校验失败后的响应数据:
没错,这次返回的就是我们制定的错误提示信息!
我们通过全局异常处理优雅的实现了我们想要的功能!以后我们再想写接口参数校验,就只需要在入参的成员变量上加上 Validator
校验规则注解,然后在参数上加上 @Valid
注解即可完成校验,校验失败会自动返回错误提示信息,无需任何其他代码!
自定义异常
全局处理当然不会只能处理一种异常,用途也不仅仅是对一个参数校验方式进行优化。
在很多情况下,我们需要手动抛出异常,比如在业务层当有些条件并不符合业务逻辑,我这时候就可以手动抛出异常从而触发事务回滚。那手动抛出异常最简单的方式就是 throw new RuntimeException("异常信息")
了,不过使用自定义会更好一些:
我们现在就来开始写一个自定义异常:
@Getter
public class UserException extends RuntimeException {
// code
private Integer code;
// 异常消息
private String msg;
public UserException() {
this(1001, "接口错误");
}
public UserException(String msg) {
this(1001, msg);
}
public UserException(Integer code, String msg) {
super(msg);
this.code = code;
this.msg = msg;
}
}
在刚才的全局异常处理类中记得添加对我们自定义异常的处理:
@ExceptionHandler(UserException.class)
public String UserException(UserException exception) {
return exception.getMsg();
}
这样就对异常的处理就比较规范了,当然还可以添加对Exception的处理,这样无论发生什么异常我们都能屏蔽掉然后响应数据给前端。
现在全局异常处理和自定义异常已经弄好了,不知道大家有没有发现一个问题:就是当我们抛出自定义异常的时候全局异常处理只响应了异常中的错误信息 msg 给前端,并没有将错误代码 code 返回。这就要引申出我们接下来要讲的东西了:统一数据响应
统一数据响应
现在我们规范好了参数校验方式和异常处理方式,然而还没有规范响应数据!比如:我要获取一个分页信息数据,获取成功了呢自然就返回的数据列表,获取失败了后台就会响应异常信息,即一个字符串。就是说:前端开发者压根就不知道后端响应过来的数据会是啥样的!所以,统一响应数据是前后端规范中必须要做的!
自定义统一响应体
统一数据响应第一步肯定要做的就是我们自己自定义一个响应体类,无论后台是运行正常还是发生异常,响应给前端的数据格式是不变的!那么如何定义响应体呢?
可以参考我们自定义异常类,也来一个响应信息代码code和响应信息说明msg:
@Data
@NoArgsConstructor
public class ResultVo<T> {
// 状态码
private Integer code;
// 响应信息
private String msg;
// 响应数据
private T data;
public ResultVo(T data) {
this(1000, "success", data);
}
public ResultVo(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
然后我们修改一下全局异常处理那的返回值:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVo<String> MethodArgumentNotValidException(MethodArgumentNotValidException exception) {
ObjectError objectError = exception.getBindingResult().getAllErrors().get(0);
return new ResultVo<>(1001, "参数校验失败", objectError.getDefaultMessage());
}
// 自定义异常
@ExceptionHandler(UserException.class)
public ResultVo<String> UserException(UserException exception) {
return new ResultVo<>(exception.getCode(),"响应失败", exception.getMsg());
}
我们再来看一下此时如果发生异常了会响应什么数据给前端:
OK,这个异常信息响应就非常好了,状态码和响应说明还有错误提示数据都返给了前端,并且是所有异常都会返回相同的格式!异常这里搞定了,别忘了我们到接口那也要修改返回类型。
我们新增一个接口好来看看效果:
@GetMapping("/getUserByCondition")
public ResultVo<UserVo> getUserByCondition() {
UserVo userVo = new UserVo();
userVo.setId("2");
userVo.setName("zzc");
userVo.setTelPhone("15607");
return new ResultVo<>(userVo);
}
看一下如果响应正确返回的是什么效果:
这样无论是正确响应还是发生异常,响应数据的格式都是统一的,十分规范!
数据格式是规范了,不过响应码 code 和响应信息 msg 还没有规范呀!大家发现没有,无论是正确响应,还是异常响应,响应码和响应信息是想怎么设置就怎么设置,要是10个开发人员对同一个类型的响应写10个不同的响应码,那这个统一响应体的格式规范就毫无意义!所以,必须要将响应码和响应信息给规范起来。
统一响应码枚举
要规范响应体中的响应码和响应信息用枚举简直再恰当不过了,我们现在就来创建一个响应码枚举类:
@Getter
public enum ResultCodeEnum {
SUCCESS(1000, "操作成功")
,
FAILED(1001, "操作失败")
,
PARAM_VALIDATE_FAILED(1002, "参数校验失败")
,
ERROR(5000, "未知错误")
;
// 状态码
private Integer code;
// 状态信息
private String msg;
ResultCodeEnum(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
}
然后修改响应体的构造方法,让其只准接受响应码枚举来设置响应码和响应信息:
public ResultVo(T data) {
this(ResultCodeEnum.SUCCESS, data);
}
public ResultVo(ResultCodeEnum resultCodeEnum, T data) {
this.code = resultCodeEnum.getCode();
this.msg = resultCodeEnum.getMsg();
this.data = data;
}
然后同时修改全局异常处理的响应码设置方式:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVo<String> MethodArgumentNotValidException(MethodArgumentNotValidException exception) {
ObjectError objectError = exception.getBindingResult().getAllErrors().get(0);
return new ResultVo<>(ResultCodeEnum.PARAM_VALIDATE_FAILED, objectError.getDefaultMessage());
}
@ExceptionHandler(UserException.class)
public ResultVo<String> UserException(UserException exception) {
return new ResultVo<>(ResultCodeEnum.FAILED, exception.getMsg());
}
这样,响应码和响应信息只能是枚举规定的那几个,就真正做到了响应数据格式、响应码和响应信息规范化、统一化!
全局处理响应数据
接口返回统一响应体 + 异常也返回统一响应体,其实这样已经很好了,但还是有可以优化的地方。
要是每一个接口返回数据时都要用响应体来包装一下好像有点麻烦,那么,能不能省去 new ResultVo()
这个过程呢?当然是有滴!
新建一个响应体工具类,使用静态方法进行包装:
public class ResultVoUtil {
// 返回成功
public static ResultVo success(Object obj) {
ResultVo resultVo = new ResultVo();
resultVo.setCode(ResultCodeEnum.SUCCESS.getCode());
resultVo.setMsg(ResultCodeEnum.SUCCESS.getMsg());
resultVo.setData(obj);
return resultVo;
}
public static ResultVo success() {
return success(null);
}
// 返回错误
public static ResultVo error(Integer code, String msg) {
ResultVo resultVo = new ResultVo();
resultVo.setCode(code);
resultVo.setMsg(msg);
return resultVo;
}
}
然后,修改全局异常处理的响应码设置方式:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVo<String> MethodArgumentNotValidException(MethodArgumentNotValidException exception) {
ObjectError objectError = exception.getBindingResult().getAllErrors().get(0);
return ResultVoUtil.error(ResultCodeEnum.PARAM_VALIDATE_FAILED.getCode(), ResultCodeEnum.PARAM_VALIDATE_FAILED.getMsg());
}
@ExceptionHandler(UserException.class)
public ResultVo<String> UserException(UserException exception) {
return ResultVoUtil.error(ResultCodeEnum.FAILED.getCode(), ResultCodeEnum.FAILED.getMsg());
}
那么,接口改为:
public ResultVo<String> addUser(UserVo userVo) {
return ResultVoUtil.success(userVo);
}
要知道一个项目下来定义的接口搞个几百个太正常不过了,要是每一个接口返回数据时都要用响应体来包装一下好像有点麻烦,有没有办法省去这个包装过程呢?当然是有滴,还是要用到全局处理。
首先,先创建一个类加上注解使其成为全局处理类。然后实现ResponseBodyAdvice
接口重写其中的方法,即可对我们的 Controller 进行增强操作,具体看代码和注释:
@RestControllerAdvice(basePackages = {"com.zzc.elegant.controller"}) // 【注意】这里要加上需要扫描的包
public class ResponseControllerAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> aClass) {
// // 如果接口返回的类型本身就是ResultVo,那就没有必要进行额外的操作,返回false
return !returnType.getGenericParameterType().equals(ResultVo.class);
}
@Override
public Object beforeBodyWrite(Object o, MethodParameter returnType, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
// String类型不能直接包装,所以要进行些特别的处理
if (returnType.getGenericParameterType().equals(String.class)) {
ObjectMapper objectMapper = new ObjectMapper();
try {
// 将数据包装在ResultVo里后,再转换为json字符串响应给前端
return objectMapper.writeValueAsString(ResultVoUtil.success(o));
} catch (JsonProcessingException e) {
throw new UserException("返回String类型错误");
}
}
// 将原本的数据包装在ResultVO里
return ResultVoUtil.success(o);
}
}
重写的这两个方法是用来在 Controller 将数据进行返回前进行增强操作,supports()
方法要返回为 true,才会执行 beforeBodyWrite()
方法,所以,如果有些情况不需要进行增强操作可以在 supports()
方法里进行判断。对返回数据进行真正的操作还是在 beforeBodyWrite()
方法中,我们可以直接在该方法里包装数据,这样就不需要每个接口都进行数据包装了,省去了很多麻烦。
我们可以现在去掉接口的数据包装来看下效果:
@GetMapping("/getUserByCondition")
public UserVo getUserByCondition() {
UserVo userVo = new UserVo();
userVo.setId("2");
userVo.setName("zzc");
userVo.setTelPhone("15607");
return userVo;
}
然后我们来看下响应数据:
===========================================================================================
2022-07-23 更:
NOT统一响应
假如系统中存在某些接口的返回参数不需要使用 ResultVo
进行封装,返回什么就是什么。例如下面接口:
@GetMapping("/test2")
public String test2() {
return "test2";
}
我就想返回一个字符串 test2
给前台,该怎么做?
新增注解
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotControllerResponseAdvice {
}
修改:ResponseControllerAdvice
全局封装返回类:
@RestControllerAdvice(basePackages = {"com.zzc.elegant.controller"})
public class ResponseControllerAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> aClass) {
// // 如果接口返回的类型本身就是ResultVo,那就没有必要进行额外的操作,返回false
return !(returnType.getGenericParameterType().equals(ResultVo.class)
|| returnType.hasMethodAnnotation(NotControllerResponseAdvice.class));
}
...
}
总结
自此整个后端接口基本体系就构建完毕了
再次强调,项目体系该怎么构建、后端接口该怎么写都没有一个绝对统一的标准,不是说一定要按照本文的来才是最好的,你怎样都可以,此文每一个环节你都可以按照自己的想法来进行编码,我只是提供了一个思路!
===========================================================================================
2022-02-01 更:
统一日志记录
使用了 logback + slf4j
来实现日志的记录。
为什么使用 Logback:
开始使用
需要引入依赖:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-loggingartifactId>
dependency>
但是,实际开发中我们不需要直接添加该依赖,你会发现 spring-boot-starter
其中包含了 spring-boot-starter-logging
,该依赖内容就是 Spring Boot 默认的日志框架 Logback+SLF4J。而 spring-boot-starter-web
包含了 spring-boot-starte
,所以我们只需要引入web组件即可
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
所以,这里我们不需要再添加依赖了。
默认配置
默认情况下,SpringBoot 将日志输出到控制台,不会写到日志文件。如果要编写除控制台输出之外的日志文件,则需在 application.properties
中设置logging.file
或 logging.path
属性。如:
注:二者不能同时使用,如若同时使用,则只有logging.file生效
logging.file=文件名
logging.path=日志文件路径
logging.level.包名=指定包下的日志级别
logging.pattern.console=日志打印规则
注意:二者不能同时使用,如若同时使用,则只有 logging.file
生效,可以看到这种方式配置简单,但是能实现的功能也非常有限,如果想要更复杂的需求,就需要下面的定制化配置了
logback-spring.xml 详解
SpringBoot 官方推荐优先使用带有 -spring
的文件名作为你的日志配置(如使用 logback-spring.xml
,而不是 logback.xml
),命名为 logback-spring.xml
的日志配置文件,将 xml 放至 src/main/resource
下面
也可以使用自定义的名称,比如 logback-config.xml,只需要在 application.properties 文件中使用 logging.config=classpath:logback-config.xml 指定即可
logback-spring.xml
内容:
一般日志文件的内容都大同小异,网上有很多哈,弄到一份日志文件即可,后期直接复制-粘贴就行。下面就是我自己的日志文件内容:
<configuration>
<property name="pattern" value="[%-5level] %d{yyyy-mm-dd HH:mm:ss.SSS} %c %M %L [%thread] %m%n">property>
<property name="log_dir" value="E:/temp/logs">property>
<appender name="consoleLog" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<pattern>${pattern}pattern>
layout>
appender>
<appender name="fileInfoLog" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERRORlevel>
<onMatch>DENYonMatch>
<onMismatch>ACCEPTonMismatch>
filter>
<encoder>
<pattern>${pattern}pattern>
encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log_dir}/info/%d.logfileNamePattern>
rollingPolicy>
appender>
<appender name="fileErrorLog" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERRORlevel>
filter>
<encoder>
<pattern>${pattern}pattern>
encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${log_dir}/error/%d.logfileNamePattern>
rollingPolicy>
appender>
<root level="info">
<appender-ref ref="consoleLog" />
<appender-ref ref="fileInfoLog" />
<appender-ref ref="fileErrorLog" />
root>
configuration>
使用日志框架:
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/getUserByCondition")
public UserVo getUserByCondition() {
log.error("======================进入查询方法");
UserVo userVo = new UserVo();
userVo.setId("2");
userVo.setName("zzc");
userVo.setTelPhone("15607");
return userVo;
}
}
好了,使用统一日志记录就到这了。
===========================================================================================
2022-07-23 更:
统一日志收集异常信息
日志信息往往伴随着异常信息的输出,因此,我们需要修改统一异常的处理器,将异常信息以流的方式写到日志文件中:
异常信息文件工具类:
@Slf4j
public class ExceptionUtil {
/**
* 打印异常信息
*/
public static String getMessage(Exception e) {
String swStr = null;
try (StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw)) {
e.printStackTrace(pw);
pw.flush();
sw.flush();
swStr = sw.toString();
} catch (IOException ex) {
ex.printStackTrace();
log.error(ex.getMessage());
}
return swStr;
}
}
修改统一异常处理器,将异常方法中的直接打印改为日志输入并打印:
@Slf4j
@RestControllerAdvice
public class UserExceptionControllerAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVo<String> MethodArgumentNotValidException(MethodArgumentNotValidException exception) {
ObjectError objectError = exception.getBindingResult().getAllErrors().get(0);
return ResultVoUtil.error(ResultCodeEnum.PARAM_VALIDATE_FAILED.getCode(), ResultCodeEnum.PARAM_VALIDATE_FAILED.getMsg());
}
@ExceptionHandler(UserException.class)
public ResultVo<String> UserException(UserException exception) {
log.error(ExceptionUtil.getMessage(exception));
return ResultVoUtil.error(ResultCodeEnum.FAILED.getCode(), ResultCodeEnum.FAILED.getMsg());
}
}
【参考资料】:
Springboot 使用 logback 日志框架超详细教程