persist 能编译,却不一定能写入一行

采购申请调用 entityManager.persist(request),编译成功只能说明 Java 类型与注解可用;EntityManagerFactory 创建成功,也不等于 PostgreSQL 里已经有申请行。依赖解析、Provider 发现、获取 JDBC 连接、映射校验、事务提交分别可能失败。判断一套 JPA 程序是否启动,首先要说明“启动”指哪一层,再用该层的证据验证。这里用采购审批累计工程中实际存在的入口,而不是搭建另一份互不兼容的演示数据库。

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

本系列的流程是 DRAFT → SUBMITTED → APPROVED → ORDERED,或 SUBMITTED → REJECTED;只有 APPROVED 可以创建订单。金额由明细计算,一张申请最多一张订单,租户不能跨界访问,重复请求不得重复创建。启动实验只建立第一条申请,并不能凭一条 INSERT 证明所有这些约束。与 Java EE/JDBC 主线共享业务语义,却使用独立的 jpa_lab 数据库;examples/jpa/ 使用实体、Provider 和 RESOURCE_LOCAL 事务,不能将它的结果称为 JDBC 主线的测试通过。

API、实现和物理库各负其责

Jakarta Persistence 3.2 给出 Persistence、EntityManagerFactory、EntityManager、实体映射与事务的契约(规范 §3.2、§7.3.2、§8.2、§9.2)。jakarta.persistence 是 API 名称,不是驱动,也不是内置数据库。在当前工程,Hibernate ORM 7.1.36.Final 实现契约,pgJDBC 42.7.7 负责 PostgreSQL JDBC 连接,服务端为 PostgreSQL 16。JPA 规范决定持久化上下文中的身份及事务同步语义,却不规定 Hibernate 必须生成某个固定的 INSERT 文本;PostgreSQL 的表约束属于数据库层,不会因写了 @Entity 自动变成租户权限过滤器。

Java SE 通过名为 procurement 的 persistence unit 创建工厂。它的定义在 src/main/resources/META-INF/persistence.xml,声明 RESOURCE_LOCAL、三个实体类,以及 Hibernate 专用的 hibernate.hbm2ddl.auto=validate。后者只做 schema 校验,不执行迁移;已有表由 db/migrations/01-init.sql 创建。RESOURCE_LOCAL 由应用调用 EntityTransaction.begin/commit/rollback 控制本地事务(规范 §7.5.2–7.5.3),不是 JTA 容器注入,更不自动把订单数据库提交和外部消息原子化。显式区分三种东西:procurement 是单元名,jpa_lab 是数据库名,hibernate.hbm2ddl.auto 是实现属性。迁移是否成功、账号是否可以写入和 Provider 能否启动,都不能由同一个名字推出来。

实际映射 ProcurementRequest.java 使用数据库 identity 主键、@Version 版本、@Enumerated(STRING) 状态、服务端以 BigDecimal 汇总的金额和明细关联;迁移文件对应 purchase_request、purchase_line、purchase_order 及订单申请唯一键。因此,persist() 后不能假定 ID 一定要等提交才分配:在这个特定映射下 Provider 可能为获得数据库生成键提前执行 INSERT。反过来,拿到 ID 仍不能证明事务已提交。addLine("paper", new BigDecimal("2.25"), 3) 在 Java 侧算出 6.75;另用数据库的新上下文读取,才能确认映射与提交确实传递了该金额。单看 Java getter 只能证明计算做过。

从真正的代码入口走到数据库

工程入口为 examples/jpa/ 源码树,具体看 pom.xml、persistence.xml、迁移脚本、DatabaseSupport.java 与 ProcurementJpaTest.java。这些是源码路径入口,不把 ../../examples/jpa 当成 Hexo 页面 URL;站点未发布相应源码时,GitLab 链接须在合并到目标分支后核验。

实际的 DatabaseSupport.factory() 读取 JPA_LAB_JDBC_URL、JPA_LAB_USER、JPA_LAB_PASSWORD,缺任何一项即失败;URL 必须以 /jpa_lab 结尾,再把 jakarta.persistence.jdbc.url/user/password/driver 交给 Persistence.createEntityManagerFactory("procurement", ...)。这是当前测试对误连库的额外防线,不是 JPA 规范要求每个 Provider 都自行限制库名。它检查后缀,却不能代替账号权限、端口核查或独立数据库审计。生产密钥不应提交到代码或日志;运行示例中的口令只表示本地实验角色。

核心测试的实际入口是 blog.jpa.ProcurementJpaTest#lifecycleAndServerCalculatedTotal,JUnit Jupiter 5.12.2 运行它;完整可编译类及 imports 均在上面的源码文件。该方法首先 DatabaseSupport.tenant() 生成隔离测试数据,接着创建申请并 addLine,在同一上下文 persist、find 后用 assertSame 检查引用,调用 commit,再 clear 并 find,用 assertNotSame 和金额 6.75、一条明细的断言验证重新装载。测试退出 0 能证明此组操作在冻结工程和本次环境下可运行;assertSame 只验证同一上下文实例,金额和明细是重新装载结果,不要反过来声称所有映射关系或所有数据库版本均已验收。

启动链的每一跳都有不同的可证伪条件。若 Maven 找不到依赖,根本没有进入 persistence unit 装配;若 META-INF/persistence.xml 路径不对,工厂发现失败;若 JDBC URL 错,创建工厂或首次访问数据库时出错;若映射列名与表不一致,validate 可能拒绝启动。该工程首轮尝试就因 purchase_line.unitPrice 与数据库列 unit_price 不一致失败,修成显式 @Column(name="unit_price") 后重新构建通过;原始首轮 stdout 未归档到通过证据,不应把这段过程当成一个具备完整原始文件的独立负向实验。解决办法不是关掉 schema 校验,而是让映射与迁移对齐。

persistence.xml 的 RESOURCE_LOCAL、类列表和 <properties> 并不在库中创建表。迁移命令须先由专用账号执行,随后再运行 Java 测试。工程 README 列出命令,下面按实际证据卡的工作目录复述;运行前确认 jpa_lab 是专门创建的 PostgreSQL 16 库,不能把迁移脚本交给生产库,也不能把现存的 Java EE 累计工程库改造成 JPA 库。

这里的“启动”可以按三个观察对象继续拆细。Maven 解析 <dependency> 并编译,回答工程能否装配;JPA 读取 META-INF/persistence.xml 并构建 EntityManagerFactory,回答单元声明和 Provider 能否协作;实际创建申请、提交并重新加载,回答映射、DDL 与事务是否协作。第一步成功不保证 XML 单元名正确,第二步工厂成功不保证用户能插入数据库,第三步能写申请也不代表订单唯一约束或租户检查已验证。排障若只存一张 BUILD SUCCESS 截图,升级版本时难以定位失败层。完整试验需保存 Java 与 Maven 版本、数据库版本、依赖树、迁移 SQL 版本、退出码及原始 stdout。

为什么 validate 没有代替数据库迁移?hibernate.hbm2ddl.auto=validate 是 Hibernate 配置项,不属于 Persistence 3.2 的建表 API。它检查模型与现存结构的一些对应关系,但不负责把旧表调整到新版本;即便校验通过,也不能推出状态 CHECK、唯一键、索引定义与预期完全相同。真实 DDL 写明订单表的 request_id 唯一约束,应用同时在订单创建时检查状态。数据库唯一键能抵御并发重复写入,应用的状态方法给出业务拒绝;两者处理的失败类别不同。若把 Hibernate 私有校验当成迁移工具,读者可能为“修复”缺列而打开自动更新选项,使发布 DDL 不受审查。这里先迁移再启动,是为了把可审查的表变更与运行时模型检查分开。

DatabaseSupport.factory() 在测试类 @BeforeAll 间接创建一个工厂,测试为业务操作分别创建 EntityManager。辅助类要求环境变量非空,校验 URL 后缀;却没有检查实际服务端的 PostgreSQL 版本。证据卡记录 PostgreSQL 16.15,这是运行环境的信息,不是应用自行验证出的断言。工厂可以存在较长时间供多个工作单元创建上下文,但 EntityManager 是承载一份上下文的可变入口,规范 §7.2 不允许假定它在线程之间安全共享。将工厂和上下文一并放入全局单例,会把不同请求的数据和尚未提交的字段修改混入一份身份映射。各测试单独创建和关闭上下文,才能把一次提交与下次独立读取画出边界。

1
2
3
4
5
6
7
cd examples/jpa
export JPA_LAB_JDBC_URL=jdbc:postgresql://127.0.0.1:5432/jpa_lab
export JPA_LAB_PSQL_URL='postgresql://127.0.0.1:5432/jpa_lab'
export JPA_LAB_USER=jpa_lab
export JPA_LAB_PASSWORD='<本地实验角色密码>'
bash ./migrate-lab.sh
../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=ProcurementJpaTest#lifecycleAndServerCalculatedTotal test

JPA_LAB_PSQL_URL 需预先由操作者配置成相同专用库、相同角色的 PostgreSQL 连接串;如果已经执行过迁移,重复执行前先审查 IF NOT EXISTS 及库里现存表,不能由 DDL 退出 0 推断表定义与本文一致。最后这条按方法单独筛选的命令 NOT_RUN;不要把整套测试通过误写成“该筛选命令实测退出 0”。已归档的真实执行命令是 ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean test,在已迁移的 PostgreSQL 16.15 专用库上退出码 0,6 项测试无失败、错误和跳过;证据卡与 stdout 记录在 writing-plans/jpa/verification/20261004T053805Z-pg16-resource-local/。迁移命令当时退出 0,但原始 stdout 没有归档,不能把它单列为有完整原始输出的迁移验收。

错驱动与错版本如何负向验证

启动失败不能只留一条异常截图。若要专门检验驱动错误,可在独立实验环境中将 DatabaseSupport.factory() 的 jakarta.persistence.jdbc.driver 值临时改为不存在的类,并用相同测试方法运行;改动不属于本次交付,NOT_RUN。应记录修改前后的文件 SHA、命令、非零退出码、完整异常链,以及表中没有本次测试新行。由于本工程测试使用随机租户,负向执行后若未记录生成的租户 ID,不能仅用全表行数变化来指认该次是否成功;更稳妥的是临时实验副本、专用空库和预先确定的 ID。另一种无需改源码的可复现负向输入是 JPA_LAB_JDBC_URL='jdbc:invalid://127.0.0.1:5432/jpa_lab' 后运行相同筛选命令:URL 后缀检查仍通过,但 pgJDBC 不接受协议;预期非零退出、无新事务提交,NOT_RUN。它测试的是坏 URL,不要改称已测试了坏驱动类。

在 examples/jpa 目录下,可把坏 URL 的命令明确写为 JPA_LAB_JDBC_URL=jdbc:invalid://127.0.0.1:5432/jpa_lab ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" -Dtest=ProcurementJpaTest#lifecycleAndServerCalculatedTotal test;预期该命令返回非零,NOT_RUN。保留有效的用户和口令变量,并在专用库提前确认测试没有产生新行。若用 ! 包裹命令检查“预期失败”,还必须单独保存原退出码,否则 ! 自身返回的零会被误记为 Maven 成功。

依赖版本错配也需拆开:如果 Maven 解析不到指定 Hibernate 版本,失败发生在构建,不是 Provider 拒绝了数据库连接;如果换成可解析但与 Jakarta Persistence API 不兼容的版本,需先确定依赖树再运行,不能从“编译仍通过”推断运行兼容。要演示不存在的坐标,可执行 ../hibernate-lab/mvnw -B -ntp dependency:get -Dartifact=org.hibernate.orm:hibernate-core:0.0.0-invalid,预期解析失败,NOT_RUN;这条命令不修改实际 pom,不是工程启动测试。对比合法版本的结果时,要分别注明 API、Provider、驱动、Java 和数据库的精确版本,否则“错版本失败”没有可定位的变量。

逐一比对报错现场能避免错修:依赖解析阶段根本没有 Java 进程进入 JPA;找不到 persistence unit 时查资源是否进入 target/classes/META-INF、单元名是否一致;连接失败时查环境变量、账号、端口和 URL;schema 验证失败时查迁移是否在同一库执行、数据库列名与注解是否一致;提交失败时还要查约束与事务,而不是马上去改 Provider 版本。若运行失败后想重试,请确认上一次事务有没有成功提交:对固定采购请求二次执行创建可能产生第二张申请;测试的随机租户降低测试间冲突,却不替业务提供幂等请求键。生产系统要把“创建成功但响应丢失”与“压根没有插入”区分开,必须在应用层定义稳定请求标识并让数据库约束参与重试判定。本文只验证基础插入,不把业务重试实现假托给 ORM。

运行时属性还有两个常被混为一谈的来源。persistence.xml 负责声明单元、实体清单与实现属性;Persistence.createEntityManagerFactory 的第二个参数传入按此次运行确定的 JDBC 参数。前者进入构建产物,后者在运行时从环境读取。若命令行变量指向另一台服务器,重新构建同一份 Java 代码也无法自动发现业务连错库。验证一次“启动通过”因此需要同时记录代码 SHA 和环境输入;当前证据卡把逐文件 SHA-256 留存,区分基线提交与当时未提交的工程文件,避免拿一个旧提交 ID 假装覆盖当前代码。没有记录的错误驱动或错版本实验只能写预期与 NOT_RUN,不能借 6 项正常测试的绿色结果给它补证。

还有一种常见的误判是“所有测试绿,因此重启升级安全”。当前六项运行在已准备好的专用数据库上;它们没有模拟旧库版本、滚动升级时新旧节点同时写入,也没有检查另一个 Provider 能否解释同一份 XML 与映射。即使所用数据库名以 jpa_lab 结尾,如果服务器地址换了或账号权限变了,连接仍可能指向错误的实例。工程入口做了后缀检查,是防止粗心误用的一层保护;发布时仍要由迁移、凭据隔离和环境审计保证目标一致。将一组可验证的启动链扩大解释成“全环境兼容”,会让这些尚未测试的风险失去负责人。

从启动结果推到哪一步

一个工厂可创建多个 EntityManager,但 EntityManager 不保证多线程共享安全(规范 §7.2);不要因为六个测试共享工厂,就把一个上下文保存在全局变量跨请求复用。实体身份在一份上下文中唯一,事务又决定什么时候把修改提交给数据库;两者不是同一件事。下一篇只讨论身份与脱管对象,后续再讨论 flush、锁定及迁移。这一篇未测试 JTA/真实容器,也没有第二 Provider 的可移植性对照。即使数据库表上有申请状态 CHECK,它只能拒绝枚举以外的字符串,并不能区分 DRAFT → ORDERED 是否合法。

故障证据也应保留失败前置条件:坏 URL 的例子只有在正确版本的驱动已在 classpath、同一测试用正确 URL 可连通时,才能归因于 URL 变化。如果测试库本就未启动,即使坏 URL 命令非零退出,也无法判断是无服务、无权限还是协议不被接受。对照试验要固定其余输入,记录错误命令和正常命令的差异;这里未执行该对照,所以只列复现方法,不给出异常类名或确切 SQL。

练习一:删除 Hibernate 依赖但保留 JPA API,实体能编译就能写入吗?解答:API 类型可以在编译时满足,Java SE 启动却仍需可发现的 Provider,实现建立 JDBC 连接还需驱动和数据库;编译、工厂和提交三层必须分开验证。

练习二:在 persist 后立即得到 identity 主键,另一个连接也必然能读到行吗?解答:不能。为取得生成值 Provider 可能已经发了 SQL,但未提交事务的写入在 PostgreSQL 16 的默认 READ COMMITTED 下不对外可见。是否已经写 SQL 也不能只靠 Java 主键判断;提交后再用独立上下文查询才是本实验的终态判据。

官方资料

本章研究卡与实验说明只摘取已归档的 00 相关判据及尚未运行的负向路径;归档的 6/6 不等于整个系列完成。