参数绑定的三种失败:不适用、缺失与转换失败
分页接口的
page参数传成了abc,返回 404,排查了半天路由配置。另一个接口的请求头X-Tenant写错,返回的却是 400。同样是「参数不是数字」,两个状态码不一样,而且都和直觉不符。
把 HTTP 请求里的字符串变成方法参数,看上去只是一次类型转换,实际上要回答三个不同的问题:
- 这个来源适用吗:参数是从路径、查询串、请求头还是请求体取值。
- 值有没有提供:没提供时用默认值、null,还是报错。
- 能不能转换成目标类型:转换失败算谁的错,返回什么状态码。
三个问题的答案混在一起时,就会出现上面这种难以理解的状态码,或者更糟:转换失败时悄悄回退到别的来源,用一个错误的值继续执行。本文用 Jersey 4.0.3 验证 Jakarta REST 对这三件事的规定,并讨论自己实现参数绑定时怎样把它们分开。实验见文末;返回值与异常的部分见资源方法返回什么,客户端收到什么。
一、缺失:primitive 有默认值,包装类型是 null
@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
@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。
类型同时有 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 时自己校验。
- 值对象只提供一种字符串构造入口,或注册显式的转换器。
- 自己实现时把来源、缺失和转换分开,不让转换失败回退到其他来源。
配套实验
- codesphere-labs/design/jakarta-rest-behavior:Jersey 4.0.3、JDK 25.0.4.1:查询、路径、请求头参数的缺省与转换失败,转换方式的选择,
List<Integer>参数(验证记录)
参考资料
- Jakarta RESTful Web Services 4.0 规范:3.2 Fields and Bean Properties
- Javadoc:jakarta.ws.rs.ext.ParamConverter
- Javadoc:jakarta.ws.rs.DefaultValue