1. 项目概述:告别重复的SQL,拥抱Example查询
如果你和我一样,常年泡在Java后端开发里,尤其是和Spring Boot、MyBatis打交道,那你一定对写那些千篇一律的增删改查SQL感到厌倦。每次新加一个字段,对应的*Mapper.xml里就得小心翼翼地同步修改好几个方法的SQL,生怕漏了一个条件导致查询结果出错。更头疼的是动态查询,一堆if test标签嵌套,代码又臭又长,可读性极差。
通用Mapper,特别是它的tk.mybatis实现(现在主流是mapper-spring-boot-starter),就是来解决这个痛点的。它不是一个新框架,而是基于MyBatis的一个插件,其核心思想是“约定大于配置”。通过继承它提供的通用接口,你的Mapper接口瞬间就拥有了数十个常用的单表操作方法,无需编写任何SQL。而Example查询,则是这个工具集中最闪耀的明珠,它让你能用面向对象的方式,流畅地构建复杂的动态查询条件,彻底告别在XML里拼接字符串的噩梦。
简单来说,通用Mapper + Example的组合,能让你在80%的单表业务场景下,将数据库操作代码量减少70%以上,并且让代码更清晰、更安全(避免SQL注入)、更易于维护。接下来,我就结合自己多年的实战经验,带你从零开始,深度拆解这套组合拳的威力与细节。
2. 核心设计思路与架构解析
2.1 为什么是通用Mapper,而不是MyBatis-Plus或其他?
首先得明白,tk.mybatis的通用Mapper和MyBatis-Plus的通用Service,解决的是类似的问题,但哲学和实现路径不同。我选择前者的一个重要原因是它的“侵入性”更低。它本质上是一个MyBatis插件,通过动态生成SQL来实现通用CRUD。你的实体类就是普通的POJO,你的Mapper接口只需要继承一个Mapper<T>接口,一切就绪。这种设计让我感觉更贴近“原生”的MyBatis,学习曲线平缓,在已有项目中引入的风险也更小。
它的核心设计基于JPA(Java Persistence API)的注解风格,使用@Table、@Column、@Id等注解来建立实体与数据库表的映射关系。插件在运行时,会读取这些注解信息,结合你调用的方法名(如selectByPrimaryKey)或传入的Example对象,动态拼接出正确的SQL语句。这种“运行时生成”的方式,既保证了灵活性,又避免了手动编写SQL的繁琐和错误。
2.2 Example查询的设计哲学:面向对象的条件组装
Example类是通用Mapper的灵魂特性。它的设计灵感来源于Hibernate的Criteria查询,但更加轻量和MyBatis化。其核心思想是:将查询条件抽象为一个对象,通过调用这个对象的方法来逐步添加条件,最终这个对象本身就能完整描述一个WHERE子句。
传统的MyBatis动态SQL是这样的:
<select id="selectByCondition" parameterType="map" resultMap="BaseResultMap"> SELECT * FROM user <where> <if test="name != null and name != ''"> AND name like concat('%', #{name}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="startTime != null"> AND create_time >= #{startTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> </where> ORDER BY create_time DESC </select>每增加一个查询字段,就需要修改XML和对应的参数Map,协作和阅读成本都很高。
而使用Example,你在Java代码中就可以完成:
Example example = new Example(User.class); Example.Criteria criteria = example.createCriteria(); if (StringUtils.isNotBlank(name)) { criteria.andLike("name", "%" + name + "%"); } if (status != null) { criteria.andEqualTo("status", status); } if (startTime != null && endTime != null) { criteria.andBetween("createTime", startTime, endTime); } example.orderBy("createTime").desc(); List<User> userList = userMapper.selectByExample(example);优势一目了然:
- 类型安全:
andEqualTo(“status”, status),如果status字段是Integer,你传一个String,编译期就会报错。XML中的#{status}可没这待遇。 - 代码即文档:查询逻辑清晰地展现在Java代码中,无需在XML和Java文件间来回跳转。
- 易于重构:字段名
“name”是字符串,配合IDE的重构功能,修改实体字段名时,这里会同步提示错误,避免漏改。 - 动态性更强:可以非常方便地在循环中、在逻辑判断中动态添加条件,构建复杂的查询树(通过
or()方法)。
2.3 核心类与接口关系图(概念)
虽然不能画图,但我们可以理清关系:
Mapper<T>接口:所有通用方法的源头,定义了selectByExample,updateByExampleSelective等方法。Example类:条件查询的封装。内部包含:orderByClause:排序子句。distinct:是否去重。- 一个或多个
Criteria对象(通过createCriteria()或or()创建)。
Criteria内部类:真正存放条件的地方。每个Criteria对象包含一个List<Criterion>,每个Criterion就是一个具体的条件(如name = ‘张三’)。- 实体类(POJO):使用
@Table,@Id,@Column等注解与数据库表关联。
你的自定义Mapper接口继承Mapper<T>,便拥有了操作Example的能力。插件在运行时,会解析Example对象和实体类注解,生成最终的SQL。
3. 环境集成与基础配置详解
3.1 Spring Boot项目中的依赖引入
现在最流行的方式是使用mapper-spring-boot-starter,它帮你自动配置好了大部分内容。在你的pom.xml中添加:
<dependency> <groupId>tk.mybatis</groupId> <artifactId>mapper-spring-boot-starter</artifactId> <version>最新版本</version> <!-- 例如 4.2.1 --> </dependency>这个starter会自动引入mapper-core(核心包)和MyBatis-Spring-Boot-Starter。注意:它可能会和官方的mybatis-spring-boot-starter产生冲突,所以通常项目中只保留这一个即可。
3.2 实体类的注解配置规范
实体类的注解是通用Mapper工作的基石。以下是一个标准的示例:
import javax.persistence.*; import java.util.Date; @Table(name = "sys_user") // 指定表名,若类名与表名符合驼峰转下划线规则,可省略 public class User { @Id // 标记为主键 @GeneratedValue(strategy = GenerationType.IDENTITY) // 主键自增策略 private Long id; @Column(name = "user_name") // 指定列名 private String userName; private String email; // 默认按驼峰转下划线规则映射到 `email` private Integer status; @Transient // 此字段非数据库表字段,插件会忽略 private String temporaryToken; // getter, setter, toString 省略 }关键注解说明:
@Table(name = “实际表名”):最重要的注解之一。如果实体类名遵循驼峰转下划线且与表名一致(如UserInfo->user_info),可省略。@Id:必须标注在主键字段上。一个实体类必须有且仅有一个@Id注解字段,否则通用方法会出错。@GeneratedValue:指示主键生成策略。IDENTITY对应MySQL的AUTO_INCREMENT,UUID可以配合@Id注解在插入前生成主键。@Column:用于指定字段与数据库列的映射关系。最常用的是name属性。如果字段名已符合驼峰转下划线,可省略。@Transient:标记该字段不是数据库表的列,插件在生成SQL时会自动忽略它。常用于业务逻辑中的临时属性。
实操心得:建议即使字段名与列名规则一致,也显式地写上
@Column(name = “xxx”)。这有两个好处:一是代码意图更清晰;二是当数据库列名因历史原因非常不规则时(如USER_NAME_),可以直接指定,避免后续踩坑。
3.3 Mapper接口的创建与扫描
你的Mapper接口需要继承通用的Mapper<T>接口,并指定泛型为对应的实体类。
import tk.mybatis.mapper.common.Mapper; public interface UserMapper extends Mapper<User> { // 这里可以定义你自己的非通用方法 // 例如复杂的联表查询,仍需在XML中编写SQL List<User> selectComplexUsersByRole(@Param("roleId") Long roleId); }接下来,需要在Spring Boot的启动类或配置类上,添加@MapperScan注解来扫描你的Mapper接口。这里有个巨坑!
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import tk.mybatis.spring.annotation.MapperScan; @SpringBootApplication // 注意!一定要使用 tk.mybatis 包下的 @MapperScan // 而不是 org.mybatis.spring.annotation.MapperScan @MapperScan(basePackages = "com.yourpackage.mapper") public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }为什么必须用tk.mybatis的@MapperScan?因为这个注解器内部会进行特殊处理,将你的接口注册为继承了通用Mapper接口的代理Bean。如果用了MyBatis官方的扫描器,你的接口就只是一个普通的MyBatis Mapper,那些通用的selectByExample等方法将无法被识别和实现。
4. Example查询的深度实战与技巧
掌握了基础,我们来深入Example的每一个角落。Example的强大,在于它用简单的API覆盖了绝大部分WHERE子句的场景。
4.1 基础条件构造:从等值查询到模糊匹配
创建一个Example对象是第一步:
Example example = new Example(User.class); Example.Criteria criteria = example.createCriteria();Criteria提供了丰富的方法:
等值查询:
andEqualTo(“字段名”, 值)criteria.andEqualTo("status", 1); // WHERE status = 1 criteria.andEqualTo("userName", "张三"); // WHERE user_name = ‘张三’这是最常用、最核心的方法。
不等值查询:
andNotEqualTocriteria.andNotEqualTo("status", 0); // WHERE status <> 0范围查询:
andBetween(“字段名”, 值1, 值2):闭区间。criteria.andBetween("age", 18, 30); // WHERE age BETWEEN 18 AND 30andGreaterThan/andGreaterThanOrEqualTo/andLessThan/andLessThanOrEqualTo:开闭区间。criteria.andGreaterThan("createTime", startDate); // WHERE create_time > #{startDate}
模糊查询:
andLike(“字段名”, 值):值中需自行包含%。criteria.andLike("userName", "%张%"); // WHERE user_name LIKE ‘%张%’andNotLike:反向模糊匹配。
空值查询:
criteria.andIsNull("email"); // WHERE email IS NULL criteria.andIsNotNull("phone"); // WHERE phone IS NOT NULLIN 查询:
andIn(“字段名”, Collection<?> 值集合)List<Integer> statusList = Arrays.asList(1, 2, 3); criteria.andIn("status", statusList); // WHERE status IN (1, 2, 3)注意:传入的集合不能为空,否则会抛出异常。实践中一定要先判断
if (collection != null && !collection.isEmpty())。
4.2 复杂条件组合:与(AND)、或(OR)和子条件
单Criteria对象内的所有条件默认是AND关系。如何实现OR?
同一Criteria内的OR:使用
or()方法链式调用。criteria.andEqualTo("status", 1) .orEqualTo("userName", "admin"); // 生成的SQL是:WHERE (status = 1) OR (user_name = ‘admin’) // 注意:这个`orEqualTo`是和前面`andEqualTo`同级的OR更清晰的写法是使用
or(Criteria):criteria.andEqualTo("status", 1); criteria.or().andEqualTo("userName", "admin"); // 效果同上,但结构更清晰多个Criteria(实现复杂AND/OR嵌套):这是
Example的高级用法。Example可以包含多个Criteria,它们之间是OR关系。Example example = new Example(User.class); // 第一个Criteria:状态为1 且 姓名包含‘张’ Example.Criteria criteria1 = example.createCriteria(); criteria1.andEqualTo("status", 1); criteria1.andLike("userName", "%张%"); // 第二个Criteria:状态为2 且 邮箱不为空 (与第一个Criteria是OR关系) Example.Criteria criteria2 = example.createCriteria(); criteria2.andEqualTo("status", 2); criteria2.andIsNotNull("email"); // 最终SQL: WHERE (status = 1 AND user_name LIKE ‘%张%’) OR (status = 2 AND email IS NOT NULL) List<User> list = userMapper.selectByExample(example);通过创建多个
Criteria,你可以构建出任意复杂的(A AND B) OR (C AND D)这类查询逻辑。
4.3 排序、去重与字段选择
排序:
// 单字段排序 example.orderBy("createTime").desc(); // ORDER BY create_time DESC example.orderBy("age").asc(); // ORDER BY age ASC // 多字段排序 example.orderBy("status").asc().orderBy("id").desc(); // ORDER BY status ASC, id DESC去重:
example.setDistinct(true); // SELECT DISTINCT ...字段选择(SelectColumns):默认查询所有字段(
SELECT *)。但有时我们只需要特定字段,可以使用selectProperties。example.selectProperties("id", "userName", "email"); // 生成的SQL: SELECT id, user_name, email FROM ...这是一个性能优化点,特别是对于有
BLOB/TEXT大字段的表,避免不必要的数据传输和序列化开销。
4.4 结合分页插件使用
Example通常和分页插件(如PageHelper)一起使用,实现高效的分页查询。这是国内项目非常标准的搭配。
import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; // 第2页,每页10条,并按照创建时间倒序 PageHelper.startPage(2, 10); Example example = new Example(User.class); example.createCriteria().andEqualTo("status", 1); example.orderBy("createTime").desc(); List<User> userList = userMapper.selectByExample(example); PageInfo<User> pageInfo = new PageInfo<>(userList); // pageInfo中包含了总数、总页数、当前页数据等所有分页信息关键点:PageHelper.startPage(pageNum, pageSize)必须紧贴在真正的查询方法(selectByExample)调用之前,中间不能有其它数据库查询操作,否则分页会失效或错乱。
5. 增删改查的通用方法实战
Example不仅用于查询(selectByExample),还能用于条件更新和删除,这是它比单纯写SQL更优雅的地方。
5.1 查询操作家族
List<T> selectByExample(Example example):最常用的条件查询。T selectOneByExample(Example example):查询单条记录。特别注意:如果查询结果多于一条,会抛出TooManyResultsException。确保你的条件能唯一确定一条记录时使用。int selectCountByExample(Example example):条件计数,用于分页查询总数或统计,性能优于先查列表再size()。List<T> selectByExampleAndRowBounds(Example example, RowBounds rowBounds):结合MyBatis原生的RowBounds进行内存分页(不推荐用于大数据量)。
5.2 更新操作:选择性更新与全量更新
这是Example在更新场景下的威力体现。
updateByExample:全量更新。User updateUser = new User(); updateUser.setUserName("新名字"); updateUser.setEmail("new@email.com"); // 注意:updateUser中所有属性都会被更新到数据库,包括为null的字段 Example example = new Example(User.class); example.createCriteria().andEqualTo("status", 0); userMapper.updateByExample(updateUser, example); // SQL: UPDATE user SET user_name='新名字', email='new@email.com', ...(所有字段) WHERE status = 0风险:会覆盖所有字段,可能误将其他字段更新为
null。慎用!updateByExampleSelective:选择性更新(强烈推荐)。User updateUser = new User(); updateUser.setEmail("updated@email.com"); // 只设置要更新的字段 Example example = new Example(User.class); example.createCriteria().andEqualTo("id", 1L); userMapper.updateByExampleSelective(updateUser, example); // SQL: UPDATE user SET email='updated@email.com' WHERE id = 1这个方法只会更新实体类中非空的属性。这是最安全、最常用的更新方式,完美支持部分字段更新。
5.3 删除操作
int deleteByExample(Example example):根据条件删除。
警告:执行删除前务必再三确认Example example = new Example(User.class); example.createCriteria().andIsNull("email"); // 删除邮箱为空的用户 int deletedCount = userMapper.deleteByExample(example);Example条件,避免误删大量数据。生产环境建议先selectByExample查看一下将要删除的数据。
5.4 插入操作
虽然插入不直接使用Example,但通用Mapper也提供了便捷方法:
int insert(T record):插入一条记录,所有字段都会参与插入,null值也会插入。int insertSelective(T record):选择性插入,只插入非空字段。对于有默认值的数据库列,这是首选。
6. 高级特性与自定义扩展
6.1 类型处理器(TypeHandler)的集成
通用Mapper完全兼容MyBatis的TypeHandler。例如,你有一个User实体,其中有一个Map<String, Object>类型的attributes字段,想以JSON字符串形式存到数据库的TEXT列。
- 首先,你需要一个自定义的
TypeHandler(例如,使用Jackson进行JSON序列化/反序列化)。 - 在实体字段上通过
@ColumnType注解指定:
这样,在使用@ColumnType(typeHandler = JsonTypeHandler.class) // 你的自定义TypeHandler private Map<String, Object> attributes;Example查询或插入时,通用Mapper会自动调用这个TypeHandler进行类型转换。
6.2 自定义通用方法
如果通用Mapper自带的方法不能满足你,你可以扩展它。你需要:
- 创建一个自定义的接口,继承
Mapper<T>和你想要的通用Mapper提供的其他接口(如SelectByIdsMapper<T>)。import tk.mybatis.mapper.common.IdsMapper; import tk.mybatis.mapper.common.Mapper; import tk.mybatis.mapper.common.MySqlMapper; public interface MyBaseMapper<T> extends Mapper<T>, MySqlMapper<T>, IdsMapper<T> { // 继承MySqlMapper可以获得MySQL特有的批量插入方法 // 继承IdsMapper可以获得根据主键字符串(逗号分隔)查询和删除的方法 } - 让你的业务Mapper继承这个自定义的
MyBaseMapper。 - 在启动类
@MapperScan中,指定markerInterface属性,告诉扫描器你的这个标记接口。@MapperScan(basePackages = "com.xx.mapper", markerInterface = MyBaseMapper.class)
6.3 乐观锁与逻辑删除的集成
通用Mapper社区提供了一些扩展插件来处理常见需求。
- 乐观锁:通过
@Version注解标记版本号字段,更新时会自动带上version = oldVersion条件,并在成功后自增。 - 逻辑删除:通过
@LogicDelete注解标记逻辑删除字段(如is_deleted)。当调用deleteByExample时,实际执行的是UPDATE table SET is_deleted = 1 WHERE ...。而所有的select*方法会自动附加AND is_deleted = 0条件。 这些功能需要引入额外的依赖(如mapper-extra)和配置,能极大简化业务代码。
7. 避坑指南与性能优化
7.1 常见问题排查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
报错:Invalid bound statement (not found) | 1. Mapper接口未被扫描到。 2. 使用了MyBatis官方的 @MapperScan。3. 方法名与通用Mapper内置方法不匹配。 | 1. 检查@MapperScan包路径是否正确。2.确保使用 tk.mybatis包下的@MapperScan。3. 检查是否错误覆盖了继承的方法。 |
查询结果字段为null | 1. 实体类字段名与数据库列名映射失败(驼峰转换问题)。 2. 数据库列名有特殊字符或关键字。 | 1. 使用@Column(name=”xxx”)显式指定。2. 在 @Column的name属性中使用反引号`column_name`。 |
selectOne抛出TooManyResultsException | 查询条件返回了多条结果。 | 确保查询条件能唯一确定一条记录,或改用selectByExample返回List。 |
| 更新/删除了全部数据 | Example条件构造错误,例如criteria.andEqualTo(“status”, null),当值为null时,此条件不会被添加到WHERE子句中。 | 在Java代码中做好判空,避免构建出无条件的Example。对于更新,优先使用updateByExampleSelective。 |
分页插件PageHelper失效 | PageHelper.startPage()调用位置不对,与查询方法之间有其他查询。 | 确保startPage紧贴在目标查询方法之前。 |
| 性能问题,查询慢 | 1. 未使用selectProperties导致查询*。2. 复杂 Example条件导致索引失效。3. in查询列表过长。 | 1. 按需选择字段。 2. 为常用查询条件建立数据库索引,并利用 Example的andEqualTo等走索引的方法。3. 对超长 in列表进行分批查询。 |
7.2 性能优化建议
- 索引是王道:
Example生成的SQL本质还是SQL。确保andEqualTo、andBetween、orderBy等操作涉及的字段已建立合适的数据库索引。使用EXPLAIN命令分析生成的SQL。 - 慎用
select *:务必养成使用example.selectProperties(“id”, “name”)的习惯,特别是表中有大字段时。 in查询长度限制:通过andIn进行查询时,如果传入的集合过大(例如超过1000条),某些数据库(如Oracle)可能会报错。需要在业务层进行分批处理。Example对象复用:在循环中构建相似查询时,注意Example和Criteria对象的创建开销。虽然不大,但在极高并发下可考虑对象复用或更底层的优化。- 监控生成的SQL:在开发环境,开启MyBatis的SQL日志(
logging.level.tk.mybatis.mapper=DEBUG),查看最终生成的SQL语句是否符合预期,这是排查问题最直接的方式。
7.3 关于复杂查询的边界
通用Mapper的Example再强大,也主要服务于单表操作。对于复杂的多表关联查询、嵌套查询、公用表表达式(CTE)等场景,它就显得力不从心了。
我的实践原则是:
- 单表动态条件查询、更新、删除:一律使用
Example,代码简洁又安全。 - 简单的固定关联查询:可以在自定义的Mapper方法中,使用
@Select注解提供SQL。 - 复杂的、动态的、涉及多表的查询:老老实实回到XML文件中编写
<select>语句,利用MyBatis动态SQL标签(<if>,<choose>,<foreach>)来完成。通用Mapper和XML方式在项目中是可以和谐共存的。
最后,再分享一个我个人的小技巧:对于团队项目,可以建立一个“通用Mapper使用规范”文档,明确规定什么场景必须用Example,什么场景用XML,以及实体类注解、Example构建的代码格式。这能极大提升团队代码的一致性和可维护性,让这个优秀的工具发挥出最大的价值。