ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从@MappedSuperclass到BaseEntity:Java持久层公共字段复用实战

从@MappedSuperclass到BaseEntity:Java持久层公共字段复用实战 做了这么多年Java后端实体类里的重复字段估计是每个项目都逃不掉的痛点。新建一张业务表就得写一个实体类而每个实体类几乎都要带上id、创建时间、更新时间、创建人、更新人……这些字段除了所在的表不一样代码长得一模一样。以前我都是复制粘贴图省事嘛直到有一次产品要加一个逻辑删除字段翻了十几个实体类逐个改改完还得检查有没有漏网之鱼从那天起我就认真研究起了MappedSuperclass也才有了这篇文章想分享给你的一套基类设计思路。MappedSuperclass翻译过来就是“映射父类”专门用来解决实体之间公共字段的复用问题。它能让我们把公共属性统一收敛到一个基类里子实体类只需要继承它JPA 在映射表结构时就会自动把基类里的字段“搬”到每张子表上。这篇文章我会从它的原理讲起再带着你实际设计一个生产可用的BaseEntity最后把我在实战中踩过的坑一并列出来适合被实体类样板代码折磨过、或者正在设计项目基础架构的同学参考。1. 项目里的重复代码逼我认真看待 MappedSuperclass当一个系统的实体类超过十个重复字段带来的问题就藏不住了。最直接的表现是样板代码铺天盖地id的Id、GeneratedValue注解createdAt和updatedAt的字段定义每个类里都要写一遍。如果某天你想给所有实体统一加上tenantId多租户字段或者deleted逻辑删除标记就得全局搜索替换改漏了就是线上事故。除了修改成本还有一致性问题。团队里不同人手写字段风格不统一有的用createTime有的用createdAt同一个含义在数据库里冒出两三个列名后续写 JPQL 和查询条件的时候非常头疼。当时的我面临三个选择第一继续复制粘贴代价是维护成本失控第二做一个工具类手动拷贝属性治标不治本第三就是 JPA 官方提供的MappedSuperclass方案把公共字段收进基类让所有实体在类结构层面共享同一套定义。我最终选择第三种方案不是因为它听起来“高级”而是MappedSuperclass的设计刚好卡在了一个恰当的位置。它不参与对象关系的多态查询也不引入额外的表结构只是单纯地把基类字段映射到子类对应的表中。这个特性跟我们的需求简直严丝合缝公共字段本来就是给每张业务表单独用的我并不指望在基类层面做多态关联也不需要数据库里出现一张抽象的“基类表”我只是不想让重复代码污染每个实体类。这里要顺带区分一下MappedSuperclass和 JPA 里的另一种继承映射Inheritance。Inheritance提供了SINGLE_TABLE、JOINED、TABLE_PER_CLASS三种策略它们的共同点是让不同子类实体在查询时可以用父类类型去接收形成真正的多态关系。但多态是有代价的单表策略会把继承树里所有子类的字段都堆到一张表里连接表策略则会让每次查询都多出 join每类一表策略的字段覆盖规则又比较绕。而MappedSuperclass没有任何多态的概念它就是简单粗暴的“代码级复用”结构清晰不引入查询负担。用场景来感受一下区别如果我要做一个“动物”模块有猫有狗查询时需要一次性查出所有动物并区分类型那应该用Inheritance但如果我只是觉得所有实体都有id、createdAt希望抽一个公共父类出来那就必须用MappedSuperclass。搞混这两个注解是很多新手最容易犯的错误。2. 先搞懂映射原理MappedSuperclass 到底做了什么2.1 基类不是实体不会被单独建表MappedSuperclass标注的类不会被 JPA 当做一个完整的实体来看待。它不会在数据库里生成表不能直接作为 Repository 的操作对象也不能出现在 EntityManager 的查询结果中。说白了它是一个“半成品”模板只有被子类继承后它的字段才具备实际意义。这个特性和抽象类配合起来非常自然。我习惯把BaseEntity声明为abstract class一方面语义上它就不该被实例化另一方面也防止有人把它误当成实体注册到持久化单元里。如果有人在上面加了一个Entity注解JPA 就会尝试为它建表基类的抽象意义就荡然无存了。2.2 所有属性都会被“复制”到子实体对应的表中这是MappedSuperclass最核心的映射规则当子实体继承基类后基类里的所有字段都会被视为子实体的一部分并映射到子实体对应的表。这种“复制”是逻辑层面的不会真的生成重复 Java 代码但在数据库结构上子表会实实在在地包含基类字段对应的那一列。比如下面的BaseEntityMappedSuperclass public abstract class BaseEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name created_at, updatable false) private LocalDateTime createdAt; }那么子类UserEntity Table(name user) public class User extends BaseEntity { private String username; private String password; }最终 Hibernate 在生成或校验user表时会认为表结构里应该有id、created_at、username、password四个列而不会去任何地方建一张base_entity表。这一点跟很多人理解的“继承 外键关联”完全不同继承在这里仅仅是 Java 层面的语法复用数据库层面两张业务表之间不会产生任何约束关系。2.3 能不能覆盖父类字段可以继承关系里最常遇到的一个需求是大部分子类沿用基类的字段定义但个别子类想换个列名或者调整一下长度。MappedSuperclass虽没有多态查询的野心但也没把路堵死JPA 提供了AttributeOverride注解来实现字段映射的覆盖。Entity Table(name user) AttributeOverride(name remark, column Column(name user_remark, length 500)) public class User extends BaseEntity { private String username; }如果你像这样在BaseEntity里定义了一个remark字段而User希望它在数据库里叫user_remark长度也比通用的 255 更长上面的写法就搞定了。类似的还有AssociationOverride可以覆盖基类里关联关系的 join 列。不过我得提醒一句覆盖字段是“例外处理”如果项目里大面积需要覆盖基类字段那说明基类的设计可能本身就有问题公共字段选得不够通用。不要为了灵活而把基类变成一坨需要到处打补丁的代码在绝大多数情况下子类直接继承默认映射就够了。2.4 基类也能多层继承但不建议叠太深MappedSuperclass支持多层继承意思是“基类”本身也可以继承另一个“基类”。比如你可以设计一个AuditableEntity继承BaseEntity在中间层加入审计字段然后业务实体直接继承AuditableEntity。MappedSuperclass public abstract class BaseEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; } MappedSuperclass public abstract class AuditableEntity extends BaseEntity { CreatedDate Column(name created_at, updatable false) private LocalDateTime createdAt; LastModifiedDate Column(name updated_at) private LocalDateTime updatedAt; } Entity Table(name user) public class User extends AuditableEntity { // ... }这个结构看起来很美最底层管主键中间层管审计业务类只关心自己的字段。实际用下来也是这么清爽所有字段最终都会平铺到user表里。但我的建议是继承层级最多三层就好再往上叠会让字段归属变得难以追踪出现字段冲突时排查 Hibernate 的映射报错也要多绕几层性价比不高。3. 手写一个生产级 BaseEntity核心实操步骤3.1 放哪些字段进基类基类设计最关键的是选字段选多了会让无场景的字段侵入所有实体选少了又解决不了重复问题。我总结了一个实用的筛选标准只要业务表里“绝大多数”实体都需要并且字段的维护逻辑和业务规则完全无关就值得放进基类。以我目前项目里的BaseEntity为例核心字段包括主键id所有表都有几乎都是自增或雪花ID必须放。创建时间createdAt所有业务表都需要记录放。更新时间updatedAt所有业务表都需要记录放。逻辑删除标记deleted现在几乎成了标配放。版本号version如果项目用了乐观锁这个字段必须全局统一放。有些字段虽然常见但我建议谨慎进基类比如createdBy和updatedBy创建人/更新人。如果项目里所有表都必须记录操作人那放进去没问题但如果只是一部分业务表需要强行放进去会让其他实体白白多出两列这时不如让那些特定的实体单独声明。还有一类字段比如租户 IDtenantId要看你的项目是不是多租户架构。如果是那它天然适合放在基类里写查询时强制过滤如果不是别预先埋雷。3.2 用审计注解自动填充公共字段基类字段放好了下一个问题是这些字段怎么赋值。createdAt和updatedAt如果每次都在业务代码里手动set那基类带来的省事效果就打了一半折扣。更优雅的做法是启动 JPA 审计功能让框架自动在插入和更新时填充。先在启动类或配置类上加上EnableJpaAuditingSpringBootApplication EnableJpaAuditing public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }然后在基类里写上带审计注解的字段MappedSuperclass EntityListeners(AuditingEntityListener.class) public abstract class BaseEntity { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; CreatedDate Column(name created_at, updatable false) private LocalDateTime createdAt; LastModifiedDate Column(name updated_at) private LocalDateTime updatedAt; Column(name deleted, nullable false) private Boolean deleted Boolean.FALSE; Version Column(name version, nullable false) private Long version 0L; // getter/setter 略 }这里有几个细节值得注意。第一CreatedDate和LastModifiedDate必须配合EntityListeners(AuditingEntityListener.class)使用否则审计功能不会触发。第二created_at要设置updatable false确保更新时 Hibernate 不会把它带进update语句里。第三deleted字段给了默认值false这样新增记录时哪怕业务代码没显式赋值数据库里也是有意义的。3.3 加上乐观锁防止并发覆盖Version注解是 JPA 实现乐观锁的标准方式。它的原理是每次更新前检查当前版本号和数据库里的版本号是否一致一致就更新并把版本号加一不一致就抛OptimisticLockException。我把版本号放进基类是因为并发问题其实和各业务表的类型无关它是一个通用的数据一致性需求。加上之后像“两个用户同时编辑同一条订单”这样的场景后提交的那一方在事务提交时会拿到异常不会静默覆盖前一个人的修改。值得注意的是Version标注的字段类型必须是int、Integer、long、Long、short、Short或java.sql.Timestamp。我用Long既满足要求也方便在日志和报警里看清楚版本号叠加的规律。加了Version后Hibernate 会自动在 update 语句里带上版本条件我们不需要在业务代码里手写任何版本判断逻辑。3.4 通过一个业务实体的完整示例验证效果基类设计好之后业务实体写起来就非常清爽了。比如一个简单的用户表Entity Table(name user) public class User extends BaseEntity { Column(name username, nullable false, unique true, length 50) private String username; Column(name password, nullable false) private String password; Column(name nickname, length 100) private String nickname; // getter/setter 略 }这段代码只包含用户自己的业务字段主键策略、审计字段、逻辑删除、乐观锁全部从基类获得。写 Repository 时也完全不需要关心继承关系public interface UserRepository extends JpaRepositoryUser, Long { }当我第一次跑起这个结构时打开数据库看到user表里整整齐齐地躺着id、created_at、updated_at、deleted、version再对比之前复制粘贴出来的实体类心里确实有一种很踏实的清爽感。后续新增实体我只需要关心业务字段本身公共列的迁移和维护成本一次性收敛到了一处。顺带说一句如果项目里用 Spring Data JPA 的Specification做动态查询继承来的字段也可以直接在条件里引用。public static SpecificationUser findByUsername(String username) { return (root, query, criteriaBuilder) - criteriaBuilder.equal(root.get(username), username); }你不需要做任何特殊处理因为 Hibernate 已经把基类字段视作User实体自己的属性了。3.5 基类设计里的几条铁律这部分是我踩过不少坑后总结出来的经验可以说每条都是“血泪教训”。基类必须是抽象类并且不要加Entity注解。加Entity会导致 JPA 为基类建表破坏了整个设计不加Entity但也不是抽象类则存在被误实例化的风险。实测下来MappedSuperclass配合abstract是最稳妥的组合。基类里的字段不要到处加Column的unique属性。如果你在基类里给某个字段加unique true所有子表都会要求该列唯一这对仅个别子类有唯一性需求的场景来说就是灾难。唯一性约束应该放在真正需要它的子类字段上。equals和hashCode不要只用业务字段更不要用基类的id字段做猛烈的对象相等性判断。在 JPA 中id通常不是持久化前存在的两个未保存的新实体id都是null直接用id判等会把不同的对象误判为相等。我比较常用的处理方式是重写hashCode为固定值或者用业务唯一键这样既能避免在Set中出现重复实体也不至于在实体状态变化时出现哈希码突变。4. 实战中踩过的坑与排查技巧4.1 集合泛型的关联字段最好别放在基类基类里放一些关联关系比如OneToMany乍一看很诱人让所有实体都拥有一个“下属集合”。但真这么干了你很快会碰到两类麻烦。第一类麻烦是子类之间会共享同一个关联字段的语义可实际业务中不同实体对关联集合的排序、过滤逻辑完全不同。比如订单有订单项集合用户有角色集合这两个集合的操作方式天差地别强塞进基类只会让代码变得别扭。第二类麻烦是性能隐患一旦在基类里声明了fetch FetchType.EAGER的集合关联每一个继承它的实体在查询时都会额外加载这个集合N1 问题防不胜防。我建议基类里只放标量字段普通列关联关系留给业务实体自己维护。控制基类的职责边界比在基类里堆砌功能重要得多。4.2 好友关注这类递归场景别往基类上想有些需求看起来很适合“抽象”比如实现一个好友关注模块Follow表里既有followerId又有followeeId既指向用户又指向用户。有同事把这类关联抽象到基类里让User继承后自动拥有“粉丝列表”和“关注列表”。结果就是基类里出现两个指向同一个实体类的ManyToMany或OneToMany查询时 Hibernate 需要额外指定mappedBy和 join 列名稍微写错一个就爆映射异常。这类递归关联属性本质上就不适合放在一个所谓“通用”的基类里。你完全可以单独写一个FollowerRelation或者直接在User内部定义两个关联集合没必要为了让代码“看起来复用”而付出巨大的排查成本。4.3 子类和基类字段名冲突会报错当基类里已经有remark字段子类里又写了一个同名字段时Hibernate 在启动阶段就会抛出映射异常提示数据库表里找不到对应列或者是属性重复映射。这不是运行时才暴露的问题而是项目一启动就崩溃。遇到这种问题不要用AttributeOverride去硬解那是覆盖列名用的不是让你消除属性冲突用的。正确做法是检查子类是否存在冗余的同名字段能删就删不能删就改名从根上避免重复映射。如果你给每个实体都配上 IDE 的编译检查这类问题在写完代码的瞬间就能发现。4.4 和 Inheritance 混用时会触发 JPA 继承的完整机制如果你在MappedSuperclass之上又嵌套了Inheritance标注的实体情况会变得复杂起来。这种混用场景在一般业务系统里非常少见但一旦用了JPA 就会按照实体继承的完整规则来解析映射基类中那些“看似普通”的字段也可能被纳入继承策略的管辖范围导致表结构、查询结果和你预想的完全不同。我的建议很简单如果你想做多态模型就老老实实用Inheritance并明确选好单表、连接表或每类一表策略如果你只是想要代码复用就用MappedSuperclass不要试图让两者叠加出什么“并行能力”。到目前为止我还没遇到过必须混用才能解决的业务场景反而见过好几起因为混用导致启动时映射检查报错的案例。4.5 微服务之间共用基类不生效还有个场景我不止一次被问到项目从一个微服务里把BaseEntity复制到了另一个微服务结果发现查询不识别基类字段或者表结构生成不对。原因很简单MappedSuperclass的映射是 JPA 运行时根据当前持久化单元里的实体类来解析的。如果你复制了基类代码却忘了把某个实体类加进新服务的实体扫描路径或者两个服务里用了不同包名、不同注解版本都会导致基类没有被正确识别。跨服务复用时不只是复制代码还要检查包扫描和依赖一致最好把公共 JPA 基类单独打成一个模块通过 jar 依赖引入这样既能保证统一也避免维护多份副本。4.6 常见问题速查表现象可能原因处理方式启动报错“Unknown column”基类字段名与数据库列名不匹配检查Column(name)是否与表结构一致新增记录后createdAt为空缺少EnableJpaAuditing或EntityListeners确认配置完整审计监听器已注册更新时createdAt被修改创建时间字段未设置updatable false在Column中显式声明updatable false子类出现重复属性或字段子类和基类定义了同名映射删除子类冗余字段或重命名覆盖字段不生效AttributeOverride写错位置或属性名不对确认注解写在子类上name要填基类属性名查询结果出现多余表连接误将Inheritance和MappedSuperclass混用明确设计意图移除不必要的继承策略多个实体共享同一唯一约束基类字段上误加unique true移掉基类唯一约束改到子类字段5. 一点私人的设计心得这篇文章写到这里该讲的原理和操作基本都覆盖了。最后分享一个我自己的体会设计BaseEntity这件事最好在项目初期就做越往后推重构成本越高。我经历过那种实体类已经铺了几十个再回头抽基类的过程不仅要改每个实体类的继承关系还要保证数据库列名和原有一致期间测试回归的范围非常大非常磨人。还有一点是基类不是越大越好。我把逻辑删除、版本号、审计时间全都放进基类后确实省心但这个决定也意味着整个系统的所有表都必须接受这些列。如果你的项目里有一部分表是纯配置表或日志表这些公共字段对它们未必有意义。遇到这种情况可以拆出两三个不同层级的基类比如BaseEntity只有主键、AuditableEntity增加审计字段、LogicDeleteEntity再增加删除标记让业务实体自己选择合适的父类继承。这套组合用法我在后一个项目里实践过比一个臃肿的万能基类要舒服得多。用不用MappedSuperclass说到底不是技术问题而是工程取舍问题。只要你想清楚“公共字段由基类统一维护”能带来多少收益并且接受“基类字段会影响所有子表结构”这个代价那它就是一个值得从项目第一天就引入的设计。
返回列表