同一个申请,为什么不让 HTTP 资源直接写 SQL

采购员提交一张申请,审批员决定是否通过,订单员才可以下单。第 01 章把路径限定为 DRAFT → SUBMITTED → APPROVED → ORDERED,或由 SUBMITTED → REJECTED;总额由服务端计算,同一申请最多一张订单。一个 Servlet 如果同时读取 HTTP 参数、判断审批状态、拼接 SQL,就把业务规则和传输协议绑在一起。将来从 REST 换成批处理,即使仍是同一次审批,也得重写判断;不启动服务器就很难验证非法状态是否被拒绝。单体不妨先划清边界,再决定是否增加运行进程。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

这里的“模块”有三层含义:Maven 构建模块决定编译依赖,Java 包与接口决定源码能看见哪些类型,WAR/EAR 是部署单元。这三层不自动对齐。把 domain 单独编成 JAR,不能阻止应用反射访问它;把服务放进不同 Maven 子工程,也不会凭空产生不同的运行时进程。第 03 章再处理部署与类加载,本章只用同一采购用例证明哪条依赖应该指向哪边。

从业务动作推导依赖方向

累计工程根 POM 列出 domain、application、adapters-jdbc、webapp 四个模块。目前的构建方向是 domain ← application ← adapters-jdbc ← webapp,webapp 还直接依赖 domain 与 application。箭头表示右边在编译时依赖左边,并非 HTTP 请求必定顺序经过四个对象。domain 只依赖 Java SE 类型与测试用的 JUnit;application 依赖 domain 和编译期的 Jakarta EE API;JDBC 适配器依赖 application;WAR 聚合三个业务 JAR。这种布局让最里面的状态规则不知道服务器名字、JNDI 地址和 SQL 方言。

业务核心的真实入口是 ProcurementRequest.java。它是 Java 21 record,构造时复制明细列表,用明细金额重算 total,不接受与计算值不等的总额,transition(next) 返回新值而非原地修改;合法边由 RequestState.java 决定。这样一个拿着旧 ProcurementRequest 引用的测试,不会因为调用 transition 就看到原引用的状态被修改;要检验结果,应保存返回值。record 是 Java 16 起正式提供的语言特性,本工程以 Java 21 编译,不能声称示例可在 Java 8 编译。

金额规则有意不相信输入的 total。LineItem 把单价标准化到两位小数,非零第三位小数会因 RoundingMode.UNNECESSARY 抛异常,数量不能小于一;ProcurementRequest 再把各行金额相加并与传入总额比较。这样错误的总额在进入端口前就被拒绝,HTTP 层不必复制一套浮点计算。但领域对象并不知道商品现在卖什么价,查价由应用层的 PriceCatalog 决定;同一 SKU 的报价规则若改变,替换实现即可复核用例,而不用在 record 里调用远程服务或读环境变量。价格目录还只是固定内存表,不能把它描述为真实企业采购的报价服务。

业务服务 ProcurementUseCases.java 收到的是租户标识、SKU 和数量,而不是客户端报价;它通过 PriceCatalog.java 查价格,由 FixedPriceCatalog.java 提供当前合成目录。RequestStore 位于应用层,描述插入、按申请 ID 与租户查找、变更状态和创建订单这些用例所需操作;它不是“DAO 必须使用 JDBC”的接口。真正的 JdbcRequestStore.java 从外向里实现它,以 javax.sql.DataSource 取得连接并执行 PostgreSQL SQL。javax.sql 是 Java SE API,不属于须从 javax 迁到 jakarta 的企业 API。

1
2
3
4
5
业务输入 → ProcurementUseCases → RequestStore(应用层端口)
↑
JdbcRequestStore / 实验用内存实现
业务不变量 ← ProcurementRequest + RequestState
容器入口 → CDI 装配 → 用例服务 → 端口实现

端口倒置的是源码依赖,不是执行方向。适配器当然调用端口的方法,但应用层不导入 blog.javaee.jdbc,否则内存实现仍要编译 JDBC 代码。另一方面,ProcurementUseCases 本身标记 @ApplicationScoped、@Inject 和 @Transactional,所以这一层并非完全不依赖 Jakarta API;真正纯粹脱离容器的是 domain。若要求连用例服务也与 Jakarta API 隔离,需再抽取无注解的服务并在外层做代理,这不是当前工程已经完成的改造。模块名不能代替对 import、POM 和运行入口的检查。

因此“核心可测试”有两个不同的判据。只运行 domain 的规则测试,可以验证状态机与金额算式,连端口都不需要;给 ProcurementUseCases 传入内存 RequestStore 和假价格目录,可以验证用例调用的顺序,但仍需要 Jakarta API JAR 参与编译,因为服务类上存在 Jakarta 注解。API JAR 在测试 classpath 可用,不等于启动了 CDI。若有人仅凭单元测试通过推论 @Transactional 已生效,反证只需检查用例对象是否由 new 构造:那条路径没有通过容器,注解不会凭字节码存在就给 Map 操作加事务。

用例约束怎样经过端口抵达数据库

draft(tenantId, items) 先查目录价格,再建立领域申请,最后交给 store.insert;submit/approve/reject 先 store.find(id, tenantId),由领域状态机拒绝非法边,应用层将错误边转换为 RequestConflictException,之后才调用 store.transition。order 在状态为 ORDERED 时尝试查回已有订单,首次下单时依次创建订单和变更状态。RequestStore 方法签名保留租户标识,使 JDBC 查询有机会按租户过滤;传入一个任意字符串不等于用户通过身份鉴别,角色和对象授权要在第 24–26 章补足。代码中既没有审批员身份校验,也没有可供调用的采购 HTTP 资源;当前 HealthResource.java 只返回 ready。

这里有两类不能混淆的“冲突”。状态机在内存中发现 DRAFT → APPROVED 本来就不合法,用例立刻抛业务冲突,存储层不应接到更新;另一类是两位审批人先后读到同一个 SUBMITTED 版本,第二位在写入时发现记录已变。实际 JdbcRequestStore.java 的 transition 以 id、tenant_id、原 status 和原 version 为条件执行 UPDATE,成功才递增版本,更新行数不是一就抛 RequestConflictException。这是通过数据库条件写入拒绝陈旧快照,不是 JVM 里的 synchronized。内存替身若只维护一个 Map,能检查非法状态,却无法模拟两个数据库连接同时读取、提交和竞争同一版本;验证第二种冲突还要真实数据库及控制交错的测试。

find(id, tenantId) 的 JDBC 查询同时匹配两个值,查不到时抛 RequestMissingException;这只隔离调用者给定的租户视角。若任意请求能自行提供 tenantId,攻击者仍可声称属于别的租户。业务规则、SQL 条件、受管身份是三个不同的检查点:前者限定状态变化,第二个限定查询命中的记录,第三个决定谁有资格以该租户和角色发起操作。安全章节还需验证请求身份如何转成可信的租户、审批员权限如何检查、拒绝时是否泄漏对象存在性;纯 Map 替身永远无法为这些问题提供容器证据。

端口与持久化协议还有一道不能跨过的界线。真实 JDBC 实现把领域规则变成条件更新及数据库操作;数据库里应核对状态、版本与唯一订单,而不是相信 record 在 JVM 内检验过就能防两个并发请求。初始迁移 中订单申请唯一键是最后一道重复行防线,不保证所有并发调用都返回同一结果。@Transactional 注在受管服务方法上,只有通过受管入口与合适的事务资源执行,才能讨论回滚范围;直接 new ProcurementUseCases(...) 没有 CDI 拦截器。内存替身验证的是业务选择和调用约定,不是 Jakarta Transactions 的回滚、数据库隔离或 JDBC 连接关闭。

例如 order 先查申请,在首次下单时调用 insertOrder,再调用 transition。如果两个写入并非同一数据库事务,更新状态失败后可能已经留下订单;即便是同一事务,还要检查受管连接是否实际参加事务、失败异常是否触发回滚及数据库最终行集合。insertOrder 对申请 ID 用 ON CONFLICT ... DO NOTHING RETURNING id,冲突后再按申请 ID 与租户查询已有订单;这既不等于跨租户可以看到订单,也不等于每条竞争路径都已经验收。把这段时间线带到第 18–23 章,用真实库证据回答“订单存在而状态未更新时如何恢复”,比在内存替身里写一个永远成功的 insertOrder 更能界定边界。

容器装配也应按已存在的实现说话。webapp/pom.xml 的 WAR 聚合用例和适配器;beans.xml 位于 WEB-INF。FixedPriceCatalog 和 JdbcRequestStore 是带范围注解的候选对象,用例构造器的 @Inject 在容器中要求为两个端口各找到适用 Bean。内存替身不应也作为未加限定的生产 Bean 装进同一个 WAR:两个 RequestStore 实现同时可选,注入可能歧义;这个故障和实际安装了两个数据源不是一回事。CDI 候选 Bean、限定符与生命周期留待第 04 章展开。

基础设施不是全无证据。已有 writing-plans/javaee-enterprise/verification/20261004T061900Z-pg16-foundation/ 原始记录:Maven clean verify 的 reactor 五项 SUCCESS、总计 BUILD SUCCESS;Open Liberty 26.0.0.5 启动日志显示应用 procurement 已启动;/procurement/api/health 和 /procurement/api/db-check 的响应头均为 HTTP 200,响应体分别是 ready 与 connected,探针脚本退出码均为零。DatabaseCheckResource 用受管 jdbc/Procurement 执行 SELECT current_database() 并断言是 javaee_lab,所以这个结果比健康接口多证明了一次到该库的读取。它仍未触达 ProcurementUseCases、申请表的增删改或并发审批,不能把它变成内存端口测试或者完整采购事务的通过证据。

验证边界时最好保留三个互不替代的输出:测试报告显示领域规则是否拒绝非法边;mvn dependency:tree 显示当前模块从何处引入技术依赖;真实服务器的日志、回包和数据库行集合则用于检查注入、事务与业务副作用。前两者若成功,只说明源码结构与本地调用路径达到各自断言;它们没有“启动应用并访问真实采购入口”这个动作。第三者在当前没有采购 HTTP 入口的情况下也不能造一个空请求来凑数。缺口应明列到下一章的实验计划,而不是改名叫“端到端已验证”。

无服务器实验:正常路与非法边

现成 ProcurementRulesTest.java 在 domain 模块检查总额为 7.00、DRAFT → APPROVED 抛错,以及合法路径与拒绝路径。它没有调用 ProcurementUseCases,不能写成“已经跑通内存端口”。以下是拟新增、未写进累计工程且 NOT_RUN 的用例测试设计:在 application/src/test/java/blog/javaee/application/ 放一份完整 JUnit 类,用 Map 实现 RequestStore;其中 find 必须同时比对 ID 与租户,transition 保存返回的新申请。内存实现不维护数据库版本和事务,只负责暴露当前调用顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package blog.javaee.application;

import blog.javaee.domain.ProcurementRequest;
import blog.javaee.domain.RequestState;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class ProcurementUseCasesBoundaryTest {
static final class MemoryStore implements RequestStore {
final Map<Long, ProcurementRequest> requests = new HashMap<>();
@Override public long insert(ProcurementRequest request) {
requests.put(1L, new ProcurementRequest(1L, request.tenantId(), request.version(),
request.state(), request.total(), request.lines()));
return 1L;
}
@Override public ProcurementRequest find(long id, String tenantId) {
ProcurementRequest request = requests.get(id);
if (request == null || !request.tenantId().equals(tenantId)) {
throw new RequestMissingException();
}
return request;
}
@Override public void transition(ProcurementRequest request, RequestState next) {
requests.put(request.id(), request.transition(next));
}
@Override public long insertOrder(long id, String tenantId) { return 10L; }
@Override public long findOrder(long id, String tenantId) { return 10L; }
}

@Test void validAndInvalidPaths() {
MemoryStore store = new MemoryStore();
PriceCatalog prices = sku -> {
if (!"paper".equals(sku)) throw new IllegalArgumentException("Unknown product");
return new BigDecimal("3.50");
};
ProcurementUseCases useCases = new ProcurementUseCases(store, prices);
long id = useCases.draft("tenant-one", List.of(new ProcurementUseCases.ItemInput("paper", 2)));
assertEquals(0, new BigDecimal("7.00").compareTo(store.find(id, "tenant-one").total()));
assertThrows(RequestConflictException.class, () -> useCases.approve(id, "tenant-one"));
assertEquals(RequestState.DRAFT, store.find(id, "tenant-one").state());
useCases.submit(id, "tenant-one");
assertEquals(RequestState.SUBMITTED, store.find(id, "tenant-one").state());
assertThrows(RequestMissingException.class, () -> useCases.approve(id, "other-tenant"));
assertEquals(RequestState.SUBMITTED, store.find(id, "tenant-one").state());
}
}

内存 insert 不仅返回 ID=1,还保存 ID=1 的对象。否则 transition 按旧 ID=0 写入另一条 Map 记录,正常路径的断言会失败。替身需要模拟这项端口约定,却不必模拟数据库分配键的完整机制。这个测试类需要在 application/pom.xml 中额外加入已由父 POM 管理版本的 org.junit.jupiter:junit-jupiter、test scope;当前文件尚无此测试依赖,不能直接把片段粘进去就宣称 Maven 会运行它。

这份类只给一张申请分配 ID=1,明确限定为单样例用例;若再加第二张申请,它会覆盖 Map 中的第一张,不能把它当作通用内存数据库。需要验证第二笔时应给替身增加单调递增键及重复订单记录,并把“先分配主键,再根据主键查询”写进端口合同。同时要意识到 MemoryStore 的 find 在抛异常时不会写日志或获得连接,这与真实 JDBC 的资源释放和异常转换链不同。故障注入应逐层选准对象:非法状态可在此验证,数据库不可用与读写回滚必须在另一个实验层级验证。

复跑顺序是在隔离工作副本中加入测试和测试依赖,从 examples/javaee-enterprise/ 运行 ../hibernate-lab/mvnw -B -ntp -pl application -am test;预期命令退出码为零、金额 7.00、非法状态与跨租户访问各抛对应的应用异常,数据库和服务器都不必启动。再把内存 insert 改为只保存未赋 ID 的 request 作对照,预期正常路径断言失败;最后运行 ../hibernate-lab/mvnw -B -ntp -pl webapp -am dependency:tree 查看方向。不能把依赖树命令成功等同于不存在违反设计的依赖:若加入反向模块依赖形成实际 Maven 循环,须重新构建并观察 reactor 报错。这些新增测试与故障注入没有本章留存的原始输出,状态均为 NOT_RUN;已有基础 clean verify 则只对它所实际运行的领域测试负责。输入、步骤、期待的错误与实际结果空栏见实验与阅读卡。

两道练习与答案

练习一:内存替身在 insert 时只返回 ID=1,却把 draft() 返回的 ID=0 的对象直接保存。随后 submit(1, tenant-one) 调用 transition 按对象 ID 回写。最终 find(1, tenant-one) 是什么状态?这说明测试应模拟端口的哪一部分?

答案:调用 transition 会把变更后的对象放到键 0,键 1 保留旧的 DRAFT;测试中 assertEquals(SUBMITTED, find(1,...).state()) 必须失败。替身至少要重现该用例依赖的 ID 分配与键保持语义,不能只保证接口方法签名都实现了。分配 ID 后仍不能据此宣称数据库已提交。

练习二:把 JdbcRequestStore 和一个不加限定符的 MemoryStore 一起放进 WAR,两者均作为 RequestStore 的 CDI Bean,然后删掉业务测试中的 new 改用容器注入。哪一层会首先暴露问题?如何修改且不把“构建成功”当成部署证据?

答案:单纯 Maven 依赖树仍可能合法,容器解析 @Inject RequestStore 时会出现多个匹配候选;应将内存替身限制在测试 classpath,或通过限定符明确生产实现。用真实 WAR 启动并访问实际注入了该端口的受管入口、检查部署诊断和业务结果,才能验证容器选择了目标 Bean;已有健康与数据库探针不注入 RequestStore,不能作为该路径证据。若临时污染 WAR 做失败实验,恢复原包后要再次构建并逐项核对包内库。

边界与资料

此结构解决源码隔离和替身测试的准入,不解决租户认证、数据库并发、容器事务传播或生产发布。把一份内存测试的通过推广成“采购下单不重复”会越过数据库唯一键、并发时间线和最终账本三道证据。先行 JPA 00:Provider 与启动 使用同一类采购语义但独立的 jpa_lab 和 RESOURCE_LOCAL,它的 JPA 验收不能替代这里的 JDBC 与受管容器实验。

当用例测试、数据库测试与容器请求最终都具备时,应分别保留对应的输入和失败判据,而不是把某次 BUILD SUCCESS 复用到所有层级。尤其是受管请求和直接构造对象走不同的调用入口,前者的成功不能自动推出内存替身遵守端口合同,后者的成功也不能推出服务器已经完成授权检查。

版本依据:Jakarta EE Platform 11.0 §8.1、§8.2、§8.3 区分模块与可见性;Jakarta CDI 4.1 与 Jakarta Transactions 2.0 分别约束注入与受管事务;Java SE 21 javax.sql 说明 DataSource 的包归属。这里的业务流程与 Maven 四模块布局是工程选择,不是 EE 11 强制的四层架构。GitLab master 链接是工程源码入口,当前未推送文件须待合并后核对,不把站点页面相对路径冒充源码链接。