Skip to content

资源方法返回什么,客户端收到什么:Jakarta REST 的响应与异常路径 ​

一个接口在查不到数据时返回 null,前端收到 204,以为操作成功、没有内容可显示。另一个接口在异常映射器里调用了一个会抛异常的格式化方法,客户端拿到的是没有任何错误信息的 500,日志里却找不到映射器抛出的那个异常。

Web 框架在资源方法和 HTTP 响应之间做了很多默认处理:返回值怎样变成状态码和响应体,异常怎样变成错误响应,没有写的 HEAD 和 OPTIONS 怎样自动响应。这些默认行为大多由规范规定,平时感觉不到,直到某个边界情况和直觉不一致。自己设计框架或者网关时,这些也是要逐项决定的契约。

本文用 Jersey 4.0.3(Jakarta REST 4.0 的参考实现)在进程内启动服务,逐项记录客户端实际收到的状态码、头和响应体,并对照规范原文。实验见文末。参数缺省与转换失败的部分另写在参数绑定的三种失败。

资源方法客户端收到返回 String200返回 null / void 方法204(不是 404)返回 Response.status(201)201,头原样保留抛 WebApplicationException(409)409,使用自带的响应抛有映射器的异常映射器的结果(422)映射器自身抛异常500,不再交回映射器返回没有写出器的类型500,失败在写出阶段只有 @GET,收到 HEAD200,无响应体,GET 被执行 1 次没写 @OPTIONS / 方法不支持200 + Allow / 405 + Allow自己实现框架时:返回值按类型分流,错误处理路径有终止条件,HEAD 与 GET 状态一致,区分 404 与 405
图 1 · 实测(Jersey 4.0.3):null 和 void 都是 204,显式 Response 原样使用;映射器自身抛异常时直接返回 500,不再递归处理;只有 GET 时 HEAD 会执行 GET 并丢弃实体,状态码仍是 200;默认 OPTIONS 列出实际方法,路径不存在仍是 404;方法不支持是 405 并带 Allow。

一、返回值决定状态码 ​

text
返回 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、普通对象,各走各的路径。

二、异常的三条路径 ​

text
抛 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「」

规范定义的处理顺序是:

  1. WebApplicationException 自带一个 Response:如果这个响应没有实体、又为该异常类型注册了映射器,就用映射器生成响应;否则直接使用它。
  2. 其他异常查找泛型类型最接近的 ExceptionMapper,由映射器生成 Response。
  3. 映射器自身抛异常时,返回 500,不再查找别的映射器。
  4. 没有映射器的异常交给容器处理,通常也是 500。

第 3 条是一个值得借鉴的设计:错误处理器本身也可能失败,而且失败时不能再交回错误处理器,否则映射器持续失败就会无限递归。自己写统一异常处理时(例如把「写出响应失败」又送回统一处理入口),需要同样的终止条件:每条错误路径最多处理一次,处理失败就落到一个不会再失败的最终响应。

开头那个映射器失败后「日志里找不到异常」的情况也在这里:映射器抛出的异常被框架吞掉、换成了 500。映射器应该保持简单,不在里面做可能失败的操作;需要格式化的错误信息提前准备好。

还有一种不太直观的 500:返回了一个没有对应 MessageBodyWriter 的类型(例如没有引入 JSON 支持时返回一个普通对象)。资源方法本身执行成功了,失败发生在写出阶段。测试只调用资源方法而不经过 HTTP,就发现不了这类错误。

三、HEAD:调用 GET,丢弃实体 ​

text
对只有 @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 ​

text
没写 @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 或 void204
返回 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 的测试覆盖。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权