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 INT | PG 报 operator does not exist: boolean = integer |
| 3 | APT 处理器顺序 | 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-Plus | MyBatis-Flex |
|---|---|---|
| 复合主键 | 不支持 | 原生支持 |
| 类型安全查询 | LambdaQueryWrapper(反射) | TableDef(APT 编译期生成) |
| 软删 | @TableLogic | @Column(isLogicDelete=true) |
| Listener API | MetaObjectHandler(全局) | 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.xml 的 annotationProcessorPaths 必须 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-Plus | MyBatis-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=...) + MetaObjectHandler | registerInsertListener / 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/