查询“某租户在指定时段可租的相机”需要同时理解类别、设备资格、取消历史和半开租期。把它改写成几个通用 findAll 调用,很容易漏掉条件。企业应用架构05:Active Record与Data Mapper 已完成合同落库,本篇在真实数据集上区分业务集合查询、外部资源访问和查询条件表达。

三个名字对应不同职责

Fowler 的 Repository 在领域与数据映射之间提供接近集合的访问方式,调用者按领域需要寻找对象。Gateway 封装外部资源的访问接口,让其调用方式适合本地代码。Query Object 则用对象结构表达查询,并把结构解释成数据库查询。它们可以协作,不能靠统一加一个 Repository 后缀消除区别。

本例 EquipmentRepository 提供 available,返回满足规则的设备身份集合。AvailabilityQuery 固定表达租户、类别、租期和排序,再生成参数化 SQL。InventoryGateway 读取另一份实际 H2 数据库中的仓库库存。前两者处理租赁数据,后者封装独立资源的连接和查询方式。

flowchart LR
  U[租赁查询用例] --> Q[AvailabilityQuery:条件与编译]
  Q --> P[SQL计划与绑定参数]
  U --> R[EquipmentRepository:可租集合]
  R --> P
  P --> D[租赁数据库]
  U --> G[InventoryGateway]
  G --> X[独立库存数据库]

Table Data Gateway 通常围绕一张表组织访问,本例没有另外伪造一个同名包装层。现有 ContractMapper 关注行与对象转换,仓储关注业务集合,库存 Gateway 关注资源接口。一个实现文件可以组合多个职责,但应说明选择,而不是为了凑齐术语堆出一串转发类。

可租查询先固定边界

AvailabilityQuery 要求非空租户、非空类别和有效租期;缺少这些条件直接拒绝。排序只能取设备身份升序或降序。它没有开放任意字段、任意运算符或任意 SQL 片段,因此是有意受限的查询对象,不能称为完整查询语言。

本章 available 的确切含义是:目标租户内、类别匹配、状态 ACTIVE,且没有与目标半开区间重叠的 RESERVED 合同。维修端口和提前量没有加入这条查询;返回的是候选设备身份,最终预约仍需经过完整申请用例。若把查询结果当成一定能够确认的承诺,会跨过并发与资格检查的边界。

查询对象的 compile 方法返回 SQL 与不可变参数列表。租户、类别和时间通过占位符绑定;只有经过枚举选择的 ASC 或 DESC 进入 SQL 文本。SQL 参数只能替代数据值,不能用一个问号安全地表示任意列名或排序语法,因此排序字段必须另做白名单处理。

仓储执行这个查询计划,把每行转换成 EquipmentId,返回不可变列表。它不把 ResultSet 返回给上层,也不要求调用者理解驱动类型。连接由会话所有者管理,语句和结果集在查询方法内部关闭,查询结束后已经没有依赖游标的延迟遍历。

用已知数据集核对每个条件

固定数据集包括 T1 的四台相机、T2 的一台相机,以及类别文字含引号的一台设备。T1 的 E1 已有重叠租约,E2 有两份取消历史,E3 的预约恰好从目标区间结束时开始,E4 已退役。目标结果明确为 E2、E3,并按身份排序。

E3 检查半开区间的相邻边界。数据库条件使用“已有开始小于目标结束,且目标开始小于已有结束”,等号不会算重叠。把任一比较改成小于等于,E3 就会被错误排除。这里沿用既有区间定义,没有临时发明一个缓冲清洁时段。

E2 检查历史与当前占用的区别。取消记录必须保留,但不再占用区间。如果直接把设备与全部合同做连接,同一设备可能出现两行;如果只看是否存在任何历史合同,取消过的设备又会永远无法出租。查询使用 NOT EXISTS 判断有效占用,不按历史记录数量复制设备行。

flowchart TB
  E[设备候选行] --> T{目标租户与类别}
  T -->|满足| A{设备 ACTIVE}
  A -->|满足| O{存在 RESERVED 且区间重叠的合同}
  O -->|存在| N[排除]
  O -->|不存在| Y[返回一个设备身份]
  C[取消历史不参与占用] -.-> O
  B[结束等于目标开始不重叠] -.-> O

这个结果集合是可核对的预期,不是从查询自身再算一遍“正确答案”。测试直接写出 E2、E3,并另检查去重后的数量不变。升降序分别断言,避免测试只比较集合而遗漏接口承诺的稳定顺序。

让错误查询真实返回错误结果

三个负例保留合法 SQL,分别只去掉租户过滤、去掉重叠时间条件,以及额外连接取消历史。它们都会在真实数据库中执行,随后用相同预期结果检查。遗漏租户会混入 OTHER;遗漏时间会把相邻的 E3 排除;历史连接会返回重复的 E2。

这些负例的入口最终退出一,原因是业务结果断言失败,错误日志可以看到实际设备列表。没有用语法错误或不存在的表冒充查询规则失效。正常入口仍退出零,说明执行环境和表结构本身可用。

空租户、空类别、缺少区间和非法排序在执行 SQL 前被拒绝。另一个类别输入特意使用 camera' OR '1'='1,数据库中也有这个完整文字值。查询只返回 QUOTE 这一台,而不会扩大成所有设备,验证绑定参数确实把引号当成数据。

参数化解决的是值与语法的分离,不自动修复遗漏租户、错误连接或错误区间。那三条危险查询同样可以完整使用参数绑定,却仍然返回错误业务结果。因此安全参数测试和集合语义测试都需要保留,不能互相替代。

本章租户字符串由可信测试输入提供,没有连接登录用户与租户的认证过程。虽然遗漏过滤能被数据集检测,仍不能称为完整的多租户隔离证明。应用身份、数据库权限、跨租户写入和后台任务都需要独立验证。

库存 Gateway 的范围更窄

InventoryGateway 的 JDBC 实现为每次查询打开独立库存数据库连接,读取仓库与类别对应的 units,再关闭连接。它与租赁数据库使用不同文件,查询确实跨过资源边界;这不是给同一个 Map 换接口名字。

测试向库存库写入 W1 的三台相机,通过 Gateway 读回三,再检查租赁库的六台设备没有变化。库存数字没有自动与本地设备身份建立对应关系,也没有据此确认某一台具体设备可以预约。资源接口的结果语义需要明确,不能把“库存三台”直接解释为本地三个身份可用。

该 Gateway 只是 JDBC 对外部资源的最小实现,没有 HTTP、鉴权、超时重试或分布式事务。缺少库存行时明确失败,不默认为零;零库存与来源没有记录是不同事实。若未来提供远程版本,需要冻结协议和错误映射,仓储的业务查询不应承担这些通信细节。

查询对象也需要退出条件

只有两个固定查询时,清楚的 finder 方法可能已经足够。本例增加查询对象,是为了让必填条件、排序白名单与 SQL 参数顺序集中可见。它仍然为同一业务问题生成一种查询形状,没有为了支持未知需求开放任意嵌套逻辑。

新增类别值无需修改 SQL,因为它是绑定数据;新增排序能力要更新枚举与编译规则;改变占用含义则会影响业务查询本身。把这些变更原因放在可定位的位置,比抽象成一个接受任意字符串的万能查询入口更容易审查。

查询次数也不是本章性能结论。当前数据量固定,H2 运行在同进程,本地结果不能推导远程数据库延迟。生产规模还需要索引、执行计划、分页和统计信息验证;本篇只证明已列条件下的实际返回集合与资源关闭。

返回集合不能代替预约提交

查询完成后,另一连接可能提交新的预约。调用者拿到的候选列表只反映查询当时可见的状态,并没有为设备加锁,也没有占用名额。如果界面选择设备后直接插入合同,两个客服可能同时选择同一台;需要在提交路径重新检查并采用适当并发协议。

资源生命周期也影响接口含义。这里返回的是已读取完成的身份列表,因此连接关闭后仍能遍历。如果改成流式 ResultSet 包装,调用者就要同时承担关闭义务和事务可见性问题,不能保持相同方法名却悄悄改变资源契约。当前实现明确采用完成读取后返回的方式。

已知数据集仅覆盖被列出的反例。查询新增条件后,需要给固定数据集增加能区分正确与错误实现的行;只增加更多同类可租设备,通常不能检查新条件是否真正参与 SQL。

运行与证据

使用上一章冻结的 JDK、H2 与 schema-v1,在仓库或解压根目录执行:

1
bash examples/enterprise-application-architecture/labs/06/run.sh
本章实验包 包含前置源码与 SQL 支持。正常入口执行八项检查;命令末尾追加 `--omit-tenant`、`--omit-time` 或 `--duplicate` 分别运行预期失败查询。原始 SQL、绑定值、连接编号和结果断言保存在 evidence/06。

所有查询都经过实际 JDBC 执行,三个负例的退出码均为一。正常查询结束时语句计数归零,Gateway 的独立连接也正常关闭。原始冻结核心没有被修改,当前查询模块尚未替换原租赁应用的内存仓储。

参考资料