Skip to content
页面导航
精简

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 日志统一用 @Slf4jlog.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.xmlFROM 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)

  1. 代理拦截调用,识别方法名 updateById
  2. TableInfoHelper 读取 ModelEntity 注解,拼出 UPDATE models SET 非空字段=? WHERE model_id=?
  3. 只把 model 中非 null 的字段放进 SET(null 不更新);
  4. 交底层 MyBatis 执行。

整个过程你没有任何 SQL 文本,SQL 是运行时字符串拼接出来的,存在内存里。

2.3 拦截器链:SQL 执行前后的"自动手术"

这是 MP 最关键的扩展点,基于 MyBatis 的 Interceptor。KIAP 注册了两条拦截器链:

  • DE 框架 MybatisPlusAutoConfigurationTenantLineInnerInterceptor(租户)+ DePaginationInnerInterceptormaxLimit=10000,且对 KaiwuDB 强制用 PostgreSQL 方言)。
  • KIAP 自身 TptMybatisConfigPaginationInnerInterceptor(无 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 属于历史风格,新代码建议交给拦截器。

自动填充——DeMetaObjectHandlerMetaObjectHandler)在 insert/update 时自动填 created_atupdated_attenant_id,你业务代码不手 set。类型转换——TptMybatisConfig 注册的 JsonbHandlerTypeHandler)自动把 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 SQLMP 动态代理生成运行时反射,无文件
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 步 — 写 Entitymodel/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 步 — 写 Mappermapper/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,写一次性主类批量生成:
    java
    FastAutoGenerator.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
默认值falsenull
数据库列通常 NOT NULL允许 NULL
态数两态:是/否三态:是/否/未知
NPE 风险

Lombok 访问器

  • boolean deleted → 生成 isDeleted(),返回 true/false
  • Boolean deleted → 生成 getDeleted(),返回 true/false/null

差异原因:isXxx() 返回基本类型,若字段是 nullBoolean,JVM 拆箱会抛 NullPointerException,故 Lombok 对可空的 Boolean 改用 getXxx()null 安全交给你。

怎么选

  • boolean:语义确定非黑即白、列 NOT NULL(如"用户是否激活")。
  • Boolean:需要"未知/未设置"态——逻辑删除标记、历史迁移新增列、可空开关。

NPE 陷阱

java
if (deleted) { ... }                       // ❌ deleted==null 时崩溃
if (Boolean.TRUE.equals(deleted)) { ... }  // ✅ null 安全,返回 false

KIAP 关键约定

  • 逻辑删除字段用 BooleanModelTagEntitydeleted),三态语义准确;null 不丢失"未标记"态。
  • MyBatis-Plus updateById 只更新非空字段,Booleannull 时不会被写进 SET,符合"不想改就不传"语义。
  • XML 查询常用 AND deleted = false 过滤未删除行(如 ModelMapper.xmlselectByUserAndTenant)。
  • 实体类禁用 @Data:其 @EqualsAndHashCode 默认用全部字段(含布尔)算哈希,改一个布尔值对象"身份"就变,会搞乱 HashSet/HashMap 缓存与持久化上下文;实体类改用 @Getter+@Setter
  • 代码生成后核对:MyBatisX / MP Generator 常默认 boolean,需改为 Boolean;判空统一 Boolean.TRUE.equals(x)

七、核心要点速查

  • 属性访问private 字段必须走 Lombok 访问器;booleanisXxx()BooleangetXxx()(可 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