统一返回对象设计:AjaxResult 与 R 的取舍
本文基于若依3.9.2、SpringBoot3版本。

统一返回对象设计:AjaxResult 与 R 的取舍
前后端分离的项目里,所有接口的响应必须有一套统一结构,前端才能用一套固定逻辑判断成败、取数据。若依在 com.ruoyi.common.core.domain 包下提供了两个统一返回对象,代表两种截然不同的封装思路:
- AjaxResult:继承
HashMap,不定义字段,数据靠 key-value 动态存取,结构灵活 R<T>:泛型实体类,code/msg/data三个固定字段,data 类型由泛型决定,类型安全
两者的状态码都来自 HttpStatus 常量类,本文章节涉及的常用值有 SUCCESS=200、WARN=601、ERROR=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 res 或 res.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"
}
其中 name、sex 就是通过链式 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 提供了"响应结构稳定、类型可预期"的强类型选项。保留两套让不同场景各取所需,但也意味着新开发者需要理解两套的存在与差异,这是灵活性换来的认知成本。

浙公网安备 33010602011771号