从 MyBatis-Plus 迁到 MyBatis-Flex:复合主键 + 软删 + APT 顺序的四个坑

10 阅读8分钟

Enterprise Connector 系列第 07 篇。完整代码:enterprise-connector

适合谁读:在 MyBatis-Plus 和 MyBatis-Flex 之间纠结的人;有复合主键表、被 Plus 的单参 selectById 卡过的人;PG + 软删被 operator does not exist: boolean = integer 炸过、或 TableDef 字段莫名为空的人。


一句话结论

本系统从 MyBatis-Plus 迁到 MyBatis-Flex 1.11.6,触发原因只有一个:复合主键支持——Plus 不原生支持,Flex 直接支持。

迁移过程踩到 4 个具体陷阱:

#陷阱症状
1复合主键的 Mapper 写法selectOneById(Serializable) 只接单参,复合键过不去
2软删字段类型 BOOLEAN vs INTPG 报 operator does not exist: boolean = integer
3APT 处理器顺序Flex Processor 排在 Lombok 前 → TableDef 字段全空
4自动填充语法不一样MP 的 @TableField(fill=...) Flex 不认,字段永远 null

下面把每个坑的"症状 / 根因 / 解决"讲清楚。


一、为什么从 MyBatis-Plus 迁到 MyBatis-Flex

1.1 触发点:复合主键

本系列第 05 篇提到的 tenant_action_config 表,主键是 (tenant_id, action) 复合键:

CREATE TABLE tenant_action_config (
    tenant_id   VARCHAR(64),
    action      VARCHAR(64),
    template_id INTEGER,
    custom_sql  TEXT,
    enabled     BOOLEAN,
    PRIMARY KEY (tenant_id, action)
);

MyBatis-Plus 的 BaseMapper 提供的 selectById(Serializable id) / deleteById(Serializable id) 都只接单个 Serializable——复合键查询要绕开 BaseMapper 的标准方法,每张复合键表都要写大段 QueryWrapper。更糟的是 MP 的 @TableId 只接受一个字段标注,复合键表不能完整声明。

1.2 Flex 的复合主键写法

MyBatis-Flex 直接支持多个字段标 @Id:

@Data
@Builder
@Table("tenant_action_config")
public class TenantActionConfig {

    @Id(keyType = KeyType.None)
    private String tenantId;

    @Id(keyType = KeyType.None)   // ← 第二个 @Id, 复合主键
    private String action;

    @Column("template_id")
    private Integer templateId;
    ...
}

keyType = KeyType.None 表示主键不是数据库自增——业务 ID 类的 String 主键必须显式声明,否则 Flex 默认用雪花算法/自增策略,生成奇怪的 ID。

1.3 其他选 Flex 的理由

维度MyBatis-PlusMyBatis-Flex
复合主键不支持原生支持
类型安全查询LambdaQueryWrapper(反射)TableDef(APT 编译期生成)
软删@TableLogic@Column(isLogicDelete=true)
Listener APIMetaObjectHandler(全局)registerInsertListener per-entity(更精细)
社区老牌但维护节奏放缓新生代,作者活跃

实际驱动迁移的是复合主键,其他都是顺带收益。


二、陷阱 1:复合主键的 Mapper 写法

Flex 的 BaseMapper.selectOneById / deleteById 只接 Serializable 单参数,复合主键过不去。

那能不能把 (tenantId, action) 拼成字符串当 id?理论可以,但拼接方式没标准、索引利用率低、service 层还要拆解拼接。更干净的做法:走 QueryWrapper 写 default 方法

@Mapper
public interface TenantActionConfigMapper extends BaseMapper<TenantActionConfig> {

    default TenantActionConfig findByTenantAndAction(String tenantId, String action) {
        return selectOneByQuery(QueryWrapper.create()
                .where(TENANT_ACTION_CONFIG.TENANT_ID.eq(tenantId))
                .and(TENANT_ACTION_CONFIG.ACTION.eq(action)));
    }

    default int deleteByTenantAndAction(String tenantId, String action) {
        return deleteByQuery(QueryWrapper.create()
                .where(TENANT_ACTION_CONFIG.TENANT_ID.eq(tenantId))
                .and(TENANT_ACTION_CONFIG.ACTION.eq(action)));
    }
}

三个点:

  • 接口里用 default 关键字:Java 8+ 默认方法,不需要再写实现类
  • TENANT_ACTION_CONFIG 是 APT 生成的 TableDef 类:不是手写常量,编译时由 mybatis-flex-processor 生成
  • 类型安全:.eq(tenantId) 在编译期就能查出传错类型的错误

⚠️ 最容易踩的坑:别用 deleteById(tenantId)

以为 deleteById 单参数能传 tenantId 删"所有该租户的授权行":

// ❌ 错误: 编译失败 (类型不匹配) 或语义错误
mapper.deleteById(tenantId);

// ✅ 正确
mapper.deleteByQuery(QueryWrapper.create()
        .where(TENANT_ACTION_CONFIG.TENANT_ID.eq(tenantId)));

复合主键表上的 deleteById 期望的是一个完整的复合主键值,不是部分字段。本系统 Mapper 的注释专门强调了这一点,提醒所有人按复合键查询/删除统一走 QueryWrapper。


三、陷阱 2:软删字段类型 BOOLEAN vs INT

3.1 症状

实体字段是 Boolean,PG 列也是 BOOLEAN NOT NULL DEFAULT FALSE,但一查就炸:

PSQLException: ERROR: operator does not exist: boolean = integer
  Hint: You might need to add explicit type casts.

3.2 根因

Flex 默认的 logic-delete 配置用 INT 0/1 绑定逻辑删除值,生成的 SQL 是 WHERE deleted = 0(0 是 INT)。PG 严格类型,BOOLEAN = INTEGER 没有隐式转换——MySQL 默默给你转,PG 不给。

3.3 解决

在全局配置里把绑定值改成 Boolean:

@Configuration
public class AutoFillListener implements MyBatisFlexCustomizer {
    @Override
    public void customize(FlexGlobalConfig globalConfig) {
        // Flex 默认用 0/1 (INT) 绑定 logic-delete, PG 下 BOOLEAN 列对 INT 没有隐式转换
        globalConfig.setNormalValueOfLogicDelete(Boolean.FALSE);
        globalConfig.setDeletedValueOfLogicDelete(Boolean.TRUE);
        ...
    }
}

之后生成的是 WHERE deleted = false(BOOLEAN),PG 认。

3.4 为什么不反过来用 Integer

也可以实体字段改 Integer、DB 列改 INT。但 BOOLEAN 是 PG 一等公民,在 pgAdmin/DBeaver 里显示 true/false 比 0/1 直观,返给前端的 JSON "deleted": false 也比 "deleted": 0 语义更好。这是项目级决策——选 BOOLEAN,就在 Flex 全局配置里把绑定值改了,一次性的事。


四、陷阱 3:APT 注解处理器顺序(最难排查的一个)

4.1 症状

TENANT_ACTION_CONFIG.TENANT_ID.eq(...),编译报错:

cannot find symbol
  symbol:   variable TENANT_ID
  location: class TenantActionConfigTableDef

打开 generated 文件夹下的 TenantActionConfigTableDef.java,发现类生成了,但里面字段是空的——明明实体上有 @Column,TableDef 却没生成任何字段。

4.2 根因

mybatis-flex-processor 是个 APT 注解处理器,编译时扫描带 @Table 的实体,按字段生成 XxxTableDef

问题在于 Lombok 也是 APT。如果 Lombok 在 Flex Processor 之后跑,它给类注入 getter/setter 时 Flex Processor 已经扫描完了——它看到的字段定义不完整(@Data 还没处理),导致 TableDef 字段为空。

4.3 解决

pom.xmlannotationProcessorPaths 必须 Lombok 在前,Flex 在后:

<annotationProcessorPaths>
  <!-- 必须按这个顺序 -->
  <path>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${lombok.version}</version>
  </path>
  <path>
    <groupId>com.mybatis-flex</groupId>
    <artifactId>mybatis-flex-processor</artifactId>
    <version>${mybatis-flex.version}</version>
  </path>
</annotationProcessorPaths>

Lombok 先跑 → 把 @Data 转成实际 getter/setter → 然后 Flex Processor 扫描完整的类 → 正确生成 TableDef。改完先 ./mvnw clean 清掉旧的 generated-sources,再 compile

4.4 为什么这个坑值得单独写

听起来"换个顺序"是小事,但它在迁移过程中花了非常多时间排查:

  • 编译错误只是"cannot find symbol",没有任何 hint 说明是 APT 顺序问题
  • Maven 的 APT 默认顺序由各 plugin 内部行为决定,文档里没明文规定
  • IDEA 自己的编译路径有时和 Maven 不一致,IDE 看着没事 Maven 一编就崩

这种"配置不对就静默失败"的坑是新手最容易卡死的。


五、陷阱 4:自动填充字段不能用 MP 语法

5.1 症状

从 MP 迁过来下意识写 @TableField(fill = FieldFill.INSERT),启动后这两个字段在 INSERT/UPDATE 时永远是 null

5.2 根因

@TableField(fill=...) 是 MyBatis-Plus 注解,Flex 不认。Flex 字段映射用 @Column(...),没有 fill 属性。

5.3 解决:注册 InsertListener / UpdateListener

Flex 是给每个实体类单独注册 Listener,不是 MP 那样全局一份:

private static final InsertListener INSERT = entity -> {
    LocalDateTime now = LocalDateTime.now();
    setIfNull(entity, "createdAt", now);
    setIfNull(entity, "updatedAt", now);
};
private static final UpdateListener UPDATE = entity -> set(entity, "updatedAt", LocalDateTime.now());

@Override
public void customize(FlexGlobalConfig globalConfig) {
    // 需要 createdAt + updatedAt 的实体
    for (Class<?> cls : new Class<?>[]{TenantConfig.class, TenantDatasource.class,
            ActionTemplate.class, SysDict.class}) {
        globalConfig.registerInsertListener(INSERT, cls);
        globalConfig.registerUpdateListener(UPDATE, cls);
    }
    // 只有 createdAt 的表
    for (Class<?> cls : new Class<?>[]{AuditLog.class, AsyncTask.class}) {
        globalConfig.registerInsertListener(INSERT, cls);
    }
    // TenantActionConfig 字段不一样: 只填 grantedAt
    globalConfig.registerInsertListener(INSERT_GRANTED, TenantActionConfig.class);
}

两个关键点:

  • per-entity 注册:多个实体共用一个 Listener,里面用反射 setIfNull(entity, "createdAt", ...) 调用,避免每个实体写重复代码
  • setIfNull 而不是 set:让调用方手工传值时优先生效,Listener 只在没值时填 now()——合理的"默认值"语义

5.4 per-entity 注册的副作用

每加一张需要自动填充的新表,必须改 AutoFillListener.customize 加注册——不像 MP 写完 @TableField(fill=...) 就好。副作用:新人加表容易忘。本系统靠 code review + 测试断言 createdAt != null 双重保障。


六、实体注解对照表(迁移时全局替换用)

用途MyBatis-PlusMyBatis-Flex
表映射@TableName("xxx")@Table("xxx")
主键@TableId(value=..., type=...)@Id(keyType=KeyType.None)
字段映射@TableField("x")@Column("x")
字段忽略@TableField(exist=false)@Column(ignore=true)
软删标记@TableLogic@Column(isLogicDelete=true)
自动填充@TableField(fill=...) + MetaObjectHandlerregisterInsertListener / registerUpdateListener
乐观锁@Version@Column(version=true)

全局搜索替换基本能覆盖 80%,剩下的(自动填充、复合主键写法)按本文方式重写。


七、为什么不直接上 JPA / Hibernate

迁 Flex 时也讨论过"要不要直接上 JPA",结论是:

维度MyBatis 系列JPA / Hibernate
SQL 控制看得见每一条 SQL自动生成,复杂查询要 JPQL/Criteria
性能调优写啥执行啥,可控N+1 / lazy load 陷阱多
多方言适配模板 SQL 自己写Dialect 抽象,有时不准
团队熟悉度Java 后端 90% 都用JPA 在国内偏少

本系统核心场景是多方言模板 SQL + 租户自定义 SQL——SQL 本身就是核心数据,不应被 ORM 隐藏。JPA 适合"业务实体复杂、关系深、SQL 不重要"的场景(传统 ERP);MyBatis 适合"SQL 是一等公民"的场景。本系统是后者。


八、复盘:迁移的真实成本

诚实说:从 MP 迁 Flex 不是一次愉快的经历。成本清单:注解全替换、复合键 Mapper 全部重写、APT 顺序坑、软删 BOOLEAN 类型坑、自动填充 Listener 改写。

事后看值得——支持复合主键 + 类型安全查询是真实收益。但如果一开始就用 Flex 写,所有踩坑都不会发生。

所以给做新项目的人一句话:有复合主键需求就直接上 Flex,没有的话两个差不多。不要走"先 MP 后迁 Flex"的弯路。


写在最后

ORM 框架迁移看起来"不就换个依赖嘛",实际工作量远大于评估时的估计。本文 4 个陷阱每一个都对应一段排查时间,希望能让你少踩几个。

完整代码:enterprise-connector · MyBatis-Flex 官方文档:mybatis-flex.com/