ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis Plus复合主键处理方案全解析:从Model继承到QueryWrapper实战

MyBatis Plus复合主键处理方案全解析:从Model继承到QueryWrapper实战 1. 复合主键的“坑”从何而来最近在重构一个老项目的数据库层把原来的MyBatis切换成了MyBatis Plus。本来想着能省不少事毕竟那些基础的CRUD方法都不用自己写了。结果在实体类定义上就卡住了因为表里用的是复合主键。MyBatis Plus默认的TableId注解只认单个主键面对user_id和role_id这种组合键直接罢工要么报错要么生成的SQL语句逻辑不对。这让我意识到虽然MyBatis Plus简化了单主键场景但复合主键这个需求在实际业务中并不少见比如用户-角色关联表、订单明细表、联合唯一约束等场景处理不好就是个不大不小的“坑”。复合主键简单说就是由两个或更多个字段共同构成一张表的主键。它和单主键在逻辑上最大的区别在于单主键的“唯一性”由一个字段保证而复合主键的“唯一性”需要所有字段组合起来才能确定。在MyBatis Plus的默认世界观里一个实体对应一个主键字段这个设计哲学在遇到复合主键时就会产生冲突。如果你强行给多个字段加上TableId框架会懵不知道以哪个为准如果你一个都不加框架又会认为这个实体没有主键导致updateById、deleteById这些便捷方法全部失效。所以处理MyBatis Plus的复合主键核心思路就是要明确地告诉框架“我这个实体主键是复合的是由这几个字段一起决定的。” 你不能指望框架像处理单字段那样自动识别。接下来我会详细拆解几种主流的解决方案并分享我在实际项目中踩过的坑和最终的选择。2. 方案一继承Model类并重写pkVal方法这是MyBatis Plus官方文档里提到的一种方式也是比较“原生”的一种思路。它的核心思想是让你的实体类继承MyBatis Plus提供的Model类然后重写pkVal()方法在这个方法里返回一个能代表你复合主键的对象。2.1 具体实现步骤首先你的实体类不能再像以前那样只是一个简单的POJO了它需要继承ModelT这里的T就是实体类本身。import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.extension.activerecord.Model; import lombok.Data; import lombok.EqualsAndHashCode; Data EqualsAndHashCode(callSuper true) // 注意这里因为继承了父类需要加上 TableName(sys_user_role) // 你的表名 public class UserRole extends ModelUserRole { private Long userId; private Long roleId; // 其他业务字段... private String createBy; /** * 重写此方法返回复合主键的Map * Map的key是数据库主键字段名value是对应的值 */ Override protected Serializable pkVal() { MapString, Object pkMap new HashMap(2); pkMap.put(user_id, this.userId); pkMap.put(role_id, this.roleId); return pkMap; } }这里有几个关键点继承ModelT这赋予了实体类使用ActiveRecord模式的能力即可以通过实体对象本身调用insert()、updateById()等方法。重写pkVal()这是灵魂所在。方法返回一个Serializable对象我们通常返回一个Map。Map的键key必须是数据库表中的主键列名注意是下划线命名如user_id值value就是当前实体对象对应字段的值。EqualsAndHashCode注解由于我们继承了父类Lombok生成equals和hashCode方法时需要将父类的字段也考虑进去所以必须加上callSuper true否则在后续使用中比如放入Set或作为Map的key可能会出问题。2.2 如何使用与背后的原理定义好实体后你就可以像使用单主键实体那样操作了// 插入 UserRole ur new UserRole(); ur.setUserId(1L); ur.setRoleId(2L); ur.setCreateBy(admin); ur.insert(); // 直接调用实体对象的insert方法 // 根据复合主键更新 UserRole updateEntity new UserRole(); updateEntity.setUserId(1L); updateEntity.setRoleId(2L); updateEntity.setCreateBy(system); updateEntity.updateById(); // 框架会根据pkVal()返回的Map去生成WHERE条件 // 根据复合主键删除 UserRole deleteEntity new UserRole(); deleteEntity.setUserId(1L); deleteEntity.setRoleId(2L); deleteEntity.deleteById(); // 根据复合主键查询使用Service层 UserRole one userRoleService.getOne( new QueryWrapperUserRole() .eq(user_id, 1L) .eq(role_id, 2L) ); // 注意这里不能直接使用 userRoleService.getById(...)因为getById只接受单个Serializable参数。原理剖析当你调用updateById()时MyBatis Plus内部会调用你重写的pkVal()方法拿到那个包含主键字段名和值的Map。然后它会用这个Map来构建SQL的WHERE子句形如WHERE user_id ? AND role_id ?。insert()和deleteById()的逻辑类似。本质上pkVal()方法就是框架与你约定的一个“获取主键标识”的接口。2.3 优缺点与避坑指南优点与ActiveRecord模式结合好对于喜欢用对象直接操作数据库的开发者来说写法很直观。无需额外配置不需要编写复杂的XML或实现特定接口。缺点与坑点污染实体模型实体类为了适配框架而继承了Model这在一定程度上破坏了POJO的纯粹性特别是在领域驱动设计DDD或追求清晰分层架构的项目中这可能不被接受。Service层的getById方法失效这是最大的痛点。IService接口提供的getById(Serializable id)方法期望一个单一的主键值。对于复合主键你无法将一个Map或自定义对象作为id参数传入类型不匹配且框架底层处理逻辑是针对单ID的。因此你必须回退到使用QueryWrapper来拼接条件进行查询失去了getById的便捷性。主键字段名硬编码在pkVal()方法里字段名是以字符串形式硬编码的。如果数据库字段名变更你需要同时修改这里的字符串容易遗漏。避坑提示如果你决定采用此方案务必在团队内明确约定禁止使用xxxService.getById()方法查询复合主键实体统一改用QueryWrapper。可以在对应的Service实现中将getById方法标记为Deprecated并给出注释防止误用。3. 方案二实现IKeyGenerator接口自定义主键生成这个方案通常被误解为只用于生成主键值比如雪花算法。但实际上IKeyGenerator接口的execute()方法返回值会作为insert操作时TableId注解标识字段的值。对于复合主键我们需要的不是“生成”一个值而是“组装”一个能代表复合主键的对象。因此这个方案更准确的用途是自定义主键的组装逻辑但它同样不完美。3.1 自定义主键组装器我们可以创建一个类实现IKeyGenerator接口返回一个代表复合主键的对象比如一个拼接的字符串或者一个JSON字符串。import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; Component public class CompositeKeyGenerator implements IKeyGenerator { Override public String execute(Object entity, String metaObject) { if (entity instanceof UserRole) { UserRole userRole (UserRole) entity; // 将两个ID用特定分隔符拼接如 1:2 return userRole.getUserId() : userRole.getRoleId(); } // 如果不是目标实体可以返回null框架可能会使用其他生成器或数据库自增 return null; } }然后在实体类中你需要指定一个字段作为“逻辑主键”并使用TableId注解并指定type IdType.ASSIGN_ID和你的生成器。Data TableName(sys_user_role) public class UserRole { // 这两个是真实的复合主键字段但不用TableId private Long userId; private Long roleId; // 新增一个字段作为逻辑上的“单一主键”其值由生成器组装 TableId(type IdType.ASSIGN_ID) private String compositeKey; // 数据库可以没有这个字段仅程序使用 // 其他业务字段... }3.2 此方案的严重局限性这个方案听起来有点“曲线救国”实际上它几乎不适用于真正的复合主键场景原因如下破坏表结构它要求你要么在数据库表中增加一个额外的composite_key字段这违背了复合主键的初衷要么这个字段仅存在于实体中而不映射到数据库这会导致ORM映射混乱。无法用于查询和更新即使你生成了这个compositeKeyMyBatis Plus的updateById(compositeKey)或getById(compositeKey)操作的是这个拼接的字符串而不是基于原始的user_id和role_id。你的业务逻辑和SQL条件仍然需要围绕原始的两个字段展开这个生成的key变得毫无意义甚至成为累赘。逻辑复杂且易错你需要维护一套额外的拼接和解析逻辑比如在查询时需要把字符串“1:2”拆解回userId1, roleId2。结论IKeyGenerator方案不适合用来解决MyBatis Plus的复合主键映射问题。它更适合于需要自定义单字段主键生成规则的场景比如一种特殊的业务编码规则。4. 方案三使用TableId的type IdType.INPUT与数据库触发器不推荐这是一个比较“野”的路子理论上可行但维护成本极高。思路是在实体类中定义一个主键字段比如id并设置TableId(type IdType.INPUT)表示这个主键值由用户输入。然后在insert之前通过代码或数据库触发器将user_id和role_id按照某种规则如MD5、拼接后哈希计算出一个值赋给这个id字段。为什么不推荐复杂度剧增你需要确保计算id的规则绝对唯一且需要在每次插入前计算。查询尴尬你想通过user_id1 and role_id2来查一条记录但你的id是计算出来的哈希值你无法反向通过user_id和role_id直接得到这个id因此getById()依然无用。你还是得用QueryWrapper。触发器依赖如果依赖数据库触发器会将业务逻辑耦合到数据库不利于移植和调试。这个方案把简单问题复杂化了引入了不必要的风险点在实际项目中应尽量避免。5. 方案四拥抱QueryWrapper放弃“捷径”思维在深入尝试了各种“奇技淫巧”之后我发现在很多复合主键场景下最稳健、最清晰的做法反而是接受MyBatis Plus默认不支持复合主键的事实然后完全使用QueryWrapper或LambdaQueryWrapper来精确地构建操作条件。这算不上一个“解决方案”而是一种策略选择。它意味着你主动放弃使用saveOrUpdate、updateById、deleteById、getById这些便捷方法转而使用更基础、更强大的条件构造器。5.1 如何操作你的实体类就是一个干净的POJO不需要继承任何类也不需要额外的注解。Data TableName(sys_user_role) public class UserRole { // 就是普通的字段没有TableId private Long userId; private Long roleId; private String createBy; }所有的CRUD操作都通过Service层配合QueryWrapper完成Service public class UserRoleServiceImpl extends ServiceImplUserRoleMapper, UserRole implements UserRoleService { // 插入 - 和以前一样 public boolean saveUserRole(UserRole userRole) { return this.save(userRole); } // 根据复合主键更新 public boolean updateByCompositeKey(UserRole userRole) { if (userRole.getUserId() null || userRole.getRoleId() null) { throw new IllegalArgumentException(复合主键字段不能为空); } UpdateWrapperUserRole updateWrapper new UpdateWrapper(); updateWrapper .eq(user_id, userRole.getUserId()) .eq(role_id, userRole.getRoleId()); // update方法第一个参数是要set的字段值实体对象第二个是条件包装器 return this.update(userRole, updateWrapper); } // 根据复合主键删除 public boolean deleteByCompositeKey(Long userId, Long roleId) { QueryWrapperUserRole queryWrapper new QueryWrapper(); queryWrapper .eq(user_id, userId) .eq(role_id, roleId); return this.remove(queryWrapper); } // 根据复合主键查询 public UserRole getByCompositeKey(Long userId, Long roleId) { QueryWrapperUserRole queryWrapper new QueryWrapper(); queryWrapper .eq(user_id, userId) .eq(role_id, roleId); return this.getOne(queryWrapper); } // 更优雅的写法使用LambdaQueryWrapper避免字段名硬编码 public UserRole getByCompositeKeyLambda(Long userId, Long roleId) { LambdaQueryWrapperUserRole lambdaQueryWrapper new LambdaQueryWrapper(); lambdaQueryWrapper .eq(UserRole::getUserId, userId) .eq(UserRole::getRoleId, roleId); return this.getOne(lambdaQueryWrapper); } }5.2 此方案的巨大优势代码清晰意图明确eq(“user_id”, 1L).eq(“role_id”, 2L)这行代码任何人一看就知道是要操作user_id1 and role_id2的记录。没有黑魔法没有隐式约定。实体类纯净你的UserRole就是一个纯粹的数据载体不包含任何框架特定的逻辑符合单一职责原则。功能强大且灵活QueryWrapper不仅能处理等值查询还能处理、、like、in、order by等所有复杂SQL条件。你用它处理复合主键同时也能处理其他所有复杂查询。避免隐性错误彻底杜绝了误用getById的可能性所有操作都是显式的。与MyBatis Plus的设计哲学更契合MyBatis Plus的核心优势之一就是强大的条件构造器。用它来解决框架“短板”复合主键的问题是以己之长补己之短。5.3 需要做的适配工作当然选择这个方案意味着你要放弃一些“偷懒”的机会自定义Service方法你需要像上面示例一样在Service接口和实现中为复合主键实体定义专用的updateByKey、deleteByKey、getByKey等方法。统一团队规范需要让团队所有成员都了解对于这类表一律使用自定义的Service方法而非通用的getById。在我看来这点额外的代码成本换来的是长期的、更高的代码可读性和可维护性是完全值得的。6. 方案五终极方案——自定义SqlInjector与AbstractMethod如果你追求极致既想保持实体类的纯洁又想为复合主键实体“复活”getById这类方法那么可以挑战一下MyBatis Plus的扩展机制自定义SqlInjector和AbstractMethod。这个方案技术难度最高但也最优雅、最彻底。其核心原理是MyBatis Plus的所有内置方法如insert、updateById、selectById都是通过SqlInjectorSQL注入器将对应的AbstractMethod抽象方法注入到Mapper中的。我们可以仿照UpdateById这个类的逻辑创建一个UpdateByCompositeKey方法并把它注入到我们的Mapper里。6.1 第一步定义复合主键注解为了方便识别哪些字段是复合主键我们可以先定义一个注解Documented Retention(RetentionPolicy.RUNTIME) Target({ElementType.FIELD}) public interface CompositeKey { // 可以设置顺序等属性 int order() default 0; }6.2 第二步在实体类上使用Data TableName(sys_user_role) public class UserRole { CompositeKey(order 1) private Long userId; CompositeKey(order 2) private Long roleId; private String createBy; }6.3 第三步编写自定义的AbstractMethod这是最复杂的一步。你需要继承AbstractMethod类并重写injectMappedStatement方法。这里以创建updateByCompositeKey方法为例import com.baomidou.mybatisplus.core.injector.AbstractMethod; import com.baomidou.mybatisplus.core.metadata.TableInfo; import org.apache.ibatis.mapping.MappedStatement; import org.apache.ibatis.mapping.SqlSource; public class UpdateByCompositeKey extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { // 1. 获取实体类中所有被CompositeKey注解的字段 ListField keyFields getCompositeKeyFields(modelClass); // 2. 构建SQL语句 // 格式UPDATE table SET ... WHERE key1 #{et.key1} AND key2 #{et.key2} String sql String.format(script\\nUPDATE %s %s WHERE %s/script, tableInfo.getTableName(), sqlSet(tableInfo.isWithLogicDelete(), false, tableInfo, false, et, et.), // 这里是关键构建复合主键的WHERE条件 keyFields.stream() .map(f - tableInfo.getKeyColumn(f.getName()) #{et. f.getName() }) .collect(Collectors.joining( AND )) ); // 3. 创建SqlSource和MappedStatement SqlSource sqlSource languageDriver.createSqlSource(configuration, sql, modelClass); return this.addUpdateMappedStatement(mapperClass, modelClass, updateByCompositeKey, sqlSource); } private ListField getCompositeKeyFields(Class? clazz) { // 反射获取带有CompositeKey注解的字段并按order排序 // 省略具体实现... } }6.4 第四步自定义SqlInjector创建一个自定义的注入器将我们写好的方法添加到方法列表中。import com.baomidou.mybatisplus.core.injector.AbstractSqlInjector; import java.util.List; import java.util.stream.Stream; import static java.util.stream.Collectors.toList; public class MySqlInjector extends DefaultSqlInjector { Override public ListAbstractMethod getMethodList(Class? mapperClass, TableInfo tableInfo) { // 获取父类DefaultSqlInjector原有的所有方法 ListAbstractMethod methodList super.getMethodList(mapperClass, tableInfo); // 添加我们自定义的方法 methodList.add(new UpdateByCompositeKey()); // 还可以添加 DeleteByCompositeKey, SelectByCompositeKey 等 return methodList; } }6.5 第五步配置到Spring最后将自定义的SqlInjector配置为Bean。Configuration public class MybatisPlusConfig { Bean public MySqlInjector mySqlInjector() { return new MySqlInjector(); } }完成以上步骤后你的UserRoleMapper接口就自动拥有了updateByCompositeKey(UserRole entity)方法其内部SQL会根据CompositeKey注解的字段自动生成正确的WHERE条件。6.6 此方案的评估优点高度封装使用优雅对业务开发者透明他们可以像使用updateById一样使用updateByCompositeKey。实体类相对干净只需要添加自定义注解无需继承框架类。缺点实现复杂维护成本高需要深入理解MyBatis Plus内部机制代码量较大。升级风险MyBatis Plus框架版本升级时内部API可能有变动你的自定义方法需要同步测试和调整。过度设计风险对于大多数项目方案四使用QueryWrapper已经足够清晰和简单。只有在你非常确定需要为复合主键提供大量便捷方法且团队有能力维护这套自定义扩展时才考虑此方案。7. 实战总结与选型建议走完了这几种方案的探索之路我的结论非常明确对于大多数中小型项目或复合主键操作不频繁的场景强烈推荐【方案四拥抱QueryWrapper】。它简单、直接、强大没有任何“魔法”代码意图一目了然维护成本最低。牺牲一点“便捷性”换来的是巨大的代码清晰度和稳定性。这是性价比最高的选择。如果你非常依赖ActiveRecord模式且能接受Service.getById不可用可以考虑【方案一继承Model】。但务必在团队内建立严格的规范防止误用getById。同时要评估实体类继承框架类对项目架构的长期影响。【方案五自定义注入器】是技术上的终极解决方案它提供了框架级别的完美支持。但它只适合架构能力非常强、追求极致、且愿意为框架扩展投入长期维护成本的技术团队。对于普通业务项目来说属于“杀鸡用牛刀”。【方案二自定义生成器和方案三INPUT类型】基本可以排除它们要么不解决根本问题要么引入了更糟糕的复杂性。最后还有一个更根本的建议在数据库设计阶段就尽量避免使用复合主键。可以考虑增加一个独立的、无业务意义的自增ID作为代理主键Surrogate Key而将原本计划作为复合主键的字段设置为唯一索引UNIQUE KEY。这样在应用层包括MyBatis Plus处理起来会简单得多getById、updateById等操作都能直接使用性能上通常也没有差异。很多时候使用复合主键是早期设计上的惯性思维改用代理主键唯一约束是更现代、更灵活的设计方式。当然如果面对的是遗留系统或确有强业务逻辑要求那么上述的【方案四】就是你最可靠的伙伴。
返回列表