Spring Boot 博客后端实践:统一接口返回
一、为什么要做统一接口返回
首先要明确:什么是统一接口返回?
所谓统一接口返回,指的是一种规范,当后端返回给前端数据时,不是像这样:
{
"id": 1,
"username": "tom"
}
而是像这样:
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"username": "tom"
}
}
可以看到,后端返回被规范为了3部分:code、message、data,其中code表示业务状态码,通常是你自定义的,message是对该状态的描述信息,data才是真正返回的数据。
为什么要这么搞?首先肯定是为了前后端解耦合,前端只需要认:code、message、data,不用关心每个接口结构细节;其次是为了统一错误处理,比如登陆过期、权限不足、参数错误,统一返回:
{
"code": 401,
"message": "未登录",
"data": null
}
然后前端统一处理:
if (res.code === 401) {
// 全局拦截
}
闲话少说,让我们看看这种规范的返回体是怎么应用于后端的。
二、成功响应设计:Result<T>
后端返回给前端的数据不能是零散的,必须有一个统一的规范,便于前端抓取;而这个统一的规范就是返回体Result<T>,定义如下:
public class Result<T> {
private int code;
private String message;
private T data;
}
当然实际的Result<T>肯定不会这么简单,比如:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
public static <T> Result<T> success() {
return success(null);
}
public static <T> Result<T> failed(String message) {
return new Result<>(ResultCode.ERROR.getCode(), message, null);
}
public static <T> Result<T> failed(ResultCode resultCode, String message) {
return new Result<>(resultCode.getCode(), message, null);
}
}
可以看到它比基础的Result<T>多了几个注解和方法,让我们从头来解析这个Result<T>类。
类名
Result<T>中的<T>是什么意思?<T> 是泛型的写法,本质是让类型“参数化”,也就是说类型也可以像参数一样传进去。在Result<T>中,这里的 <T> 表示data的类型不固定,可以由调用者决定;举以下例子:
返回用户信息:Result<UserDTO>
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"username": "tom"
}
}
不返回数据:Result<Void>
{
"code": 200,
"message": "success",
"data": null
}
Result<T>是怎么转换为json的?
按理说,一个对象不会自己变成json,那为什么返回给前端的数据是json格式呢?
这里其实是 Spring Boot 自动帮你做的,核心组件是Jackson,Spring Boot 默认用Jackson(JSON 序列化库),将Result对象转换为JSON字符串。
成员变量
虽然前文说过了,但再强调一下:code表示业务状态码,message表示对该状态的描述信息,data表示返回的数据。
方法
主要的方法有success(T data),success(),failed(String message),failed(ResultCode resultCode, String message),最后一个ResultCode 对象是一个枚举类,内容如下:
import lombok.Getter;
@Getter
public enum ResultCode {
SUCCESS(200, "success"),
BAD_REQUEST(400, "bad request"),
UNAUTHORIZED(401, "unauthorized"),
FORBIDDEN(403, "forbidden"),
NOT_FOUND(404, "not found"),
ERROR(500, "internal server error");
private final int code;
private final String message;
ResultCode(int code, String message) {
this.code = code;
this.message = message;
}
}
可以看到每一条code都对应着一条message,当我们在外部调用 Result.success(data) 时,方法内部会自动使用 ResultCode.SUCCESS 中定义的 code和 message,从而完成统一响应封装。
三、分页响应设计:PageResult<T>
当后端向前端展示分页内容时,必须要做分页封装,这里的PageResult<T>就是一个很好的例子。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
private Long total;
private Long pageNum;
private Long pageSize;
private List<T> list;
public static <T> PageResult<T> of(IPage<T> page) {
return new PageResult<>(page.getTotal(), page.getCurrent(), page.getSize(), page.getRecords());
}
public static <T> PageResult<T> empty(long pageNum, long pageSize) {
return new PageResult<>(0L, pageNum, pageSize, Collections.emptyList());
}
}
它一般不会单独返回,而是封装在Result里:
Result<PageResult<UserVO>>
最终的json大概是:
{
"code": 200,
"message": "success",
"data": {
"total": 100,
"pageNum": 1,
"pageSize": 10,
"list": [
{
"id": 1,
"username": "admin"
}
]
}
}
让我们来解析这个类。
类名
PageResult<T>这没什么好说的,不过是泛型的再一次运用。
成员变量
total:表示总记录数。比如数据库中一共有 123 篇文章,即使当前页只查 10 条,total 仍然是 123。
pageNum:表示当前页码。比如前端请求:
?pageNum=2&pageSize=10
那么这里就是第 2 页。
pageSize:表示每页条数。比如一页查 10 条,这里就是 10。
list:表示表示当前页的数据列表。
例如:
PageResult<UserVO>
那么:
private List<UserVO> list;
如果是:
PageResult<ArticleVO>
那么:
private List<ArticleVO> list;
方法
这两个方法从名字上看有点不知所云,一个of一个empty是啥意思?首先来看 of 方法:
public static <T> PageResult<T> of(IPage<T> page) {
return new PageResult<>(
page.getTotal(),
page.getCurrent(),
page.getSize(),
page.getRecords()
);
}
它的作用是:把 MyBatis-Plus 的分页对象 IPage<T> 转成自己的 PageResult<T>。有人可能有疑惑:为什么不直接返回 MyBatis-Plus 的IPage<T>,还要自己封装一层呢?这其实是设计层面的考虑,直接返回 IPage 会产生如下问题:
- 暴露 MyBatis-Plus(框架耦合)
- 字段名不符合前端习惯
- 后期不好扩展
因此这里定义一个面向前端的PageResult<T>,通过PageResult.of(IPage<T> page) 把 MyBatis-Plus 的分页结果转换成自己的响应结构。实际使用如下:
public PageResult<User> listUsers(long pageNum, long pageSize) {
Page<User> page = new Page<>(pageNum, pageSize);
IPage<User> result = userMapper.selectPage(page, null);
return PageResult.of(result);
}
这里的IPage<User> result = userMapper.selectPage(page, null); 用到了面向接口编程,因为IPage<T>实际上是一个接口,userMapper.selectPage(page, null)返回的是一个实现类对象(实际上是 Page<User>);所谓面向接口编程,意思是接口不能 new,但可以接收实现类对象,比如说:
UserService userService = new UserService(); // × 编译错误,不能new接口
UserService userService = new UserServiceImpl(); // √
这里的userService可以调用UserServiceImpl和UserService中的公共方法,即UserServiceImpl重写方法,而UserServiceImpl的特有方法则无法调用。
什么是
Page<T>?什么是IPage<T>?这两者有什么区别?
Page<T>是 MyBatis-Plus 提供的一个分页对象,它实现了IPage<T>接口,是真正用来存储数据的分页对象;而IPage<T>是 MP 提供的一个接口,里面有一系列方法供实现类实现。使用方法如下:
Page<User> page = new Page<>(1, 10); // 创建 Page(空的)
// 调用查询
IPage<User> result = userMapper.selectPage(page, null); // 查询结果塞进 Page,但用 IPage 接收
userMapper.selectPage(page, null)调用了 MyBatis-Plus 提供的通用 Mapper 接口,只要 UserMapper 继承了 BaseMapper<T>,就能调用selectPage等一系列方法,不用自己写 sql !假设 Page 对象如上文所示,那么实际执行的 sql 如下:
SELECT * FROM user LIMIT 0, 10;
同时还会执行一系列 sql,比如:
SELECT COUNT(*) FROM user;
用来计算总条数total。
第二个参数是查询条件构造器(Wrapper),此处为null,意思是select * from user,当然你也可以自己构建 Wrapper,例如:
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getName, "张三"); // eq: equal,等值条件
userMapper.selectPage(page, wrapper);
此时查询 sql 变为:
SELECT * FROM user WHERE name = '张三' LIMIT ...
注意到这里多出了限制条件WHERE,这就是 Wrapper 的作用。
of方法弄明白后,empty方法就容易多了。顾名思义,empty 就是空,意思是当前页啥都没有查出来,代码如下:
public static <T> PageResult<T> empty(long pageNum, long pageSize) {
return new PageResult<>(
0L,
pageNum,
pageSize,
Collections.emptyList()
);
}
返回效果:
{
"total": 0,
"pageNum": 1,
"pageSize": 10,
"list": []
}
这里有人可能有疑惑:为什么后端不直接给前端返回null?如果真这么返回,前端直接就炸了:Cannot read properties of null。
四、分页请求
当前端发送分页请求时,到底该怎么接受?
常见的想法当然是封装成一个类,基础的PageQuery类如下:
@Data
public class PageQuery {
@Min(value = 1, message = "页码不能小于 1")
private Long pageNum = 1L;
@Min(value = 1, message = "每页数量不能小于 1")
@Max(value = 100, message = "每页数量不能超过 100")
private Long pageSize = 10L;
}
可以看到,PageQuery只包含了最基础的pageNum和pageSize,即当前页码和每页数量;当然具体业务上肯定不可能这么简单,比如说分页获取已发布文章的请求体如下:
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticlePublicQueryRequest extends PageQuery {
private Long categoryId;
private Long tagId;
private String keyword;
}
可以看到,ArticlePublicQueryRequest继承了PageQuery,并且多出了分类Id、标签Id、关键词;这里第二个注解@EqualsAndHashCode(callSuper = true)含义是生成的 equals/hashCode 会把父类的字段也算进去,举个例子:
父类有字段:
pageNum
pageSize
子类有字段:
categoryId
tagId
keyword
对于以下两个对象字段:
obj1:
pageNum = 1
pageSize = 10
categoryId = 1
obj2:
pageNum = 2
pageSize = 10
categoryId = 1
不加 callSuper:认为相等(因为只看 categoryId); 加了 callSuper:不相等(因为 pageNum 不同)。
实际Controller层应用如下:
@Operation(summary = "分页获取已发布文章")
@GetMapping
public Result<PageResult<ArticleListVO>> page(@Valid ArticlePublicQueryRequest request) {
return Result.success(articleService.getPublicPage(request));
}
这里@Valid注解的作用十分重要:它是启用注解检查的开关,加上后会触发字段检验PageQuery中的@Min和@Max注解对应的字段,一旦不符合会直接抛异常;如果不加@Valid,那@Min(value = 1, message = "页码不能小于 1")等一系列注解根本不会生效!
Spring Boot 自动将前端 url 请求绑定到ArticlePublicQueryRequest中,后端拿着这些参数再去数据库里查询。
五、总结
統一接口返回,是真善美的集合,花點時間去挑戰後端有趣的難題,不再討論這件事情了。
