
一个隐藏了一年的 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 = true,data = "1111"——错了。实际结果是 meta.success = true,data = null,meta.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 说,当多个方法都匹配时,选择”最具体”的那个。String 是 Object 的子类,所以 success(String) 比 success(T)(擦除后是 success(Object))更具体。
对于非 String 类型,不会有这个问题。但问题是,调用方不知道自己传入的参数会在什么时候恰好变成 String。他可能在测试的时候传的是 Integer,测试通过。后来需求变了,参数改成了 String,测试也通过(因为 success 不报错),但返回结果不对。这种”静默失败”是最危险的,因为它在所有层面看起来都是正确的——HTTP 200,meta.success=true,没有异常日志。
解决方案不是让开发者注意不要传 String,而是在设计 API 时就不要创建这种二义性的重载。具体来说:
- 去掉
success(String message) 这个容易产生歧义的重载; - 如果要设置 message,必须显式调用
success(T data, String message),这样调用方明确知道自己在设置 message; - 或者干脆去掉所有不带 message 的方法,统一走 Builder 模式。
异常处理组件该放哪
Response 是统一返回体,异常处理则是它的另一面——当程序出错时,怎么把异常转成统一的错误响应返回给前端。这两个东西是密切相关的。
框架里有一个 exception-starter 组件,里面放了异常模型和全局异常处理器。但仔细想想,这两样东西的归属应该不同。
异常模型(各种业务异常类)属于通用基础层,应该放在 core 里。任何模块都可能需要抛异常——不管是 web 模块、rpc 模块还是定时任务模块,它们不应该为了引用一个异常类而依赖整个 starter。
全局异常处理器(@ControllerAdvice 或 @RestControllerAdvice)是 Spring Web 的特性,属于适配层。它应该放在 rest 或 web 模块中,只在需要提供 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); }
public static <T> ApiResponse<T> failure(String message) { return new ApiResponse<>(new Meta(ResponseState.FAIL, message), null); }
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) .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> { @Deprecated public static Response success(String message) { log.warn("Response.success(String) 已被废弃,请使用 ApiResponse.success() 或显式传入 data 参数"); return ...; } }
|
在过渡期内,框架同时支持 Response 和 ApiResponse。关键是在监控里埋点,看哪些服务还在用旧的 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 客户端实现交织在一起,还有静态工具类和单例模式的混用。那是另一种维度的挑战。