MyBatis 去 XML 的另一种写法:Provider 与 SQL Builder
从一个没有 Mapper XML 的项目切入,重新理解 MyBatis 的“去 XML”并不等于“全写注解”,而是还可以通过 Provider 和 SQL Builder 把动态 SQL 放回 Java 代码中。
一直以来,我对 MyBatis 都有个很强的惯性认知:
Mapper 接口负责声明方法,Mapper.xml 负责写 SQL。
改条件就去 XML 里写 <if test="">,调参数就继续改动态 SQL,很多人从入门到工作多年,都是这样用过来的。
直到最近我看到一个项目,翻遍了 resources/mapper,居然一份 XML 都没找到。
我第一反应是:
这项目肯定是全用
@Select、@Update这类注解硬写 SQL 了。
结果点开 Mapper 之后,我才发现自己想简单了。
1. 去 XML,不只有 @Select
如果只是最简单的单表按主键查询,用注解确实很舒服:
@Select("select * from tb_user where id = #{id}")
UserDO selectById(Long id);
但只要一进入动态 SQL 场景,很多人写着写着就会变成这样:
@Select("<script>" +
"select * from tb_user " +
"<where>" +
" <if test='name != null and name != \"\"'> and name like concat('%', #{name}, '%') </if>" +
" <if test='age != null'> and age >= #{age} </if>" +
"</where>" +
"</script>")
List<UserDO> list(UserQuery req);
这种写法的体验通常都不太好:
- 字符串拼接可读性差
- 没有 SQL 高亮
- 一复杂起来就很难维护
所以很多团队最后还是会回到 XML,至少动态 SQL 放在 XML 里,结构还算清楚。
2. 真正让我改观的是 @SelectProvider
我在那个项目里看到的 Mapper 是这样的:
@SelectProvider(type = UserSqlProvider.class, method = "selectByCondition")
List<UserDO> selectByCondition(UserQuery req);
当时我心里第一反应是:
Provider?这又是什么东西?
点进去一看,是这种写法:
public class UserSqlProvider {
public String selectByCondition(UserQuery req) {
return new SQL() {{
SELECT("id, name, age, status, create_time");
FROM("tb_user");
if (req.getName() != null && !req.getName().isBlank()) {
WHERE("name like concat('%', #{name}, '%')");
}
if (req.getMinAge() != null) {
WHERE("age >= #{minAge}");
}
if (req.getStatus() != null) {
WHERE("status = #{status}");
}
ORDER_BY("create_time desc");
}}.toString();
}
}
这时候我才意识到:
SQL()SELECT()WHERE()ORDER_BY()
这些并不是项目自己封装的 DSL,而是 MyBatis 自带的 SQL Builder。
也就是说,所谓“去 XML”,其实并不只有“把 SQL 全塞进注解字符串里”这一条路。
它还可以是:
- Mapper 接口里仍然只声明方法
- 动态 SQL 写到 Provider 类中
- SQL 最终仍由 MyBatis 执行
- 只是载体从 XML 变成了 Java 方法
3. Provider 的本质是什么?
如果用一句话来概括,Provider 的本质就是:
把原本写在 XML 里的动态 SQL 逻辑,搬回 Java 代码里组织。
它解决的问题主要是两个:
- 动态 SQL 的表达能力
- Java 代码内聚带来的可维护性
比如条件查询、动态筛选、拼接排序字段,这类逻辑用 Provider 往往会比 XML 更贴近业务代码。
再举一个例子,比如动态排序:
public String list(UserQuery req) {
return new SQL() {{
SELECT("*");
FROM("tb_user");
if (req.getName() != null && !req.getName().isBlank()) {
WHERE("name like concat('%', #{name}, '%')");
}
if ("create_time".equals(req.getOrderBy())) {
ORDER_BY("create_time desc");
} else if ("age".equals(req.getOrderBy())) {
ORDER_BY("age desc");
} else {
ORDER_BY("id desc");
}
}}.toString();
}
这类代码有几个明显好处:
- 动态条件逻辑直接用 Java
if - 参数对象和查询逻辑更贴近
- 排序字段可以自然地做白名单校验
尤其是动态排序这种需求,放在 XML 里往往一不小心就会走向字符串拼接;放在 Provider 里,白名单校验反而更顺手。
4. 但 Provider 不是银弹
看到这里,很容易产生一个误解:
既然 Provider 这么灵活,那是不是以后 MyBatis 都不需要 XML 了?
我觉得并不是。
Provider 解决的是“中等复杂度动态 SQL”的体验问题,但它并不能彻底解决复杂 SQL 的表达成本。
比如下面这些场景:
- 多表复杂 Join
- 深层子查询
- CTE
- 窗口函数
- 大段报表 SQL
理论上你都可以继续用 Builder 写,但写着写着就会变成“在 Java 里造 SQL 抽象树”,可读性反而可能还不如直接写原生 SQL。
所以更合理的结论应该是:
- 中等复杂度动态查询:Provider 很适合
- 复杂报表 / 超长 SQL:XML 或独立 SQL 文件可能更直观
也就是说,Provider 不是要替代所有 SQL 载体,而是多提供了一种更适合某类场景的写法。
5. Provider 最容易踩的坑:参数绑定
如果你真打算在项目里系统性使用 Provider,有一个地方很容易踩坑:参数绑定。
5.1 单参数对象:最省心
像这种写法:
List<UserDO> list(UserQuery req);
Provider 里直接写:
#{name}#{minAge}#{status}
通常就能映射到 req 的属性,体验最好。
5.2 多参数场景:一定要加 @Param
如果是这种写法:
@SelectProvider(type = UserSqlProvider.class, method = "get")
UserDO get(@Param("id") Long id, @Param("status") Integer status);
那 Provider 里就必须清楚地按参数名来取值。
否则非常容易出现这种典型问题:
- 参数名变成
param1/param2 - Provider 里写的占位符和实际参数对不上
- 最后报错还不够直观
很多人第一次碰 Provider,最容易卡的不是 SQL Builder 本身,而是参数传递和占位符绑定这块。
6. 这件事为什么值得重新理解?
我觉得这次最有意思的点不是“学到了一个 MyBatis 的冷门注解”,而是它让我重新理解了“去 XML”这件事。
以前我脑子里的等式一直是:
去 XML = 改成注解 SQL
但真正看完这个项目之后,我觉得更准确的理解应该是:
去 XML = SQL 载体从 XML 转移,不一定等于 SQL 表达能力退化。
也就是说,XML 并不是唯一能承载动态 SQL 的地方。
在 MyBatis 体系里,至少还有一条非常现实的路线:
- Mapper 继续保持接口风格
- 动态 SQL 交给 Provider
- 复杂条件交给 Java 逻辑组织
- 最终由 MyBatis 负责执行
这种方式对很多不喜欢 XML、但又不想把 SQL 全塞进注解字符串的人来说,确实是一个很有价值的折中方案。
7. 最后一句话
写了很多年 MyBatis 之后,我越来越觉得:真正限制我们的,往往不是框架本身,而是我们对框架的固有印象。
很多人一提 MyBatis,就默认想到:
- XML
<if><where><foreach>
这些当然都没错,但这并不代表 MyBatis 只能这样用。
@SelectProvider 和 SQL Builder 的存在,其实就在提醒我们一件事:
MyBatis 的“去 XML”,并不只是“把 SQL 写进注解里”,而是可以把动态 SQL 的组织方式重新放回 Java。
如果你的项目正好卡在“XML 太重、注解太乱”这两个极端之间,那 Provider 这条路,确实值得认真看一眼。