Response 二义性之殇与异常处理重构

random-pic-api

一个隐藏了一年的 bug

先看一段代码。这是框架里 Response 类的几个静态工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Response success() {
return Response.success(null);
}

public static <T> Response success(T data) {
return new Response().success(data, null);
}

public static Response success(String message) {
return new Response().success(null, message);
}

public Response success(T data, String message) {
this.meta = new Meta(ResponseState.SUCCESS, message);
this.data = data;
return this;
}

调用方的代码很简单:

1
2
3
4
5
@GetMapping("/value")
public Response<String> getDictValue() {
String dictValue = "1111";
return Response.success(dictValue);
}

请问返回值是什么?

如果你觉得 meta.success = truedata = "1111"——错了。实际结果是 meta.success = truedata = nullmeta.message = "1111"

原因是 Java 的重载解析:success(String)success(T) 更具体,编译器优先选了前者。调用方想把 "1111" 当业务数据返回,但框架把它当成了提示消息。

这个 bug 在生产环境存在了一年多,没人发现。为什么?因为这个接口恰好是一个字典查询,前端在没有 data 的时候自己 fallback 了一个默认值。后来字典配置变了,前端 fallback 的值和后台配置不一致,顺藤摸瓜才查到这里。

这不是一个编码失误,这是一个 API 设计缺陷。当你提供的 API 在特定类型下行为发生”静默改变”时,你埋下的不是 bug,而是一个迟早会触发的雷。

为什么失败了还要 data

再看 failure 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Response failure() {
return failure(null);
}

public static Response failure(String message) {
return new Response().failure(null, message);
}

public Response failure(T data, String message) {
this.meta = new Meta(ResponseState.FAIL, message);
this.data = data;
return this;
}

一个真实的调用:

1
2
3
4
5
6
public Response getWeChatUserAccessToken(String code, String responseToken) {
if (responseToken == null || !token.equals(responseToken)) {
return new Response().failure("微信返回的Token与配置不一致!", responseToken);
}
...
}

这里开发者的意图是把错误详情放进 data,把错误概述放进 message。但参数顺序反了——failure(T data, String message),第一个参数是 data,第二个是 message。所以 "微信返回的Token与配置不一致!" 变成了 data,responseToken 变成了 message。前端取错误信息的时候,到底读 data 还是读 message?如果是不同的人写的不同接口,有的把错误信息放 data,有的放 message,前端就得写两套处理逻辑。

再举一个更极端的例子——参数校验失败时的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Response buildArgumentValidMsg(BindingResult b) {
List<Map<String, Object>> invalidArguments = new ArrayList<>();
StringBuilder sb = new StringBuilder("【");
for (FieldError error : b.getFieldErrors()) {
Map<String, Object> errorMap = new HashMap<>(3);
errorMap.put("field", error.getField());
errorMap.put("defaultMessage", error.getDefaultMessage());
errorMap.put("rejectedValue", error.getRejectedValue());
invalidArguments.add(errorMap);
sb.append(error.getDefaultMessage()).append(";");
}
sb.append("】");
return new Response().failure(invalidArguments, "参数校验未通过!" + sb.toString());
}

这个方法做了几件事:把校验不通过的字段详情结构化放到 data 里,又把所有错误信息拼接成字符串放到 message 里。结果 data 和 message 各有一份错误信息,只是结构不同。前端看到的是一个失败的响应,里面有 data(一个复杂的对象数组),也有 message(一个拼接的字符串),但他不知道以哪个为准来展示给用户。

这种做法背后是一个更根本的问题:**failure 方法的语义不清晰**。成功的时候返回 data,天经地义。失败的时候,返回的应该是错误信息,而不是业务数据。把 data 和失败结果放在一起,只会让调用方困惑——这个 data 到底是错误详情,还是部分成功的业务数据?

重载规则的真正陷阱

上面那个 success(String) vs success(T) 的问题,其实 Java 规范里有明确定义。JLS 15.12.2.5 说,当多个方法都匹配时,选择”最具体”的那个。StringObject 的子类,所以 success(String)success(T)(擦除后是 success(Object))更具体。

对于非 String 类型,不会有这个问题。但问题是,调用方不知道自己传入的参数会在什么时候恰好变成 String。他可能在测试的时候传的是 Integer,测试通过。后来需求变了,参数改成了 String,测试也通过(因为 success 不报错),但返回结果不对。这种”静默失败”是最危险的,因为它在所有层面看起来都是正确的——HTTP 200,meta.success=true,没有异常日志。

解决方案不是让开发者注意不要传 String,而是在设计 API 时就不要创建这种二义性的重载。具体来说:

  1. 去掉 success(String message) 这个容易产生歧义的重载;
  2. 如果要设置 message,必须显式调用 success(T data, String message),这样调用方明确知道自己在设置 message;
  3. 或者干脆去掉所有不带 message 的方法,统一走 Builder 模式。

异常处理组件该放哪

Response 是统一返回体,异常处理则是它的另一面——当程序出错时,怎么把异常转成统一的错误响应返回给前端。这两个东西是密切相关的。

框架里有一个 exception-starter 组件,里面放了异常模型和全局异常处理器。但仔细想想,这两样东西的归属应该不同。

异常模型(各种业务异常类)属于通用基础层,应该放在 core 里。任何模块都可能需要抛异常——不管是 web 模块、rpc 模块还是定时任务模块,它们不应该为了引用一个异常类而依赖整个 starter。

全局异常处理器@ControllerAdvice@RestControllerAdvice)是 Spring Web 的特性,属于适配层。它应该放在 restweb 模块中,只在需要提供 REST API 的服务里生效。

这样拆分之后,exception-starter 还剩下什么?如果它只是把 core 的异常模型和 web 的全局处理器拼在一起,那这个 starter 就没有独立存在的必要了。可以直接取消,或者在它里面放真正扩展的能力——异常监控告警、错误码国际化、异常日志与链路追踪的联动。

这其实是一个通用的原则:一个 starter 要有独立的存在价值。如果它的功能完全可以被拆分到其他模块中而不损失任何能力,那它就应该被拆分。

重构方案

说了这么多问题,怎么改?原则很简单:保持向后兼容,分阶段迁移

第一步:修正 Response 的 API

核心思路是引入 Builder 模式,提供一条清晰的构建路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Data
public class ApiResponse<T> implements Serializable {
private Meta meta;
private T data;

private ApiResponse(Meta meta, T data) {
this.meta = meta;
this.data = data;
}

// 成功 - 只用这两个就够了
public static <T> ApiResponse<T> success() {
return new ApiResponse<>(new Meta(ResponseState.SUCCESS, null), null);
}

public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(new Meta(ResponseState.SUCCESS, null), data);
}

// 失败 - 只需要 message
public static <T> ApiResponse<T> failure(String message) {
return new ApiResponse<>(new Meta(ResponseState.FAIL, message), null);
}

// 复杂场景用 Builder
public static <T> Builder<T> builder() {
return new Builder<>();
}

public static class Builder<T> {
private Meta meta;
private T data;

public Builder<T> success() {
this.meta = new Meta(ResponseState.SUCCESS, null);
return this;
}

public Builder<T> failure() {
this.meta = new Meta(ResponseState.FAIL, null);
return this;
}

public Builder<T> code(String code) {
if (this.meta != null) this.meta.setCode(code);
return this;
}

public Builder<T> message(String message) {
if (this.meta != null) this.meta.setMessage(message);
return this;
}

public Builder<T> data(T data) {
this.data = data;
return this;
}

public ApiResponse<T> build() {
if (meta == null) this.meta = new Meta(ResponseState.SUCCESS, null);
return new ApiResponse<>(meta, data);
}
}
}

对于参数校验失败这种需要返回结构化错误详情的场景,用 Builder:

1
2
3
4
5
6
7
8
9
10
11
12
13
private ApiResponse<Void> buildArgumentValidMsg(BindingResult b) {
List<FieldErrorDetail> errors = b.getFieldErrors().stream()
.map(e -> new FieldErrorDetail(e.getField(),
e.getDefaultMessage(), e.getRejectedValue()))
.toList();

return ApiResponse.<Void>builder()
.failure()
.code("VALIDATION_ERROR")
.message("参数校验未通过")
.data(errors) // 结构化错误详情明确放在 data 里
.build();
}

之前那个微信 Token 校验的场景,变成了:

1
2
3
4
5
6
public ApiResponse<?> getWeChatUserAccessToken(String code, String responseToken) {
if (responseToken == null || !token.equals(responseToken)) {
return ApiResponse.failure("微信返回的Token与配置不一致!"); // 干净,没有歧义
}
...
}

第二步:处理兼容性

旧的 Response 类保留,标记 @Deprecated

1
2
3
4
5
6
7
8
9
10
@Deprecated
public class Response<T> extends ApiResponse<T> {
// 委托给 ApiResponse,保持旧调用方正常工作
@Deprecated
public static Response success(String message) {
// 日志告警:有人在用容易产生歧义的方法,建议迁移
log.warn("Response.success(String) 已被废弃,请使用 ApiResponse.success() 或显式传入 data 参数");
return ...;
}
}

在过渡期内,框架同时支持 ResponseApiResponse。关键是在监控里埋点,看哪些服务还在用旧的 API,主动推动迁移。等所有服务都迁移完成后,下一个大版本再删除 Response 类。

第三步:异常组件重组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Before:
exception-starter/
├── BaseException.java
├── BusinessException.java
├── SystemException.java
└── GlobalExceptionHandler.java

After:
core/
├── exception/
│ ├── BaseException.java
│ ├── BusinessException.java
│ └── SystemException.java

rest/
└── handler/
└── GlobalExceptionHandler.java

exception-starter/ (如果还需要的话)
└── monitor/
└── ExceptionMetricsReporter.java # 异常监控

这样改了之后,一个纯 RPC 的模块引入 core 就能用异常模型,不需要引入多余的 web 依赖。一个 web 模块引入 rest 就自动获得全局异常处理器。职责清晰,依赖最小化。

越简单的 API,越难设计

重构 Response 这件事让我特别有感触。它可能是整个框架里”最简单”的类——十几个方法,几百行代码。但正因为它简单,所有微服务、所有接口都在用它,它的每一个设计缺陷都会被无限放大。

设计 API 的时候有一个检验标准:如果一个方法名,使用者在调用之前需要犹豫”我该用哪个”,那这个 API 的设计就是失败的。 success(String)success(T) 就完美地中了这个标准——当 T 恰好是 String 时,使用者和编译器做出了不同的选择。

下一篇我们聊缓存组件。它的问题刚好相反——不是太简单,而是太复杂。一个接口塞了五十几个方法,三种 Redis 客户端实现交织在一起,还有静态工具类和单例模式的混用。那是另一种维度的挑战。