资源方法返回什么,客户端收到什么:Jakarta REST 的响应与异常路径
一个接口在查不到数据时返回 null,前端收到 204,以为操作成功、没有内容可显示。另一个接口在异常映射器里调用了一个会抛异常的格式化方法,客户端拿到的是没有任何错误信息的 500,日志里却找不到映射器抛出的那个异常。
Web 框架在资源方法和 HTTP 响应之间做了很多默认处理:返回值怎样变成状态码和响应体,异常怎样变成错误响应,没有写的 HEAD 和 OPTIONS 怎样自动响应。这些默认行为大多由规范规定,平时感觉不到,直到某个边界情况和直觉不一致。自己设计框架或者网关时,这些也是要逐项决定的契约。
本文用 Jersey 4.0.3(Jakarta REST 4.0 的参考实现)在进程内启动服务,逐项记录客户端实际收到的状态码、头和响应体,并对照规范原文。实验见文末。参数缺省与转换失败的部分另写在参数绑定的三种失败。
一、返回值决定状态码
返回 String:HTTP 200,Content-Length: 5,body「hello」
返回 null:HTTP 204,body「」
void 方法:HTTP 204,body「」
返回 Response.status(201):HTTP 201,X-Trace: t1,Content-Length: 7,body「created」规范对返回类型的规定很简单:void 和 null 都是 204;普通对象是 200,由能写出该类型的 MessageBodyWriter 序列化;Response 按它自己的状态、头和实体处理。
开头那个「查不到返回 null」的接口,问题不在框架,而在契约:204 的意思是「成功,没有内容」,而「找不到」应该是 404。资源方法想表达不同的结果,就应该返回 Response 或抛出对应的异常,而不是依赖 null 的默认映射。
显式的 Response 必须原样交给写出流程。自己实现框架时有一个容易犯的错误:外层统一用 Response.ok(result) 包装返回值,结果资源方法返回的 Response 被当成普通实体再包一层,状态码和头都丢了。返回值需要先按类型分流:Response、GenericEntity、null 或 void、普通对象,各走各的路径。
二、异常的三条路径
抛 WebApplicationException(409):HTTP 409,Content-Length: 5,body「taken」
抛有映射器的异常:HTTP 422,Content-Length: 19,body「mapped:库存不足」
映射器自身抛异常:HTTP 500,Content-Length: 0,body「」
抛没有映射器的 RuntimeException:HTTP 500,Content-Length: 0,body「」
返回没有 MessageBodyWriter 的类型:HTTP 500,Content-Length: 0,body「」规范定义的处理顺序是:
WebApplicationException自带一个Response:如果这个响应没有实体、又为该异常类型注册了映射器,就用映射器生成响应;否则直接使用它。- 其他异常查找泛型类型最接近的
ExceptionMapper,由映射器生成Response。 - 映射器自身抛异常时,返回 500,不再查找别的映射器。
- 没有映射器的异常交给容器处理,通常也是 500。
第 3 条是一个值得借鉴的设计:错误处理器本身也可能失败,而且失败时不能再交回错误处理器,否则映射器持续失败就会无限递归。自己写统一异常处理时(例如把「写出响应失败」又送回统一处理入口),需要同样的终止条件:每条错误路径最多处理一次,处理失败就落到一个不会再失败的最终响应。
开头那个映射器失败后「日志里找不到异常」的情况也在这里:映射器抛出的异常被框架吞掉、换成了 500。映射器应该保持简单,不在里面做可能失败的操作;需要格式化的错误信息提前准备好。
还有一种不太直观的 500:返回了一个没有对应 MessageBodyWriter 的类型(例如没有引入 JSON 支持时返回一个普通对象)。资源方法本身执行成功了,失败发生在写出阶段。测试只调用资源方法而不经过 HTTP,就发现不了这类错误。
三、HEAD:调用 GET,丢弃实体
对只有 @GET 的资源发 HEAD:HTTP 200,body「」;GET 方法被调用 1 次规范规定:没有 @HEAD 方法时,调用 @GET 方法并丢弃返回的实体。所以:
- HEAD 请求会执行 GET 的全部逻辑,包括查询和计算,只是不发送响应体。GET 有副作用(计数、记录访问)时,HEAD 也会触发。
- GET 生成实体的成本很高时,可以单独写一个
@HEAD方法只计算头。 - 「无响应体」和「204」是两回事:HEAD 的状态码应该和 GET 一样是 200。自己实现时如果把 HEAD 实现成「调用 GET、返回 null」,再交给「null 映射为 204」的通用逻辑,状态码就错了。
四、OPTIONS 与 405
没写 @OPTIONS 时发 OPTIONS:HTTP 200,Allow: GET,HEAD,OPTIONS,PUT
对不存在的路径发 OPTIONS:HTTP 404
对只有 GET/PUT 的资源发 DELETE:HTTP 405,Allow: GET,OPTIONS,PUT默认的 OPTIONS 响应由资源上的注解生成,列出实际支持的方法;路径不存在时仍然是 404,不会因为 OPTIONS 是「元请求」就对任何路径都回 200。方法不支持时返回 405 并带上 Allow,而不是 404:路径存在、方法不对,是两种不同的错误,客户端的处理方式也不同。
自己实现路由时,这两点常被简化掉:用一个硬编码的方法列表回答所有 OPTIONS,或者把「路径不匹配」和「方法不匹配」都返回 404。默认 OPTIONS 也不等于 CORS 预检的完整实现,跨源请求还需要 Access-Control-* 一组头,见浏览器的跨源限制。
五、给自己实现的框架列一张契约表
| 输入 | 应当得到 |
|---|---|
| 返回普通对象 | 200,按类型选择写出器 |
| 返回 null 或 void | 204 |
返回 Response | 原样使用,不再包装 |
| 抛自带响应的异常 | 使用该响应 |
| 抛有映射器的异常 | 映射器的结果 |
| 映射器自身失败 | 500,不再递归处理 |
| 没有写出器 | 500,在写出阶段发现 |
| HEAD 无专门方法 | 执行 GET,状态与头相同,无响应体 |
| OPTIONS 无专门方法 | 200,Allow 列出实际方法;路径不存在仍 404 |
| 方法不支持 | 405 与 Allow |
每一行都应该有一个经过真实 HTTP 的测试:只调用资源方法的单元测试看不到写出阶段的失败、HEAD 和 OPTIONS 的自动处理,也看不到状态码。
六、检查清单
- 用返回
Response或抛异常表达「找不到」等结果,不依赖 null 的默认映射。 - 返回值按类型分流,显式
Response不被再次包装。 - 异常映射器保持简单;错误处理路径有终止条件。
- 知道 HEAD 会执行 GET 的逻辑;HEAD 与 GET 的状态码一致。
- 区分 404 与 405,405 带上 Allow。
- 写出阶段的失败用经过 HTTP 的测试覆盖。
配套实验
- codesphere-labs/design/jakarta-rest-behavior:Jersey 4.0.3、JDK 25.0.4.1:返回值与状态码、HEAD 与 OPTIONS、405、WebApplicationException、映射器与映射器失败、没有写出器、参数缺省与转换失败(验证记录)
参考资料
- Jakarta RESTful Web Services 4.0 规范:3.3.3 Return Type、3.3.4 Exceptions、3.3.5 HEAD and OPTIONS
- Jersey 用户指南
- RFC 9110:405 Method Not Allowed 与 Allow