Skip to content

参数绑定的三种失败:不适用、缺失与转换失败 ​

分页接口的 page 参数传成了 abc,返回 404,排查了半天路由配置。另一个接口的请求头 X-Tenant 写错,返回的却是 400。同样是「参数不是数字」,两个状态码不一样,而且都和直觉不符。

把 HTTP 请求里的字符串变成方法参数,看上去只是一次类型转换,实际上要回答三个不同的问题:

  1. 这个来源适用吗:参数是从路径、查询串、请求头还是请求体取值。
  2. 值有没有提供:没提供时用默认值、null,还是报错。
  3. 能不能转换成目标类型:转换失败算谁的错,返回什么状态码。

三个问题的答案混在一起时,就会出现上面这种难以理解的状态码,或者更糟:转换失败时悄悄回退到别的来源,用一个错误的值继续执行。本文用 Jersey 4.0.3 验证 Jakarta REST 对这三件事的规定,并讨论自己实现参数绑定时怎样把它们分开。实验见文末;返回值与异常的部分见资源方法返回什么,客户端收到什么。

来源路径 / 查询 / 请求头不适用 ≠ 没传缺失策略默认值 / null / 报错int 缺省为 0转换构造器 / valueOf / fromString失败不回退到别的来源转换失败时(规范 3.2)@QueryParam / @PathParam / @MatrixParam404 NotFoundException@HeaderParam / @CookieParam400 BadRequestException缺失时int → 0;?n= → 0Integer → null@DefaultValue("5") → 5需要「格式错误返回 400」时:用 String 接收后自己校验,或改写 NotFoundException 的映射
图 1 · 实测(Jersey 4.0.3):缺失时 int 得到 0、Integer 得到 null,0 与未传无法区分;转换失败时查询和路径参数返回 404、请求头返回 400,这是规范的明文规定;同时有 String 构造器与 valueOf 时 Jersey 选 valueOf,规范本身没有规定先后。

一、缺失:primitive 有默认值,包装类型是 null ​

text
@QueryParam int 缺省:HTTP 200,body「int=0」
@QueryParam Integer 缺省:HTTP 200,body「Integer=null」
@DefaultValue("5") 缺省:HTTP 200,body「int=5」
@QueryParam int 传空串 ?n=:HTTP 200,body「int=0」

参数没有提供时,int 得到 0,Integer 得到 null,@DefaultValue 给出指定值;在这个实现里,传了空串的 int 也得到 0。这意味着资源方法无法区分「没传」「传了空串」和「传了 0」。分页、数量、金额这类 0 有业务含义的参数,用 primitive 类型就丢失了「缺失」这个信息。

需要区分时,用包装类型或 Optional 接收,在方法里明确处理 null;或者用 @DefaultValue 把默认值写在声明上,让缺失的含义可见。

二、转换失败:查询参数 404,请求头 400 ​

text
@QueryParam int 传 abc:HTTP 404
@PathParam int 传 abc:HTTP 404
@HeaderParam int 传 abc:HTTP 400
@QueryParam 的转换构造器抛 IllegalArgumentException:HTTP 404
@HeaderParam 的转换构造器抛 IllegalArgumentException:HTTP 400
@QueryParam List<Integer>,?x=1&x=b:HTTP 404

这是规范的明文规定:构造参数值时抛出的异常(WebApplicationException 除外),如果参数来自 @MatrixParam、@QueryParam 或 @PathParam,实现必须生成 404 NotFoundException;来自 @HeaderParam 或 @CookieParam 则生成 400 BadRequestException。

背后的理由是:路径和查询串是 URI 的一部分,「URI 里的值无法解释」被视为「没有这个资源」;请求头不属于资源标识,值不对就是请求本身有问题。理由可以理解,但对客户端并不友好:?page=abc 返回 404,调用方会以为接口地址错了。

如果希望查询参数的格式错误返回 400 并带上说明,有两种做法:用 String 接收再在方法里校验并抛出 BadRequestException;或者注册 ExceptionMapper<NotFoundException>,检查异常的 cause 是否来自参数转换,再改写成 400。前者更直接,也让「转换失败」成为资源方法可见的分支。

三、转换方式的选择 ​

规范列出了参数类型可以通过哪些途径从字符串构造:已注册的 ParamConverter、primitive、接受单个 String 的构造器、名为 valueOf 或 fromString 的静态方法,以及由这些类型组成的 List/Set/数组;同时有 valueOf 和 fromString 时用 valueOf,枚举则用 fromString。

text
类型同时有 String 构造器、valueOf、fromString:body「valueOf(7)」
只有 valueOf 与 fromString:body「valueOf(7)」
枚举(自带 valueOf,另写 fromString),传 h:body「HIGH」

规范没有规定构造器和静态方法之间的先后,Jersey 4.0.3 选择了 valueOf。如果一个值对象同时有这几种入口,并且它们的行为不同(例如构造器严格、valueOf 宽松),实际使用哪一个取决于实现。值对象最好只提供一种从字符串构造的入口,或者注册一个显式的 ParamConverter,让转换规则不依赖发现顺序。

枚举的特殊规则值得注意:每个枚举都自带 valueOf(按常量名精确匹配),另写 fromString 才能支持别名或大小写不敏感。写了 fromString 之后,按常量名传值的旧客户端是否仍然能用,取决于 fromString 的实现。

四、自己实现参数绑定时 ​

把三个问题拆成三个职责:

职责输入输出
来源(ValueProvider)参数的注解是否适用;适用时的原始字符串列表(可能为空)
缺失策略空列表、默认值声明、目标类型默认值、null,或「缺失」错误
转换(ValueConverter)字符串与目标类型转换结果,或带原因的转换失败

几个容易出错的地方:

  • 「不适用」和「缺失」要用不同的结果表示。一个 Optional<String> 同时表示「这个参数没有 @QueryParam 注解」和「有注解但请求里没传」,调用方就无法正确处理。
  • 转换失败不能回退到别的来源。如果绑定链是「先试转换,失败了再从容器里按类型取一个对象」,那么 ?n=abc 可能悄悄得到一个容器里的默认实例,错误被掩盖。失败就应该是失败。
  • 转换器抛出的异常不能被当作「不适用」。构造器存在但拒绝了输入(IllegalArgumentException),和构造器不存在,是两种情况:前者是客户端的错,后者是开发者的配置问题。
  • primitive 目标类型遇到 null:反射调用时传入 null 会在调用阶段失败,应该在缺失策略里给出默认值或报错。

这样拆开之后,测试也变成了三个独立的矩阵:每种来源 × 有值/无值;每种目标类型 × 合法/非法/空串/多值;以及少量经过真实请求的组合测试,验证状态码。

五、检查清单 ​

  • 0、空串、未提供有不同含义的参数,用包装类型接收并显式处理。
  • 知道查询参数与路径参数转换失败是 404、请求头是 400;需要 400 时自己校验。
  • 值对象只提供一种字符串构造入口,或注册显式的转换器。
  • 自己实现时把来源、缺失和转换分开,不让转换失败回退到其他来源。

配套实验

参考资料

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