统一返回对象设计:AjaxResult 与 R 的取舍

本文基于若依3.9.2、SpringBoot3版本。

image

统一返回对象设计:AjaxResult 与 R 的取舍

前后端分离的项目里,所有接口的响应必须有一套统一结构,前端才能用一套固定逻辑判断成败、取数据。若依在 com.ruoyi.common.core.domain 包下提供了两个统一返回对象,代表两种截然不同的封装思路:

  • AjaxResult:继承 HashMap,不定义字段,数据靠 key-value 动态存取,结构灵活
  • R<T>:泛型实体类,code/msg/data 三个固定字段,data 类型由泛型决定,类型安全

两者的状态码都来自 HttpStatus 常量类,本文章节涉及的常用值有 SUCCESS=200WARN=601ERROR=500

统一响应要解决什么问题

没有统一封装时,每个接口自己决定返回结构,前端每个接口都要单独适配:有的返回 {success: true},有的返回 {status: 1},出错时字段还不一样,前端判断逻辑写成一团乱麻。统一返回对象要达成三个目标:

  • 字段约定:成败用固定字段表达,前端统一读取
  • 便捷构建:成功/失败等常见场景一行代码生成
  • 兜底能力:异常也能被包装成同样的结构返回,前端不用区分正常和异常响应

若依的两套实现,本质是对「灵活」和「类型安全」这两个目标的不同侧重。

AjaxResult:继承 HashMap 的灵活封装

AjaxResult 的核心设计是继承 HashMap<String, Object>,让自己天生就是一个 map,无需定义字段就能通过 put/get 存取任意数据,Jackson 也会直接把它序列化成 JSON 对象。

它只定义了三个常量,作为 map 的 key——这是前后端约定的固定字段:

public class AjaxResult extends HashMap<String, Object> {
    private static final long serialVersionUID = 1L;

    /** 状态码 */
    public static final String CODE_TAG = "code";

    /** 返回内容 */
    public static final String MSG_TAG = "msg";

    /** 数据对象 */
    public static final String DATA_TAG = "data";
}

构造方法与 data 为 null 的处理

AjaxResult 有三个构造方法:无参构造(空实现,留给子类或反序列化)、(code, msg)(code, msg, data)。有参构造就是把常量作为 key 调 put 存入:

public AjaxResult() {
}

public AjaxResult(int code, String msg) {
    super.put(CODE_TAG, code);
    super.put(MSG_TAG, msg);
}

public AjaxResult(int code, String msg, Object data) {
    super.put(CODE_TAG, code);
    super.put(MSG_TAG, msg);
    if (StringUtils.isNotNull(data)) {
        super.put(DATA_TAG, data);
    }
}

这里有个容易忽略的细节:data 为 null 时不会存入 map。所以成功但无数据的接口,返回的 JSON 里根本没有 data 这个 key,而不是 "data": null。这样前端判断「有没有数据」时,用 "data" in resres.data != null 都能得到一致结果,也避免了无意义的空字段。

静态工厂方法:重载委托与默认值

AjaxResult 提供三类静态快捷方法:成功、警告、失败/错误。以成功为例,四个重载层层委托,最终汇聚到一个全参版本:

public static AjaxResult success() {
    return AjaxResult.success("操作成功");
}

public static AjaxResult success(Object data) {
    return AjaxResult.success("操作成功", data);
}

public static AjaxResult success(String msg) {
    return AjaxResult.success(msg, null);
}

public static AjaxResult success(String msg, Object data) {
    return new AjaxResult(HttpStatus.SUCCESS, msg, data);
}

调用 success()success(data) 时,"操作成功"这个默认 msg 就是在委托过程中逐步填充的。这种静态工厂方法替代构造器的写法,让调用点语义自解释——AjaxResult.success(user) 一眼看出是"带数据的成功响应",而不用关心 code 默认值是几。

警告方法只有一个 msg 入口,没有默认消息:

public static AjaxResult warn(String msg) {
    return AjaxResult.warn(msg, null);
}

public static AjaxResult warn(String msg, Object data) {
    return new AjaxResult(HttpStatus.WARN, msg, data);
}

警告默认 code 是 601(HttpStatus.WARN),但没有默认 msg——警告属于业务层主动告知的"非致命问题",msg 必须由调用方明确给出才不失去意义。

失败/错误方法里有一个特殊重载,是唯一能自定义 code 的入口

public static AjaxResult error() {
    return AjaxResult.error("操作失败");
}

public static AjaxResult error(String msg) {
    return AjaxResult.error(msg, null);
}

public static AjaxResult error(String msg, Object data) {
    return new AjaxResult(HttpStatus.ERROR, msg, data);
}

public static AjaxResult error(int code, String msg) {
    return new AjaxResult(code, msg, null);
}

默认错误走 500(HttpStatus.ERROR),而 error(int code, String msg) 允许业务方返回自定义业务错误码——比如前面文章提到的认证失败用 AjaxResult.error(401, msg) 返回业务 401,就走的这个入口。

状态判断与装箱问题

三个判断方法根据 code 值判断当前结果类型:

public boolean isSuccess() {
    return Objects.equals(HttpStatus.SUCCESS, this.get(CODE_TAG));
}

public boolean isWarn() {
    return Objects.equals(HttpStatus.WARN, this.get(CODE_TAG));
}

public boolean isError() {
    return Objects.equals(HttpStatus.ERROR, this.get(CODE_TAG));
}

this.get(CODE_TAG) 从 map 取出的 code 是 Integer(int 装箱),而常量是 int。这里用 Objects.equals 而不是 == 比较,是为了避免两种坑:一是取值为 null 时 == 直接空指针,二是两个 Integer 用 == 比较时在 [-128, 127] 之外靠引用判断会误判。Objects.equals 会先判 null 再调 equals,安全可靠。

put 重写:链式调用

AjaxResult 还重写了 HashMap.put,把返回值从 Object 改成 this

@Override
public AjaxResult put(String key, Object value) {
    super.put(key, value);
    return this;
}

这是 Builder 模式的简化写法,让追加字段能连续调用:

AjaxResult.success().put("name", "zhangsan").put("sex", "1");

结合继承 HashMap 的特性,AjaxResult 可以在标准 code/msg/data 之外,任意追加自定义字段——这是它最灵活的地方。

一个典型的 AjaxResult 响应是这个形态:

{
    "msg": "操作成功",
    "code": 200,
    "data": {
        "userName": "admin",
        "phonenumber": "15888888888"
    },
    "name": "zhangsan",
    "sex": "1"
}

其中 namesex 就是通过链式 put 追加的字段。但灵活性也有代价:HashMap 本身不保证插入顺序(实际按 hash 分布输出),字段顺序在 JSON 里不可控;而且任何字段都是 Object,取用时需要强转,没有编译期检查。

R:泛型实体类的强类型封装

R<T> 是另一个思路:不继承 map,而是定义固定字段的实体类,data 的类型由泛型参数 T 决定:

public class R<T> implements Serializable {
    private static final long serialVersionUID = 1L;

    /** 成功 */
    public static final int SUCCESS = HttpStatus.SUCCESS;

    /** 失败 */
    public static final int FAIL = HttpStatus.ERROR;

    private int code;

    private String msg;

    private T data;
}

调用方在编译期就能拿到明确类型,比如 R<List<UserEntity>> 的 data 就是 List<UserEntity>,无需强转。当前框架代码里 R 仅在 TestController(Swagger 测试接口)中使用,但这只是当前代码的使用情况,不代表 R 的定位仅限于测试。

私有方法 restResult:工厂的核心

R 没有全参构造器,而是用一个私有方法统一创建对象:

private static <T> R<T> restResult(T data, int code, String msg) {
    R<T> apiResult = new R<>();
    apiResult.setCode(code);
    apiResult.setData(data);
    apiResult.setMsg(msg);
    return apiResult;
}

这个私有方法是所有便捷方法的地基,把"new 对象 + 设字段"的过程封装起来,对外只暴露语义明确的方法。为什么不用全参构造?因为工厂方法能把默认值和默认 code 内化在方法内部——调用 R.ok(data) 就知道 code 是 200、msg 是"操作成功",调用点自解释;而构造器只能靠参数列表区分,无法命名、无法封装默认值。

便捷方法 ok / fail

成功系列,code 统一为 SUCCESS,默认 msg"操作成功":

public static <T> R<T> ok() {
    return restResult(null, SUCCESS, "操作成功");
}

public static <T> R<T> ok(T data) {
    return restResult(data, SUCCESS, "操作成功");
}

public static <T> R<T> ok(T data, String msg) {
    return restResult(data, SUCCESS, msg);
}

失败系列,code 统一为 FAIL,默认 msg"操作失败":

public static <T> R<T> fail() {
    return restResult(null, FAIL, "操作失败");
}

public static <T> R<T> fail(String msg) {
    return restResult(null, FAIL, msg);
}

public static <T> R<T> fail(T data) {
    return restResult(data, FAIL, "操作失败");
}

public static <T> R<T> fail(T data, String msg) {
    return restResult(data, FAIL, msg);
}

public static <T> R<T> fail(int code, String msg) {
    return restResult(null, code, msg);
}

状态判断:int 直接比较

R 也提供成功/失败判断,但和 AjaxResult 不同,它传入一个 R 对象来判定:

public static <T> Boolean isError(R<T> ret) {
    return !isSuccess(ret);
}

public static <T> Boolean isSuccess(R<T> ret) {
    return R.SUCCESS == ret.getCode();
}

R 的 code 是基本类型 int(字段直接声明,不经过 map 装箱),所以这里直接用 == 比较,没有 AjaxResult 那套装箱空指针问题。

序列化形态:data 为 null 也输出

R 的 data 是普通字段,无论是否为 null 都会被序列化——这点与 AjaxResult(data 为 null 时不存入)正好相反。TestController 里的典型用法:

@GetMapping("/list")
public R<List<UserEntity>> userList() {
    List<UserEntity> userList = new ArrayList<UserEntity>(users.values());
    return R.ok(userList);
}

成功响应是这个形态:

{
    "code": 200,
    "msg": "操作成功",
    "data": [
        {
            "userId": 1,
            "username": "admin",
            "mobile": "15888888888"
        },
        {
            "userId": 2,
            "username": "ry",
            "mobile": "15666666666"
        }
    ]
}

失败响应(如 R.fail("用户不存在")):

{
    "code": 500,
    "msg": "用户不存在",
    "data": null
}

可以看到失败时 data 仍以 null 序列化出来。这保证前端始终能按 res.data 读取,结构永远完整统一;缺点是没有数据的接口也会输出一个多余的 "data": null

两种封装怎么选

两者核心差异可总结为一张表:

维度 AjaxResult R<T>
底层结构 继承 HashMap,数据用 key-value 存 固定字段实体类
data 类型 Object,取用需强转 泛型 T,编译期类型安全
字段扩展 可任意 put 追加字段,链式调用 只有 code/msg/data 三个固定字段
data 为 null 不存入,JSON 无该字段 始终序列化,输出 "data": null
状态判断 Objects.equals(Integer 装箱) ==(int 基本类型)
自定义 code error(int, String) fail(int, String)
使用场景 Controller 通用返回,框架内处处使用 强类型场景,当前仅测试接口

选择依据很简单:需要灵活扩展任意字段、快速搭建响应时用 AjaxResult;对返回数据类型有强约束、希望编译期就保证类型正确时用 R。若依主业务线几乎全用 AjaxResult,因为它要适配各类增删改查与列表返回,map 的灵活性更贴合;R 适合对类型安全要求更高的场景,但在灵活扩展上被固定字段限制住了。

思考

AjaxResult 继承 HashMap 的取舍:继承 map 换来了零字段定义、任意扩展、链式调用,代价是失去了类型安全和结构约束。字段拼错不会在编译期报错,序列化字段顺序也由 HashMap 的 hash 分布决定、不可控。这是典型的"以类型安全换开发效率"——若依选择它作为主返回对象,是权衡业务开发中响应结构频繁变动后做的决定:改返回字段只需 put 一行,不用改类定义。但当响应结构需要强约定、要避免随手往 map 里塞字段时,R 的固定结构反而是优点。

601 非标准状态码的意义HttpStatus.WARN = 601 不是任何 HTTP 标准码,而是若依自定义的业务码。标准 HTTP 状态码是传输层语义(404 表示资源不存在、500 表示服务器错误),而 AjaxResult 的 code 是业务层语义,两者可以不一致(HTTP 永远 200,业务码在 body 里)。601 为"警告"单独划了一个档位,让前端能区分"操作失败(500,需报错提示)"和"操作有警告(601,可温和提示)",属于在统一返回体系里补充的非标准但实用的约定。

为什么框架同时保留两套:不是设计冗余,而是两种风格互补——AjaxResult 覆盖了绝大多数"响应结构随时可能变化"的 Controller 场景,R 提供了"响应结构稳定、类型可预期"的强类型选项。保留两套让不同场景各取所需,但也意味着新开发者需要理解两套的存在与差异,这是灵活性换来的认知成本。

posted @ 2026-08-25 23:15  咖啡八杯  阅读(3)  评论(0)    收藏  举报