Appearance
Java 对象映射与持久化:Lombok + MyBatis-Plus 精要(基于 KIAP 项目)
本文档面向已熟悉 Java 基础、初接触 KIAP 后端(Spring Boot 3.2.5 / MyBatis-Plus / Lombok)的开发者, 目标是把"对象属性访问 → 注解生成代码 → 数据库 CRUD → 分页/多租户"这条链路一次性讲透。 所有结论均锚定本项目真实代码,配套规范见
AGENTS.md第 2.4、2.5 节。
一、从"字段"说起:Java 对象如何暴露属性
在 KIAP 里,无论是实体(Entity)还是接口传输对象(DTO),字段几乎一律声明为 private。这是 Java 封装的基本要求——类外部不能直接 obj.field 访问私有字段,必须通过访问器方法。
访问器从哪来?靠 Lombok。Lombok 不是 Java 官方特性,而是一个第三方编译期工具(projectlombok.org):它在编译阶段通过注解处理器自动把 getXxx()、setXxx()、toString() 等方法插进字节码,你的源码里只写注解,不写这些样板方法。KIAP 的 AGENTS.md 明确允许使用 @Getter、@Setter、@Data、@Builder、@Slf4j、@RequiredArgsConstructor。
这里要分清两类注解的"产量"差异:
@Getter:只生成读方法(getXxx())。@Data:是组合包,等于@Getter+@Setter+@RequiredArgsConstructor+@ToString+@EqualsAndHashCode,一次性把读写、构造、打印、相等比较全生成。
实体类禁止用 @Data(AGENTS.md 硬规则),原因有三个:① @EqualsAndHashCode 默认用全部字段算哈希,在 MyBatis/缓存场景里同一行改一个字段 equals 就变了,易埋 bug;② @Setter 放开全字段可写,绕过业务校验;③ @ToString 全字段打印,实体若含密钥、token 等大字段,日志会泄漏或超长。因此实体类规范是单独用 @Getter + @Setter;DTO 无持久化语义顾虑,可用 @Data。
布尔字段有一个特例:基本类型 boolean Lombok 生成 isXxx()(如 isDeleted()),返回 true/false,未赋值默认 false;包装类型 Boolean 生成 getXxx()(如 getDeleted()),除了 true/false 还可能是 null。这个差异不是随意的——isXxx() 返回基本类型,若字段是 null 的包装类,JVM 自动拆箱会抛 NullPointerException,所以 Lombok 对可空的 Boolean 保守地用 getXxx()。
为什么有时用 Boolean、有时用 boolean? 本质在"是否需要三态"。基本类型 boolean 不能为 null,只有"是/否"两态,对应数据库 NOT NULL 列,适合语义确定的字段(如"用户是否激活")。包装类 Boolean 可以为 null,表示"未设置/未知",对应允许 NULL 的列,适合历史迁移新增列、可空开关。代价是 NPE 风险:直接 if (deleted) 在 deleted == null 时会崩溃,应写 if (Boolean.TRUE.equals(deleted))。一句话:确定非黑即白用 boolean,需要"未知"这一态用 Boolean。
toString() 也属于 Lombok 自动生成的方法,它是整个对象实例的文本化(类名 + 所有字段名值),用于日志/调试看"这个对象现在什么状态",不是取单个字段(取单字段用 getXxx())。KIAP 日志统一用 @Slf4j 的 log.xxx 且禁止 System.out.println,打对象用 log.info("req={}", req),占位符 {} 会自动触发 toString()。若实体含敏感字段,可用 @ToString(exclude=...) 或 @ToString(of={...}) 显式控制打印范围。
二、对象如何落到数据库:MyBatis-Plus 的整体定位
MyBatis-Plus(MP)是在 MyBatis 之上的增强层,核心做两件事:自动生成通用 SQL 和 可插拔的拦截器链。它让你从"每张表手写 CRUD 的 XML"中解放出来,同时保留写复杂 SQL 的能力。
KIAP 里的典型分工:
- 通用 CRUD(增删改查按主键、条件列表)由 MP 自动提供,你零 SQL。
- 复杂查询/分页你写 XML,但分页的
LIMIT、多租户的tenant_id条件由 MP 拦截器自动补。
2.1 实体:声明"对象 ↔ 表"的映射
映射关系不是手写 SQL,而是用注解声明。以 model/entity/ModelEntity.java 为例:
java
@TableName("models") // 类 → 表名
public class ModelTagEntity {
@TableId(type = IdType.ASSIGN_ID) // 主键,雪花 ID 由 MP 生成
private String modelId;
@TableField("name") // Java 字段 → 列名
private String modelName;
@TableField("base_model") // 驼峰 modelName ↔ 下划线 base_model
private String baseModel;
}@TableName 绑定表,@TableId 标主键并可指定 ID 策略,@TableField("列名") 显式声明字段↔列的对应。当 Java 驼峰名与数据库下划线列名不一致时必须写 @TableField;本项目未依赖全局驼峰转下划线开关,字段几乎全显式标注。
表名注意:本项目
ModelEntity的@TableName("models")与ModelMapper.xml中FROM models一致,虽AGENTS.md通则要求新表用tpt_前缀,但该模块实际物理表是models,以@TableName与 XML 实际写法为准。
2.2 Mapper:一行继承获得全部通用能力
java
public interface ModelMapper extends BaseMapper<ModelEntity> {
// 通用方法(insert/selectById/updateById/deleteById/selectPage…)已自带,不用写
Page<ModelEntity> selectByUserAndTenant(...); // 仅复杂查询才在此声明
}BaseMapper<T> 只是接口,没有实现类。MP 在启动期通过 MapperFactoryBean 为它动态生成代理对象(MybatisMapperProxy)。DE 框架用 @MapperScan("com.inspur.**.mapper.**") 自动扫到所有 Mapper,你不用加 @Mapper 注解。
当你调 modelMapper.updateById(model):
- 代理拦截调用,识别方法名
updateById; TableInfoHelper读取ModelEntity注解,拼出UPDATE models SET 非空字段=? WHERE model_id=?;- 只把
model中非null的字段放进SET(null 不更新); - 交底层 MyBatis 执行。
整个过程你没有任何 SQL 文本,SQL 是运行时字符串拼接出来的,存在内存里。
2.3 拦截器链:SQL 执行前后的"自动手术"
这是 MP 最关键的扩展点,基于 MyBatis 的 Interceptor。KIAP 注册了两条拦截器链:
- DE 框架
MybatisPlusAutoConfiguration:TenantLineInnerInterceptor(租户)+DePaginationInnerInterceptor(maxLimit=10000,且对 KaiwuDB 强制用 PostgreSQL 方言)。 - KIAP 自身
TptMybatisConfig:PaginationInnerInterceptor(无 maxLimit)。
分页是怎么实现的——以 selectByUserAndTenant 为例,XML 里 SQL 只是 SELECT * FROM models ... ORDER BY created_at DESC,没有 LIMIT/OFFSET。分页拦截器检测到入参有 Page 对象(带 current+size)后,在 SQL 执行前:① 尾部拼 LIMIT ? OFFSET ?;② 额外发一条 COUNT(*) 算总数;③ 把数据与总数回填到同一个 Page 对象。方言适配由 DePaginationInnerInterceptor.findIDialect() 完成——当数据库识别为 OTHER 且 JDBC URL 含 kaiwudb 时强制用 PG 方言(KaiwuDB 兼容 PG 语法),屏蔽了不同库分页写法的差异。
多租户是怎么实现的——TenantLineInnerInterceptor + DeTenantLineHandler 在每条 SQL 的 WHERE 后自动追加 AND tenant_id = ?(值取自 PrincipalContext 当前租户)。所以通用方法(如 selectById)你不用写租户条件;自定义 XML 里你手写 tenant_id 属于历史风格,新代码建议交给拦截器。
自动填充——DeMetaObjectHandler(MetaObjectHandler)在 insert/update 时自动填 created_at、updated_at、tenant_id,你业务代码不手 set。类型转换——TptMybatisConfig 注册的 JsonbHandler(TypeHandler)自动把 Java 对象 ↔ PostgreSQL jsonb 互转,复杂字段(如 target_vars)直接以 JSON 存取。
2.4 一次调用的完整生命周期
Service: modelMapper.updateById(model)
├─① Mapper 代理拦截,解析方法名 updateById
├─② TableInfoHelper 读注解 → 拼 UPDATE 表 SET 非空字段 WHERE id=?
├─③ 拦截器链:TenantLine 追加 AND tenant_id=?;Pagination 无 Page 入参则跳过
├─④ MetaObjectHandler 自动填充 updated_at 等
├─⑤ MyBatis 执行,TypeHandler 做类型转换
└─⑥ 返回受影响行数三、免写代码全景与"谁写什么"
| 代码内容 | 来源 | 位置 |
|---|---|---|
| 通用 UPDATE/SELECT/INSERT/DELETE SQL | MP 动态代理生成 | 运行时反射,无文件 |
LIMIT/OFFSET(分页) | MP 分页拦截器改写 | 运行时 |
COUNT(*)(分页总数) | MP 分页拦截器额外发 | 运行时 |
AND tenant_id=?(通用方法) | MP 租户拦截器改写 | 运行时 |
created_at/updated_at 赋值 | MP MetaObjectHandler | 运行时 |
| Object↔jsonb 转换 | MP TypeHandler | 运行时 |
| 复杂查询主 SQL | 你写 | resources/mapper/XxxMapper.xml |
| Mapper 接口声明 | 你写 | mapper/XxxMapper.java |
| 实体字段 + 映射注解 | 你写(声明式) | entity/XxxEntity.java |
MP 的"自动代码"三种来源:动态代理生成实现类、拦截器改写 SQL、回调钩子自动填充。
四、实战:把一张 SQL 表接入代码(可复用流程)
以新增 model_tags 表为例,完整落地五步:
第 1 步 — 写 Entity(model/entity/ModelTagEntity.java):
java
@Getter @Setter // 实体类禁 @Data
@TableName("model_tags")
public class ModelTagEntity {
@TableId(type = IdType.ASSIGN_ID) private String id;
@TableField("model_id") private String modelId;
@TableField("tag_name") private String tagName;
@TableField("tenant_id") private String tenantId;
@TableField("user_id") private String userId;
@TableField("created_at") private java.time.LocalDateTime createdAt;
@TableField("updated_at") private java.time.LocalDateTime updatedAt;
@TableField("deleted") private Boolean deleted; // 包装类,逻辑删除
}第 2 步 — 写 Mapper(mapper/ModelTagMapper.java):
java
public interface ModelTagMapper extends BaseMapper<ModelTagEntity> {
List<ModelTagEntity> selectByModelId(@Param("modelId") String modelId); // 仅自定义才声明
}第 3 步 — 写 XML(仅自定义查询,resources/mapper/ModelTagMapper.xml);通用方法无需 XML。
第 4 步 — 写 Service(构造器注入 Mapper,禁 @Autowired 字段注入):
java
@Service @RequiredArgsConstructor
public class ModelTagService {
private final ModelTagMapper modelTagMapper;
public void addTag(String modelId, String tagName, String userId, String tenantId) {
ModelTagEntity tag = new ModelTagEntity();
tag.setModelId(modelId); tag.setTagName(tagName);
tag.setUserId(userId); tag.setTenantId(tenantId);
// created_at/updated_at/deleted 由 Handler + 默认值处理,不手 set
modelTagMapper.insert(tag); // MP 生成 INSERT
}
public Page<ModelTagEntity> pageTags(long current, long size) {
return modelTagMapper.selectPage(new Page<>(current, size), null); // MP 自带分页
}
}第 5 步 — 写 Controller(前缀 /external/private/api,异常统一走 @RestControllerAdvice,Controller 不 try-catch)。
复用清单:Entity(注解 + @Getter@Setter)→ Mapper(extends BaseMapper)→ XML(仅自定义)→ Service → Controller。
KIAP 关键坑:① 实体类禁 @Data;② 两套分页拦截器 maxLimit 并存,单页超 1 万条会被截断;③ XML 表名须与 @TableName 一致;④ 逻辑删除用 Boolean 且判空用 Boolean.TRUE.equals();⑤ 通用方法租户条件交给拦截器自动补。
五、Entity / Mapper 代码生成工具
第 1、2 步是纯样板,可用工具生成:
- MyBatisX 插件(推荐):阿里开源免费,连库后右键表 → Generate,可一键出 Entity(带
@TableName/@TableField)+ Mapper(extends BaseMapper)+ XML + Service,默认 MP 风格。 - MP 官方 Generator:临时引入
mybatis-plus-generator,写一次性主类批量生成:javaFastAutoGenerator.create(url, user, pwd) .globalConfig(b -> b.outputDir("kiap-service/src/main/java")) .packageConfig(b -> b.parent("com.inspur.de.kiap").entity("model.entity").mapper("mapper")) .strategyConfig(b -> b.addInclude("model_tags") .entityBuilder().enableLombok().enableTableFieldAnnotation() .controllerBuilder().disable()) .execute(); - IDEA Database → Generate POJOs:有 IDEA 即可,但默认 JPA 风格(
@Entity/@Column),需手动改 MP 注解。
生成后必须人工核对:实体类须为 @Getter@Setter 而非 @Data(Generator 默认可能给 @Data,须改);可空布尔须 Boolean 而非 boolean;时间用 LocalDateTime。
六、布尔类型专题(boolean vs Boolean)
核心差异
| 维度 | boolean(基本类型) | Boolean(包装类) |
|---|---|---|
| 能否为 null | 否 | 能 |
| 默认值 | false | null |
| 数据库列 | 通常 NOT NULL | 允许 NULL |
| 态数 | 两态:是/否 | 三态:是/否/未知 |
| NPE 风险 | 无 | 有 |
Lombok 访问器
boolean deleted→ 生成isDeleted(),返回true/false。Boolean deleted→ 生成getDeleted(),返回true/false/null。
差异原因:isXxx() 返回基本类型,若字段是 null 的 Boolean,JVM 拆箱会抛 NullPointerException,故 Lombok 对可空的 Boolean 改用 getXxx() 把 null 安全交给你。
怎么选
- 用
boolean:语义确定非黑即白、列NOT NULL(如"用户是否激活")。 - 用
Boolean:需要"未知/未设置"态——逻辑删除标记、历史迁移新增列、可空开关。
NPE 陷阱
java
if (deleted) { ... } // ❌ deleted==null 时崩溃
if (Boolean.TRUE.equals(deleted)) { ... } // ✅ null 安全,返回 falseKIAP 关键约定
- 逻辑删除字段用
Boolean(ModelTagEntity的deleted),三态语义准确;null不丢失"未标记"态。 - MyBatis-Plus
updateById只更新非空字段,Boolean为null时不会被写进SET,符合"不想改就不传"语义。 - XML 查询常用
AND deleted = false过滤未删除行(如ModelMapper.xml的selectByUserAndTenant)。 - 实体类禁用
@Data:其@EqualsAndHashCode默认用全部字段(含布尔)算哈希,改一个布尔值对象"身份"就变,会搞乱HashSet/HashMap缓存与持久化上下文;实体类改用@Getter+@Setter。 - 代码生成后核对:MyBatisX / MP Generator 常默认
boolean,需改为Boolean;判空统一Boolean.TRUE.equals(x)。
七、核心要点速查
- 属性访问:
private字段必须走 Lombok 访问器;boolean→isXxx(),Boolean→getXxx()(可 null)。 - 注解分工:
@Getter只生成读;@Data是组合包,实体类禁用,用@Getter@Setter。 - 映射声明:字段↔列用
@TableName/@TableField声明,SQL 由 MP 运行时按注解生成,不手写。 - MP 原理:动态代理生成 BaseMapper 实现 + 拦截器链(分页/租户改写 SQL)+ 回调钩子(填充/类型转换)。
- 免写清单:通用 CRUD、LIMIT、COUNT、tenant_id、时间填充均由 MP 自动完成。
- 新表流程:Entity → Mapper → XML(可选) → Service → Controller,五步可复用。
- 代码生成:MyBatisX 或 MP Generator 一键产出,生成后改
@Data为@Getter@Setter、可空布尔改Boolean。