请求体不是领域对象:命令、身份与可信上下文
下单接口直接用
@RequestBody Order接收请求,省掉了一个类和一段转换代码。客户端只传商品和数量,租户和下单人在控制器里补上。功能都正常。直到有人在 JSON 里多带了几个字段。
把请求体直接绑定到领域对象,看起来是在减少重复:反正字段差不多。问题在于这两类对象回答的是不同的问题。领域对象描述「这个订单是什么」,它的每个字段都有规则决定由谁写入;请求体描述「客户端想做什么」,它只应该包含客户端有权决定的那部分。把二者合并,等于默认客户端有权决定领域对象的所有字段。
本文用同一个下单接口的两种写法、两种读取方式和一个表单,看客户端多带的字段、别人的 id 分别会怎样。实验在 Spring Framework 7.0.9 上用 MockMvc 运行,见文末。
一、字段由谁决定,是一条业务规则
先把订单的字段按「谁有权决定」分开:
| 字段 | 由谁决定 | 来源 |
|---|---|---|
| 商品、数量 | 客户端 | 请求体 |
| 租户、下单人 | 系统,根据已认证的身份 | 认证之后的请求上下文 |
| 状态 | 领域规则:新订单是 NEW,只能通过支付变成 PAID | 聚合的方法 |
| id、创建时间 | 系统 | 仓储、时钟 |
这张表本身就是需求的一部分。它通常不会写在需求文档里,但每个字段都有一个答案;接口设计要做的是让代码只接受第一行的字段来自客户端。
二、直接绑定领域对象:客户端决定了一切
@PostMapping("/v1/orders")
String place(@RequestBody Order o, @RequestAttribute("tenant") String tenant) {
if (o.tenantId == null) o.tenantId = tenant; // 「补上」租户
orders.save(o);
}客户端在 JSON 里多带了 tenantId、ownerId 和 status:
请求体直接绑定到领域对象,客户端在 JSON 里带上 tenantId、ownerId、status:保存的是 Order[tenant=t-bob, owner=bob, sku=A1, qty=2, status=PAID]当前登录的是 t-alice 租户的 alice,保存下来的订单却属于另一个租户的另一个人,而且直接是已支付状态。反序列化器只做一件事:JSON 里有什么字段、对象上有对应的属性,就写进去。它不知道哪些字段是客户端无权决定的。
「如果为空再补上」是这段代码最危险的地方:它看起来处理了身份,实际上只在客户端没传时才生效。改成无条件覆盖能挡住租户,但挡不住 status,也挡不住以后新增的字段。这类问题的根源是允许列表方向反了:领域对象有多少字段,客户端就能写多少字段,代码要逐个想起来去覆盖。
三、命令对象:只包含客户端有权决定的部分
record PlaceOrder(String sku, int qty) { }
@PostMapping("/v2/orders")
String place(@RequestBody PlaceOrder cmd,
@RequestAttribute("tenant") String tenant, @RequestAttribute("user") String user) {
Order o = Order.place(tenant, user, cmd.sku(), cmd.qty()); // 状态由工厂方法决定
orders.save(o);
}绑定到只含 sku、qty 的命令对象,身份取自请求上下文,同样的 JSON:保存的是 Order[tenant=t-alice, owner=alice, sku=A1, qty=2, status=NEW]同样的 JSON,多出来的字段没有地方可以落。命令对象把第一节那张表的第一行变成了类型:新增一个领域字段时,它默认不会出现在命令里,要有人主动决定「这个字段客户端能不能传」,并把它加进去。
命令的名字也是有意义的。PlaceOrder、ChangeShippingAddress、CancelOrder 各自只带这个动作需要的数据,对应聚合上一个表达意图的方法。一个通用的 OrderDTO 用来创建、修改、取消,就又回到了「所有字段都能传」。命令与查询分开建模的更多理由,见 CQRS 不必过度设计。
身份从哪里来。 租户和用户来自认证之后的上下文:认证过滤器验证凭据,把结果放进请求属性或安全上下文,控制器从那里读取。请求体、查询参数、自定义请求头都是客户端写的,不能作为身份的来源。上下文怎样在线程之间传递、用完怎样清理,见参数放在哪里:请求数据、依赖与上下文。
四、读取:按 id 查找不等于有权读取
写入时守住了身份,读取时还有一个入口:
租户 t-alice 的用户按 id 读取 t-bob 的订单,只按 id 查:Order[tenant=t-bob, owner=bob, sku=B9, qty=1, status=NEW]
按(租户,id)查:not foundfindById(id) 只回答「这个 id 的订单是什么」,不回答「当前用户能不能看」。id 是自增整数时,换一个数字就能读到别人的订单;换成 UUID 只是更难猜,并不改变「没有检查归属」这个事实。
修法是把归属放进查询条件:仓储提供 findByTenantAndId(tenant, id),而不是先查出来再在控制器里比较。前者让「按租户隔离」成为仓储接口的一部分,忘记检查的写法根本不存在;后者每个调用方都要记得比较。找不到和无权访问都返回 404,也避免告诉对方「这个 id 存在」。
这和聚合的边界有关:租户是很多聚合的外层范围,在仓储层面就应该体现出来。更细的权限(同一租户内谁能看哪个订单)属于授权规则,可以放在应用服务里,但查询仍然要先限定在租户内。
五、表单绑定也一样
不只是 JSON。表单和查询参数绑定到带 setter 的对象时:
表单绑定到有 setRole 的对象,请求参数里多带 role=admin:nickname=al, role=admin
在 @InitBinder 里只允许 nickname:nickname=al, role=memberSpring 的数据绑定会把同名的请求参数写进对象的属性。WebDataBinder.setAllowedFields 可以限定允许绑定的字段,但它是一个需要记得配置的防线;更稳妥的仍然是让绑定目标只有允许的字段,不可变的 record 或只有允许属性的表单对象都可以。
六、什么时候可以直接绑定
命令对象多了一个类和一段转换代码。下面这些情况,直接绑定的风险可以接受:
- 对象的所有字段确实都由客户端决定,并且没有身份、状态、价格这类系统字段。比如一个纯粹的搜索条件对象。
- 内部工具或批处理,调用方本身是可信的系统,且输入不来自外部。
反过来,只要对象里有一个字段是「客户端不能决定」的,就不应该直接绑定。领域对象几乎总是这样:它的意义就在于有规则决定字段由谁写入。
转换代码重复时,可以把它放进命令对象自身的方法或聚合的工厂方法里,而不是引入通用的映射工具:自动映射会把同名字段全部复制过去,又一次绕开了第一节那张表。层与层之间的对象转换,见分层、应用服务与仓储。
七、测试要证明什么
- 多带字段的测试:请求体里加上租户、下单人、状态,断言保存下来的值来自上下文和规则,而不是请求。这条测试在新增领域字段时仍然有效。
- 跨租户读取的测试:用 A 租户的身份读 B 租户的 id,断言 404。
- 表单绑定的测试:多带不允许的参数,断言没有被写入。
只测正常的请求,这三类问题都不会暴露:功能完全正确,问题只在多出来的输入上。
八、检查清单
- 列出领域对象每个字段由谁决定;客户端只能决定其中一部分。
- 写接口绑定到按动作命名的命令对象,只包含客户端有权决定的字段。
- 身份只取自认证之后的上下文,不取自请求体、参数或自定义请求头。
- 仓储按(租户,id)查询;找不到与无权访问都返回 404。
- 表单绑定的目标只有允许的字段;需要时用
setAllowedFields加一道防线。 - 测试覆盖多带字段和跨租户访问。
配套实验
- codesphere-labs/ddd/command-and-identity:Spring Framework 7.0.9、Jackson 3.2.3、JDK 25.0.4.1,MockMvc:请求体绑定到领域对象与命令对象、按 id 与按(租户,id)读取、表单绑定与
setAllowedFields(验证记录)
参考资料