返回文章列表

项目复盘

Spring Boot 博客后端实践:统一接口返回

統一接口返回,是真善美的集合!作爲新時代後端學習者,有必要了解!獎勵一塊xx手錶

2026 年 04 月 29 日357176published
#Spring Boot

Spring Boot 博客后端实践:统一接口返回


一、为什么要做统一接口返回

首先要明确:什么是统一接口返回

所谓统一接口返回,指的是一种规范,当后端返回给前端数据时,不是像这样:

{
  "id": 1,
  "username": "tom"
}

而是像这样:

{
  "code": 200,
  "message": "success",
  "data": {
    "id": 1,
    "username": "tom"
  }
}

可以看到,后端返回被规范为了3部分:codemessagedata,其中code表示业务状态码,通常是你自定义的,message是对该状态的描述信息,data才是真正返回的数据。

为什么要这么搞?首先肯定是为了前后端解耦合,前端只需要认:codemessagedata,不用关心每个接口结构细节;其次是为了统一错误处理,比如登陆过期、权限不足、参数错误,统一返回:

{
  "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 中定义的 codemessage,从而完成统一响应封装。

三、分页响应设计: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可以调用UserServiceImplUserService中的公共方法,即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只包含了最基础的pageNumpageSize,即当前页码和每页数量;当然具体业务上肯定不可能这么简单,比如说分页获取已发布文章的请求体如下:

@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中,后端拿着这些参数再去数据库里查询。


五、总结

統一接口返回,是真善美的集合,花點時間去挑戰後端有趣的難題,不再討論這件事情了。

上一篇

已经是第一篇了

下一篇

已经是最后一篇了