JPA 持久化 03:类型、值对象与主键
金额、日期和申请编号为什么不能只看 Java 类型
采购申请的明细写着单价 19.90、数量 3,汇总金额应由服务端计算为 59.70。对象里使用 BigDecimal,不等于数据库已经保存了正确金额:乘法输入可能有三位小数,列可能只留两位,重新读取后也可能出现表示形式的变化。类似地,审批时间写进 Instant 并不能自动回答数据库列用什么时区解释;@Id 放在字段上也没有替业务决定重试时如何识别同一张申请。把这三类问题放在一起,是因为它们都发生在 Java 值跨过持久化边界的地方,却由不同层负责。
这里沿用采购域的约束:DRAFT → SUBMITTED → APPROVED → ORDERED,或者 SUBMITTED → REJECTED;只有 APPROVED 能建订单,每张申请至多一单,租户不能越权。本文集中在明细单价、申请金额、下单时间和身份的表示,不把业务校验、数据库约束和 JPA 映射混成一个注解。先修实体生命周期和持久化上下文(系列 01);涉及提交后重读时,默认使用独立的新上下文,不以同一上下文的缓存命中冒充数据库往返。
一种 Java 类型,不止一个约束
累计工程在 ProcurementRequest.addLine 里已对基本 BigDecimal 单价执行 setScale(2, UNNECESSARY) 并检查范围,但尚未引入本章讨论的 @Embeddable MoneyAmount 或币种字段。新增的独立数据库断言覆盖两个超两位精度的拒绝路径及提交后金额重读;记录在 writing-plans/jpa/verification/20261004T061327Z-pg16-precision-guard/RUN.md。值对象替换映射和对应迁移仍是 NOT_RUN;普通金额回归不等于此扩展实验通过。
Jakarta Persistence 3.2 §2.6 列出 BigDecimal、LocalDate、LocalDateTime、OffsetDateTime、Instant、枚举等基本类型。它回答的是“这些属性可以作为基本持久化属性吗”,不是“单价允许多少位”“列超长由谁拒绝”。§11.1.9 @Column 的 precision、scale 是十进制列的声明,nullable 是列映射约束;若数据库已由迁移脚本建好表,单改注解并不会修改旧表。即使新建表用了这些声明,输入范围、舍入和负数的业务规则仍要在 Java 层确定,再由数据库列与检查约束守住最后一道线。
例如 new BigDecimal("19.90") 保留了十进制输入;new BigDecimal(19.90) 接收的却是二进制浮点值的精确十进制展开,起点就偏离了标价。单价 19.90 与数量 3 的乘积仍用十进制运算,但 19.905 是否允许不能交给某个 provider 的列写入路径“碰运气”。可以在入口使用 setScale(2, RoundingMode.UNNECESSARY) 拒绝超出两位小数的输入,随后检查金额非负、precision 对应的总位数;若有折扣与税费,则必须另行定义何时舍入。列 numeric(18,2) 能防止不符合列范围的终态,却不能解释因何舍入或保证服务端汇总公式正确。BigDecimal.equals 还会比较 scale,19.9 和 19.90 在数值比较 compareTo 中相等,却不满足 equals;实验应分别断言数值与 scale,不要把一次 round-trip 的 Java 表示当成所有数据库/驱动的规范承诺。
枚举也有类似分界。现有 ProcurementRequest.status 明确标了 @Enumerated(EnumType.STRING),这比序号随枚举声明顺序变化更利于阅读,但并不保证改名后旧行自动迁移;REJECTED 拼错或者数据库存在历史枚举值,会在读取边界暴露。tenantId、item 是字符串,却不意味着非空、非空白与租户授权由 String 类型保障。基本类型是映射的入口,业务意义另有主人。[PATTERN] 每遇到一个持久化字段,分别写下 Java 域的有效输入、列的物理范围和现存数据的迁移要求;三者一致才能谈“类型安全”。
如果把价格作为会变动的商品目录属性,采购明细也不应该在日后重新加载商品价格来计算历史金额。明细单价是审批时的交易快照;写入总额时应核对明细,而不是分别信任客户端传来的数值。即使列都用 numeric(18,2),数据库也只知道每列的数值范围,不会凭这两个列名推出“总额等于所有明细的单价乘数量之和”。需要用服务端统一的计算入口、同事务写入与回读断言连接起这条规则。若表迁移允许单价或数量在已批准后变化,历史审批的含义也会随之改变;状态机应限制修改窗口,必要时另设数据库或审计约束。这个问题发生在领域层和事务层,给金额再加一个注解并无帮助。
时间是事实,日历是视图
采购“申请日期”通常表示业务日,用 LocalDate;“审批发生于某时”通常要表达时间线上的一点,可选 Instant。它们不能互换:同一个 Instant 按不同时区显示会落在不同日期,而不带时区的 LocalDateTime 不能单凭数值还原唯一时刻。规范 §2.6 承认这些时间类型作为基本类型,但数据库时间列、JDBC 驱动、连接时区和 provider 配置的组合仍需实测。更不能用“本地电脑显示 2026-10-04 09:00”推论数据库保存了 UTC。迁移时显式决定 PostgreSQL 16 列使用 date、timestamp 还是 timestamptz,读取后在新上下文比较预期的时间线值,并记录连接/session 时区;这里没有运行证据,不能声称某种映射必然输出特定 SQL。
规范 §11.1.54 @Temporal 面向旧的 java.util.Date / Calendar 一类日期类型;不要给 Instant 或 LocalDate 加 @Temporal 来“修复”时区。时间值的可移植性与具体数据库的列精度仍是两个问题:毫秒或微秒被截断时,应该先看 DDL 和数据库返回值,再决定断言精度,而不是把误差写进规范。
值对象可以嵌入,不能借此产生另一份身份
单价常由“数值 + 币种”共同解释。@Embeddable 的意义不是让货币偷偷成为另一个实体,而是把一组与宿主同生共死的值作为宿主状态展开。规范 §2.7 明确嵌入对象没有自己的持久化身份;跨持久化实体共享同一个嵌入对象的语义未定义。不可变设计、每次构造新值更容易避免“改了一张明细,另一张也跟着变”的错觉。无参构造的普通类可作 embeddable;3.2 也允许 record 作为 embeddable,但本系列选择普通类以便把字段访问与模型迁移的差别单独看清。
下面的代码是下一步放进 examples/jpa/src/main/java/blog/jpa/MoneyAmount.java 的独立候选类型,不表示工程已有此类或测试已通过。对照现有 ProcurementLine.java:它目前把 unitPrice 直接映射为 BigDecimal;引入这个值对象时必须同时修改实体映射与独立迁移,不能仅把类复制进工程就启动 hibernate.hbm2ddl.auto=validate。为了便于讨论,币种暂固定为三位大写码;真正可用币种集合和汇率不由 JPA 定义。
1 | |
候选改造是在 ProcurementLine 的字段访问模式下以 @Embedded private MoneyAmount unitPrice; 代替原金额字段,并且保持现有 unit_price 列、增加 currency 列。如果同一实体里嵌入两次 MoneyAmount,两个属性默认映射会争用同名列;需要按 §11.1.4 @AttributeOverride 在嵌入位置区分列名。相同类并不隐含相同角色:一处是单价,一处可能是限额。equals 之所以可以直接比较 BigDecimal,是因为构造时已把数值规范到两位小数;如果后续允许三位小数或额外构造入口,必须重新审视相等性。跨币种相加必须另有换算政策,现有总额字段没有币种列,改造时应规定整张申请只允许同一币种或一并改造总额;值对象只能阻止无意的“数值脱离币种”,不能给出汇率。代码中的 precision() 与整数位检查守住了 Java 构造器的范围,不证明数据库列一定是 numeric(18,2);迁移与已建列还需单独核对。
若只为状态码定制单列格式,可以讨论 §3.9 类型转换 的 AttributeConverter;但 converter 把一个属性和一个数据库列相互转换,不适合把“金额+币种”伪装成可分别约束、查询的两列。这里选 embeddable 的理由是列结构及生命周期,不是 ORM 优化。
@Id 指定身份,业务去重另立约束
§2.4 主键与实体身份 要求每个实体有主键,每个实体继承层次恰好定义一次。简单键可以用 Long 等基本类型;生成的键适合当前申请行与明细行。现有 ProcurementRequest.java 使用 Long id、@GeneratedValue(strategy = IDENTITY)。它说明 ORM 要从数据库取得一个持久化身份,不说明 persist 前、persist 后或 flush 前哪一条语句确切执行,也不允许用“ID 已非空”代替“事务已提交”。修改已持久化实体的主键在 §2.4 中属于未定义行为;不能通过重写 id 来“转移”明细所属申请。
采购的业务标识可能是租户范围内的申请号 (tenant_id, request_no);它需要数据库唯一约束和服务端授权检查,即使物理主键仍用单列 id。不能用 find(ProcurementRequest.class, id) 成功就推断当前租户有权读该行:find 只按实体键查询,租户条件必须写入有授权语义的业务入口。重试也不能依靠自增键防重复,因为两次请求会得到两个不同 id;稳定幂等键或申请号加唯一约束才为“同一次请求”提供识别依据。[PATTERN] 技术主键解决行身份,业务唯一键解决重复业务事实,租户范围解决可见性;三个问题写成三个不同断言。
键的相等性还关系到上下文中的查找和 Java 集合行为。实体使用数据库生成的 id 时,持久化之前的对象没有稳定键,不宜直接把可变 id 纳入 hashCode() 再放进 HashSet;id 赋值后哈希值可能变化,集合找不到同一个实例。复合键则相反,其组成部分必须在写入前稳定,并通过一致的相等性规则表达同一数据库键。这是设计 Java 对象键的约束,不是要求把所有自然键都改成主键。尤其在多租户采购里,拿一个裸的 requestNo 比较两张不同租户的申请会误判为同一业务事实,域内识别必须连同租户信息一并处理。
如果决定让 (tenant_id, request_no) 直接成为复合主键,规范 §2.4.1 提供 @EmbeddedId / @IdClass:主键类需符合相等性及类型规则,类有公开或受保护的无参构造,或者使用 3.2 允许的 record 类型;equals / hashCode 应与数据库主键值相等语义一致。一个简化的值键可写成 record RequestKey(String tenantId, String requestNo),但把它真的用作 @EmbeddedId 还必须迁移现有外键与实体映射,订单和明细引用也得跟着调整。本文不把“能写出键类”误报为“现有模式已经安全迁移”。派生身份及 @MapsId 的限制见 §2.4.2,尤其不能把值对象 MoneyAmount 的“无身份”混为“复合主键”。
实验入口:先确定观察的是哪一层
现有 ProcurementJpaTest.java 的 lifecycleAndServerCalculatedTotal 已在独立 PostgreSQL 16.15 库的 RESOURCE_LOCAL 测试批次中通过:一条 2.25 × 3 明细由服务端算出 6.75;提交后在同一 EntityManager 中 clear 再 find,断言重读对象不是旧引用、总额按 compareTo 等于 6.75,集合包含一条明细。这不是两条明细、币种嵌入、日期时间或主键异常实验。该批次六个测试通过、退出码 0,原始记录见 RUN.md;其中迁移原始 stdout 未归档,不算单独的迁移验收。代码冻结标识是同目录逐文件 SHA-256,不把当时的基线提交当作工程代码 SHA。
本章后续要在 examples/jpa/src/test/java/blog/jpa/Chapter03MappingTest.java 添加独立筛选的场景,继续使用 procurement RESOURCE_LOCAL 持久化单元与专用库,不与 Java EE/JDBC 工程共库。现有 01-init.sql 已建 numeric(18,2) 金额列、价格非负检查和 request_id 外键;没有 MoneyAmount、currency 列、日期字段、复合业务键或此测试。下面的输入、失败判据与新类型仍是 NOT_RUN,不能拿上述六个通过的用例充数。实验输入与证据缺口见 本章研究与实验卡。
扩展正常路径:在同一事务创建租户 A 的申请,分别放入 19.90 × 3 和 0.10 × 1 两条明细;服务端计算预期总额 59.80,提交后关闭上下文,再开上下文通过 id 重读。断言行数、单价的十进制数值、金额两位小数、独立列中币种、明细所属申请 id;若增加 LocalDate 与 Instant,还应按 DDL 与时间精度分别断言日期和时刻。@Embedded 改造前后应分别用独立迁移验证,不可复用旧表结构并把启动失败算作映射失败的证据。此扩展场景状态:NOT_RUN,没有测试日志或数据库终态。
扩展失败路径分三层。入口传 19.905 或负数应被候选构造器拒绝,且不开始写入。违反数据库 NOT NULL、numeric(18,2) 范围或未来唯一键的输入,应在受控迁移下断言事务失败、回滚后行数不变。用不存在或其他租户的申请 id 调用业务加载入口,应返回无权限/不可见的业务结果,而不是把 EntityManager.find 当权限过滤器。现有批次的 uniqueOrderAndTenantAndRetry 已断言服务方法拒绝其他租户下单,但并未验证这里的非法金额/复合键输入。最后,尝试在持久化后修改键不是可移植的“异常测试”,规范定义其行为未定义,应避免这样编排正常生产路径。扩展失败场景状态:NOT_RUN;具体 SQLSTATE、异常链、是否在 flush 还是 commit 抛出,要由原始运行记录确认。
两道练习
练习一:两行分别输入 12.30 和 12.300;明细列为 numeric(18,2),希望不悄悄丢精度。哪一行应该拒绝?怎样测试读回后的等价性?
答案:若规则明确为“输入最多两位小数”,第二行虽数值相同,也应按输入格式拒绝;但 setScale(2, UNNECESSARY) 只拒绝非零被丢弃的数字,12.300 会通过。若格式本身重要,在入口额外检查原输入的 scale() <= 2,不要把数值归一化当作格式验证。读回比较数值可用 compareTo,同时单独检查业务选定的 scale,不借 equals 一次性混合两个判据。
练习二:已经用 @Id Long id 的申请表,新增租户内申请号,要避免相同请求被重复提交。是否必须改成 @EmbeddedId?
答案:不必。保留行主键,在数据库上建立 (tenant_id, request_no) 唯一约束,并在业务入口用同一租户的稳定请求键定位结果,冲突时重新查询受授权的已提交申请。仅靠内存“先查不存在”挡不住并发;唯一约束保证最终不出现两行,如何把冲突翻译成重试结果仍属业务/事务逻辑,需在后续并发章节验证。复合键只有在确实要以业务二元组作为实体身份、并能迁移所有关联键时才值得引入。
边界与速查
| 关心的问题 | 映射承担什么 | 仍须另行保证什么 |
|---|---|---|
| 十进制金额 | §2.6 基本类型、§11.1.9 列声明 | 输入位数、币种一致、汇总公式、实际 DDL |
| 日历日与时刻 | §2.6 时间类型 | 会话时区、数据库类型、往返精度 |
| 单价与币种 | §2.7 嵌入状态 | 不跨实体共享、迁移两列、领域校验 |
| 行键与业务键 | §2.4 实体身份 | 租户授权、幂等键、数据库唯一约束 |
这些映射不覆盖汇率、隐私隔离、跨租户授权,也不保证数据库已经执行了迁移。实验判据需在代码、DDL、连接参数和数据库终态上分别核对;没有实际跑过的部分持续标记 NOT_RUN,不能用规范文字顶替实测。






