4、发布系统MyBatis-Plus 持久层实现
第4章MyBatis-Plus 持久层实现上一章拆解了 publish 系统的数据模型本章聚焦持久层的具体实现——从 BaseEntity 基类设计到 Mapper 层与 Service 层的继承体系再到TableField的各种映射技巧。整个持久层遵循统一的模式BaseEntity→\to→IBasicMapper/BaseMapper→\to→ServiceImpl代码高度一致几乎不存在重复造轮子。4.1 BaseEntity —— 统一主键基类publish 系统定义了一个极简的实体基类所有 12 个领域实体均继承自它GetterSetterpublicabstractclassBaseEntityimplementsSerializable{TableId(typeIdType.AUTO)privateLongid;}这里存在一个关键设计决策使用Long而非long。包装类型允许id为null当 MyBatis-Plus 的save()方法保存新实体时传入id null数据库自增主键产生的 ID 会自动回填到实体对象中。FlowService 的insertAndSetObjectId()方法正是依赖这个机制publicvoidinsertAndSetObjectId(PublishFlowflow){save(flow);// save 后 flow.getId() 自动包含数据库生成的主键值}TableId(type IdType.AUTO)指定主键策略为数据库自增。这意味着所有 INSERT 语句都不包含id列完全交由 MySQL 的 AUTO_INCREMENT 处理。相比 MyBatis-Plus 内置的雪花算法ASSIGN_ID自增主键在单机部署场景下更简洁直观且与 Flyway 迁移脚本中定义的BIGINT UNSIGNED NOT NULL AUTO_INCREMENT完全一致。注意此处继承的是项目自定义的BaseEntity而非 MyBatis-Plus 提供的ModelT。原因是 publish 系统不需要 ActiveRecord 模式的 CRUD 能力——实体只负责承载数据数据库操作全部收敛到 Service 层。这种分离保持了实体类的纯粹性。4.2 Mapper 层设计标准继承链publish 系统所有 Mapper 接口遵循统一的继承模式MyBatisRepositorypublicinterfacePublishBizMapperextendsBaseMapperPublishBiz{// 自定义查询方法}两级继承关系BaseMapperT来自 MyBatis-Pluscom.baomidou.mybatisplus.core.mapper.BaseMapper提供insert()、deleteById()、updateById()、selectById()、selectList()等标准 CRUD 方法。12 个 Mapper 接口无需编写任何 XML 就能获得完整的单表操作能力。MyBatisRepository是justgotrip-core自定义的注解本质上等同于Repository但额外携带了 MyBatis 的语义标记方便 Spring 组件扫描和 IDE 识别。自定义查询注解 SQL 风格对于超出 BaseMapper 能力的查询publish 系统统一使用Select注解直接在 Mapper 接口上书写 SQL不编写 XML 映射文件。以 PublishTaskMapper 为例MyBatisRepositorypublicinterfacePublishTaskMapperextendsBaseMapperPublishTask{Select(SELECT * FROM publish_task WHERE biz_id #{bizId} AND status running)ListPublishTaskgetRunningByBizId(Param(bizId)longbizId);Select(SELECT * FROM publish_task WHERE id #{id} AND status running)PublishTaskgetRunningTask(Param(id)longid);Select(SELECT t.name, t.biz_id as id, COUNT(*) as deployTimes FROM publish_task t WHERE t.create_time BETWEEN #{from} AND #{to} GROUP BY t.name, t.biz_id ORDER BY deployTimes DESC)ListMapString,ObjectcountByBiz(Param(from)Stringfrom,Param(to)Stringto);Select(SELECT t.editor as displayName, COUNT(*) as deployTimes FROM publish_task t WHERE t.create_time BETWEEN #{from} AND #{to} GROUP BY t.editor ORDER BY deployTimes DESC)ListMapString,ObjectcountByEditor(Param(from)Stringfrom,Param(to)Stringto);}注解 SQL 风格的优势在于查询逻辑与接口定义在同一文件中查找和修改时不需要在两个文件间跳转。对于 publish 系统这种查询复杂度不高以单表查询为主、表数量有限12 张表的场景注解 SQL 比 XML 映射文件更易维护。动态 SQL 的处理。当需要条件性拼接 SQL 时使用script标签包裹在注解内嵌入 MyBatis 动态 SQL 语法Select({script,SELECT * FROM publish_app,if testids ! null and ids.size() 0,WHERE id IN,foreach itemitem collectionids open( separator, close)#{item}/foreach,/if,/script})ListPublishAppfindByIds(Param(ids)ListLongids);这种方式在保持注解风格的同时获得了 XML 的动态 SQL 能力。对于统计类查询countByTime还有更灵活的动态分组Select({script,SELECT UNIX_TIMESTAMP(MIN(t.create_time)) * 1000 as x, ,COUNT(*) as y, MAX(t.name) as bizName FROM publish_task t ,WHERE t.create_time BETWEEN #{from} AND #{to} ,if testbizId 0AND t.biz_id #{bizId}/if,GROUP BY DATE_FORMAT(t.create_time, ${gran}) ORDER BY x,/script})ListMapString,ObjectcountByTime(Param(gran)Stringgran,...);注意${gran}使用了$而非#——因为gran是 SQL 片段如%Y-%m-%d或%Y-%m需要直接拼接进 SQL 而非作为参数绑定。这类场景下要注意防止 SQL 注入好在gran的值来自后端枚举限定日/周/月不直接接收用户输入。统计查询返回ListMapString, Object。当查询结果不是标准实体时直接返回 Map 列表。这在统计排行榜和曲线图数据时非常实用——无需为每种统计口径定义专门的 VO 类。前端 ECharts 组件直接消费 Map 中的x/y/deployTimes等字段。Mapper 层全景Mapper继承自定义方法数自定义查询内容PublishBizMapperBaseMapperPublishBiz3按 projectId 查、按 owner 查、按 name 查PublishAppMapperBaseMapperPublishApp5按 bizId 查、按 profile 查、批量按 ID 查、按 ippath 查、查所有 profilePublishTaskMapperBaseMapperPublishTask5查运行中任务、按业务统计、按编辑人统计、按时间统计PublishFlowMapperBaseMapperPublishFlow1按 taskId 查所有步骤PublishPackMapperBaseMapperPublishPack1按 bizIdcode 查缓存PublishRelationMapperBaseMapperPublishRelation2按 branchbizId 查、按 bizId 查全部其余 6 个 MapperBaseMapperT0仅使用继承的 CRUD 方法超过半数的 Mapper 接口没有任何自定义方法——它们完全依赖BaseMapperT提供的通用 CRUD。这得益于 Service 层统一使用lambdaQuery()链式查询来覆盖简单条件过滤无需在 Mapper 中声明。4.3 Service 层设计统一继承 ServiceImpl所有 Service 类遵循相同的继承模式ServicepublicclassPublishTaskServiceextendsServiceImplPublishTaskMapper,PublishTask{// 业务方法}ServiceImplM extends BaseMapperT, T是 MyBatis-Plus 提供的通用 Service 实现它内部持有一个baseMapper字段类型为 M提供与 BaseMapper 对应的 CRUD 方法代理以及 Lambda 查询构造器的快捷入口。lambdaQuery() —— 链式查询Service 层最常用的查询方式是lambdaQuery()它通过 Lambda 表达式引用实体 getter 方法实现类型安全的查询条件构建ServicepublicclassPublishTaskServiceextendsServiceImplPublishTaskMapper,PublishTask{publicListPublishTaskgetRunningTasks(){returnlambdaQuery().eq(PublishTask::getStatus,running).list();}}PublishTask::getStatus这种写法不同于字符串status——如果字段被重命名IDE 的重构功能会自动更新 Lambda 引用而字符串不会。这是 Lambda 查询构造器相比传统QueryWrapper的核心优势。FlowService —— save() 重写实现 upsertFlowService 是最能体现 MyBatis-Plus 灵活性的 Service 实现ServicepublicclassFlowServiceextendsServiceImplPublishFlowMapper,PublishFlow{publicvoidinsertAndSetObjectId(PublishFlowflow){save(flow);}Overridepublicbooleansave(PublishFlowflow){if(flow.getId()!null){returnupdateById(flow);}returnsuper.save(flow);}}save()方法被重写为有 id 则更新无 id 则插入的语义。这解决了一个实际的业务场景Flow 在执行过程中需要多次更新例如状态从create变running再变succ同时输出内容逐步累积。调用方只需要设置好 Flow 对象的所有字段然后调用save()不需要判断当前是 insert 还是 update。insertAndSetObjectId()展示了 MyBatis-Plus 的一个关键特性调用save()后数据库生成的 ID 会自动回填到传入的实体对象中。这意味着调用方在insertAndSetObjectId(flow)返回后可以通过flow.getId()获取到新生成的 Flow ID进而将其关联到 Task。PublishTaskService —— 自定义查询与通用 CRUD 的配合ServicepublicclassPublishTaskServiceextendsServiceImplPublishTaskMapper,PublishTask{publicListPublishTaskgetRunningTasks(){returnlambdaQuery().eq(PublishTask::getStatus,running).list();}publicListPublishTaskgetRunningByBizId(longbizId){returnbaseMapper.getRunningByBizId(bizId);}publicPublishTaskgetRunningTask(longid){returnbaseMapper.getRunningTask(id);}publicvoidinsertAndSetObjectId(PublishTasktask){save(task);}}lambdaQuery()适合简单的单条件查询而带自定义 SQL 的复杂查询通过baseMapper调用 Mapper 接口中定义的方法。两种方式并存各司其职。PublishAppService —— 纯代理模式ServicepublicclassPublishAppServiceextendsServiceImplPublishAppMapper,PublishApp{publicListPublishAppfindByBizId(longbizId){returnbaseMapper.findByBizId(bizId);}publicListPublishAppfindByBizIdAndProfile(longbizId,Stringprofile){returnbaseMapper.findByBizIdAndProfile(bizId,profile);}publicPublishAppfindByIpAndPath(Stringip,Stringpath){returnbaseMapper.findByIpAndPath(ip,path);}// ... 其余方法同理}AppService 的方法都是对 Mapper 方法的直接代理没有额外业务逻辑。部分开发者会质疑这种透传是否有必要——答案是有必要。Service 层的存在隔离了 Controller 与 Mapper当未来需要在查询 App前后增加缓存逻辑、权限校验或数据脱敏时修改只发生在 Service 层Controller 层完全不受影响。这是分层架构的核心价值。4.4 TableField(exist false) —— 瞬态字段TableField(exist false)是 publish 系统中最普遍的 MyBatis-Plus 注解用法。它标记的字段存在于 Java 对象中但不会生成对应的 SQL 列映射。关联对象预加载最常见的用途是运行时携带关联对象避免在模板或前端需要时再查数据库// PublishTask.javaTableField(existfalse)privatePublishBizbiz;TableField(existfalse)privateListPublishFlowflows;TableField(existfalse)privateListPublishAppappList;当 Controller 需要返回一个 Task 的完整信息包括它属于哪个业务、有哪些步骤、部署到哪些机器时Service 层在一次查询后将这些关联对象 set 到 Task 上直接返回。前端或模板渲染时无需再次发起查询。这种设计在单体应用中非常实用——用少量内存换取显著的查询次数减少。前端展示辅助// PublishApp.javaTableField(existfalse)privateObjectrunningStatus;// 运行时状态如running或null// PublishBiz.javaTableField(existfalse)privatelongdeployTimes;// 发布次数统计// PublishRelation.javaTableField(existfalse)privateStringappName;TableField(existfalse)privateStringip;TableField(existfalse)privateStringpath;这些字段的值来自计算或关联查询仅用于前端页面展示不需要持久化。以runningStatus为例App 本身不存储是否正在发布的状态——这个信息来自 Redis 中的分布式锁。Service 查询 App 列表时逐条检查 Redis 中是否存在对应的锁将结果 set 到runningStatus前端据此显示不同的状态图标。瞬态字段完整清单实体瞬态字段用途PublishTaskappsbizflowsappListpack运行时携带关联对象和前端参数PublishAppbizrunningStatus关联 Biz 信息和实时发布状态PublishBizdeployTimes发布次数统计展示PublishRelationappNameippath关联 App 关键信息展示PublishPackowner包大小MB展示4.5 TableField 特殊列名映射MySQL 保留字处理当实体字段名与 MySQL 保留字冲突时必须用反引号包裹列名。publish 系统中有两处典型场景// PublishFlow.javaTableField(out)privateStringout;TableField(err)privateStringerr;out和err都是 MySQL 保留字。如果不加反引号MyBatis-Plus 生成的INSERT INTO publish_flow (..., out, err, ...)会被 MySQL 解析为语法错误。加上反引号后生成的 SQL 变为INSERT INTO publish_flow (..., out, err, ...)MySQL 将其正确识别为列名。// PublishTask.javaTableField(interval)privateintinterval;interval是 MySQL 的保留关键字用于日期计算DATE_ADD(NOW(), INTERVAL 1 DAY)。不加反引号的 SQL 中interval会被误解析为关键字而非列名。同理interval是 Java 中合法的字段名无需特殊处理矛盾只出现在 SQL 层面。列名映射的选择策略publish 系统选择了不全局配置驼峰转下划线而是依赖 MyBatis-Plus 默认的驼峰命名转换。也就是说实体字段bizId自动映射到数据库列biz_id实体字段createTime自动映射到create_time。只有当列名与自动转换结果不一致如保留字或需要明确指定时才使用TableField标注。FieldStrategy 控制更新行为// PublishApp.javaTableField(updateStrategyFieldStrategy.IGNORED)privateStringlocker;FieldStrategy.IGNORED表示更新时忽略字段值的判空逻辑——即使locker为null也生成SET locker null。这是发布锁释放的关键当发布完成时需要将locker设为null表示锁已释放如果用默认策略NOT_NULLMyBatis-Plus 会跳过 null 字段导致锁永远无法通过 UPDATE 语句释放。4.6 持久层设计原则总结回看整个 publish 系统的持久层可以发现三条贯穿始终的设计原则原则一继承优于配置。实体统一继承BaseEntity获得自增 IDMapper 统一继承BaseMapperT获得通用 CRUDService 统一继承ServiceImplM, T获得 CRUD 代理和 Lambda 查询。每一层只写差异化的代码共性逻辑全部由父类承担。12 张表、12 个实体、12 个 Mapper、12 个 Service但重复代码趋近于零。原则二注解 SQL 优先于 XML。对于 publish 系统的查询复杂度以单表 CRUD 和简单聚合统计为主注解 SQL 比 XML 更合适——查询逻辑与接口定义同文件script标签覆盖了少数需要动态拼接 SQL 的场景。如果未来出现数十行以上的复杂查询再考虑 XML 映射文件也不迟。原则三瞬态字段承载视图逻辑。TableField(exist false)大量运用于关联对象预加载和前端展示辅助。数据库保持每列只存储本实体自有属性的纯洁性Java 层通过瞬态字段扩展运行时信息各司其职。这本质上是一种充血模型的折中实践——实体不只是数据库行的简单映射还承担了部分领域对象的运行时表达能力。本章小结publish 系统的持久层依托 MyBatis-Plus 的继承体系实现了高度一致、低重复的代码结构。BaseEntity 提供统一主键BaseMapper 提供通用 CRUDServiceImpl 提供 Service 层代理和 Lambda 查询入口。TableField注解在三个场景中发挥了关键作用exist false支持瞬态运行时字段反引号包裹处理 MySQL 保留字冲突FieldStrategy.IGNORED控制 null 值更新行为。整条持久层链路简洁而不简陋——每一处设计都有明确的工程意图。