如果你维护过一个后台管理页面,上面有用户名、手机号、状态、注册时间范围、是否VIP这些筛选条件,你大概率在MyBatis里写过这样一大段XML:每个条件都要关心空值判断、字段名拼接、逗号和and的位置。后来我把这类查询逐步切换到了MyBatis Dynamic SQL——官方那个Java DSL风格的SQL构建库,一两个迭代下来,SQL编写和维护效率的提升用“3倍”来形容,真不是夸张。这篇我想把这一年多实践里让我最受益的5个关键优势、典型用法和踩过的坑一次说清楚,给同样被XML动态SQL折磨的同行一个参考。
1. XML里的动态SQL,为什么总觉得“不够用”
1.1 一个典型的多条件查询,XML要写多少样板
先看一段我在多个项目里反复见到的查询,场景是用户管理列表,六个筛选条件加一个删除标记开关:
<select id="selectUserByCondition" resultMap="UserResultMap"> select id, name, phone, status, city, vip, created_at from users <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="phone != null and phone != ''"> and phone = #{phone} </if> <if test="status != null"> and status = #{status} </if> <if test="startTime != null"> and created_at >= #{startTime} </if> <if test="endTime != null"> and created_at <= #{endTime} </if> <if test="vip != null"> and vip = #{vip} </if> <if test="deleted != null"> and deleted = #{deleted} </if> </where> order by id desc </select>说实话,六个条件这样写还能接受。但现实业务里的查询往往不止六个条件。我见过一个600行的select,where条件里嵌了四层if,还有两个choose做or分支,光缩进和括号就够人喝一壶。更麻烦的是,XML里的动态逻辑在编译期验证不到:字段名拼错,编译不报错,请求进来才报错;and、or写错位置,查询结果错了却不报异常;想把这个查询里的某个条件组合复用到另一个查询,只能复制粘贴。
这种“文本模板式”的动态SQL,本质上是在用字符串处理和人力记忆对抗不确定性,一旦条件数量上了量级,维护成本会指数上升。真正压垮我的,是下面几个具体的时刻。
1.2 让我决定换掉它的三个临界时刻
第一个时刻是一次字段改名引发的线上事故。当时把orders表的user_id改名为owner_id,我在XML里全局搜索替换,漏了一处,结果那个查询在凌晨定时任务里触发了字段不存在的错误,被线上告警叫醒。字段名这种纯字符串引用,靠肉眼扫XML,你永远不知道漏掉的那处在什么时候爆雷。
第二个时刻是我想验证“不同条件组合下生成的SQL是否正确”。发现XML动态SQL几乎没法做纯单元测试。你得启动Spring Boot上下文、起H2内存库、把MyBatis跑起来才能验证。一套流程下来,短则几十秒,慢则几分钟,根本形成不了快速试错循环。而开发效率提升最关键的因素,恰恰是反馈速度。
第三个时刻更直观:团队新同事在一个三层嵌套的if里加一个or条件,加了半天不敢提交,因为没人能通过低成本测试证明改完还是对的。那一刻我意识到,工具选型在用人的心智负担投票,XML这套模式已经到极限了。
2. 第一个关键优势:类型安全的列引用,把拼写错误挡在编译期
2.1 Table类与Column:一次定义,全局受益
MyBatis Dynamic SQL的第一步,是用一个Table类把表和列的元信息定义好,之后所有SQL引用都走列对象而不是字符串。我的写法通常是这样的:
public final class UserDynamicSqlSupport { public static final User user = new User(); public static final class User extends SqlTable { public final Column<Integer> id = column("id", JDBCType.INTEGER); public final Column<String> name = column("name", JDBCType.VARCHAR); public final Column<String> phone = column("phone", JDBCType.VARCHAR); public final Column<Integer> status = column("status", JDBCType.INTEGER); public final Column<String> city = column("city", JDBCType.VARCHAR); public final Column<Boolean> vip = column("vip", JDBCType.BOOLEAN); public final Column<LocalDateTime> createdAt = column("created_at", JDBCType.TIMESTAMP); public final Column<Boolean> deleted = column("deleted", JDBCType.BOOLEAN); public User() { super("users"); } } }注意几个点:列名“users”和“created_at”只在定义处出现一次,后面所有查询都用user.name、user.createdAt这种引用。列名和Java属性只是通过Column对象建立映射,而不是靠复制字符串。Column<Boolean>这种声明,也让“vip字段被当成Integer使用”这类的类型错配在编译期就暴露出来。
2.2 从“搜XML找字段”到“IDE全局重构”
以前改一个字段名,流程是把字段名在项目里全局搜一遍,然后逐个打开XML看清楚它到底属于哪张表、哪些查询在用。搜索出来的结果里还夹杂着注释、日志、其它微服务里的同名常量,得靠人肉分辨。现在改字段名,只需要在Table类定义处改一次,然后借助IDE的Refactor——Rename,所有引用这个Column的地方自动更新,没有任何漏网之鱼。
加字段也是一样。以前加一个字段,要在N个select的字段列表、resultMap、update set语句里手工补齐。现在只要在Table类加一个Column,然后让编译器告诉你哪些查询构建方法需要同步。这种体验上的差异,第一次用的时候会有一种“以前到底在干嘛”的感慨。
2.3 效率账:一次运行时错误平均浪费多少时间
粗略算一笔账:手写XML字段名,漏写、错写的概率谁都不敢保证是零。一次字段名错误从发生到修复,背后可能是“测试环境凑巧没覆盖到,线上告警才发现→翻日志定位→修复→重新上线”,平均至少一个小时。而类型安全让这个发现时间从小时级压缩到代码写下的那一刻,毫秒级报错。一个团队每个月少踩一两次这种坑,节省下来的时间相当可观。
再加上IDE补全带来的输入速度提升,写一个查询时再也不用背字段名,输入user.之后IDE自动列出一堆Column供选择。这个体验带来的效率增益,才是“3倍”这个数字最直接的来源。
3. 第二个关键优势:函数式DSL,动态条件不用再“拼字符串”
3.1 where/isEqualTo/isLike:一条查询按着业务语言展开
真正爽的部分来了。动态SQL在XML里的核心矛盾,是“要不要包含这个条件”和“条件之间的关系”全都要靠标签和文本位置来表达。到了MyBatis Dynamic SQL里,这个问题变成了简单的Java方法调用:
public List<User> searchUsers(String name, Integer status, LocalDateTime startTime, LocalDateTime endTime) { SelectStatementProvider select = select(user.id, user.name, user.phone, user.status, user.createdAt) .from(user) .where(user.deleted, isEqualTo(false)) .and(user.name, isLikeWhenPresent(likePattern(name))) .and(user.status, isEqualToWhenPresent(status)) .and(user.createdAt, isGreaterThanOrEqualToWhenPresent(startTime)) .and(user.createdAt, isLessThanOrEqualToWhenPresent(endTime)) .orderBy(user.id.descending()) .build() .render(RenderingStrategy.MYBATIS3); return userMapper.selectMany(select); }likePattern是我自己写的工具方法,把name转成%name%并转义掉用户输入里的%和_。isEqualToWhenPresent、isLikeWhenPresent这类“WhenPresent”系列方法,作用是当参数值为null时自动丢弃这个条件。这样每个条件就浓缩成一行方法调用,真正的“动态”从XML的if标签里解放出来,变成了Java代码的条件判断。
3.2 动态条件的两种常用写法
第一种是上面这种“WhenPresent”链式写法,适合条件数量固定、直接从方法参数取值的场景,代码最简洁。第二种适合条件来自一个查询对象、或者条件个数动态变化的场景:先把所有条件收集到一个列表里,再循环调用and把条件追加进去。虽然代码多几行,但逻辑完全在Java层,可以调试、可以打印、可以随手stream().filter(),比在XML里维护一堆if/choose要灵活得多。
这里多说一句。有人可能觉得“这无非是把XML标签换成了Java方法”,但实际上差异很大。XML里如果漏写了where标签,或者if里判断错了,得到的是一个结构坏掉的SQL;而DSL构建器本身是强类型的,条件对象拼出来的结果一定是一个结构合法的SQL。出问题的地方只会在取值逻辑上,而取值逻辑是可以用IDE和调试器直接看的。
3.3 和XML if/where/choose做一次行数PK
同一个七条件查询,我用两种方式各写了一遍,做了一个直观对比:
| 对比项 | XML if/where 写法 | Dynamic SQL 写法 |
|---|---|---|
| 代码行数 | 35到50行 | 12到16行 |
| 条件组合的可读性 | 依赖嵌套标签,括号难配对 | 方法链顺序即SQL顺序 |
| 字段名错误暴露时机 | 运行时才报错 | 编译期直接标红 |
| 条件复用 | 复制粘贴或<sql>片段 | Java方法/函数式组合 |
| 单测可行性 | 需要容器和数据库 | 纯JUnit即可验证 |
行数减少不是重点,重点是心智负担下降。原本脑子里要装“这个if在外面还是里面”“and要不要写”“where有没有被注释掉”这些事,现在只需要按业务逻辑逐个追加条件。一行条件、一个方法、一个动作,思考和输入几乎同步,编码速度就是这样拉开的。
4. 第三个关键优势:SQL片段可组合可复用,告别复制粘贴
4.1 把公共条件提取成Java方法
XML时代想复用动态条件,基本只能靠<sql>标签,但<sql>是纯文本包含,一旦涉及表别名、多表join,片段里写死的列名很容易出问题。Dynamic SQL则可以把任意一段条件抽成一个Java方法,本质上走的是普通Java的复用机制。
举个我自己项目里的例子。报表模块有订单表、退款表、操作日志表三张表,每个查询都必须带上“创建时间在起始/结束时间之间”这个过滤条件。以前三份XML各自维护一份时间判断,客户改了一次“结束时间也要包含当天最后一秒”的需求,三处都要同步改,改漏一遍报表数据就是错的。改成Dynamic SQL之后,我把时间窗口抽成一个公共方法,三个查询各自调用,后续再改区间规则只动一处。
方法的参数也可以是活的:允许传null,null就代表不限制这一端。这样“最近7天”“本月至今”“自定义区间”这些变化的诉求,全都能由同一个方法表达。在XML里要实现这类抽象,要么写模板变量,要么把三种情况全堆进if分支,复杂度完全不在一个量级。
4.2 组合子思维:小函数拼大查询
我把这种方式理解为“组合子思维”:一个复杂查询不是一坨从上到下的模板文本,而是由若干小的、可独立测试的条件片段组合而成。比如:
notDeleted()表示只查未删除timeWindow(start, end)表示时间范围statusIn(statuses)表示状态集合
每个方法都短小、独立、可单测。然后主查询就是把这些方法按业务语义组合起来。新增一个“只看某渠道来源”的筛选,不会触碰原有条件代码,而是再加一个小条件方法。这种扩展性,是复制粘贴式XML永远给不了的。
不过得提醒一句:抽象不要做太早。同一个条件在两个查询里出现时可以提取;如果只在某一个特殊查询里用到,那就先写在该查询里凑合着。过度设计比复制粘贴更消耗效率。
5. 第四个关键优势:SQL构建纯Java化,单元测试终于好写了
5.1 原来验证SQL要启动整个应用,现在一个@Test就够
这是最打动我的一点。用Dynamic SQL以后,验证SQL生成结果不需要Spring容器,不需要连数据库,只需要一个普通的JUnit测试:
@Test void shouldRenderWhereClause() { SelectStatementProvider selectStatement = select(user.id, user.name) .from(user) .where(user.deleted, isEqualTo(false)) .and(user.name, isLike("%张%")) .build() .render(RenderingStrategy.MYBATIS3); assertThat(selectStatement.getSelectStatement()) .contains("select id, name from users") .contains("where deleted = ?") .contains("and name like ?"); }这个测试的执行时间是毫秒级。以前要验证同样一段XML逻辑,最快也得启动Spring Boot上下文、构造HTTP请求、让MyBatis真正执行一遍SQL,中间还要应付缓存不刷新、日志没打印等各种干扰。反馈链路从“分钟级”变成“毫秒级”,对于开发效率来说是质变级别的提升。
5.2 断言SQL文本与参数列表,回归测试覆盖条件组合
除了SQL文本,还可以从SelectStatementProvider的getParameters()里拿到参数绑定Map,进一步断言条件值是否正确绑定。这样整套DAO层逻辑就能像普通工具类一样测试。
我个人喜欢对where条件做组合测试:name为null时SQL里不能出现name like;startTime不为null时必然出现created_at >= ?;status为空列表时自动不生成status in ()这种非法SQL。一个十几条件的查询,写十几个参数化测试用例,基本能把主要组合覆盖住。以后谁改了这个查询,跑一遍测试就知道有没有改挂。
这里有一个测试设计上的经验:断言别写得太死,不要拿整条SQL字符串做相等比较,也不要断言参数的每个key。只要锁住结构关键点,比如“select了哪些列”“where有没有包含某个条件”“order by是什么”,就足够了。否则表字段顺序调一下、join写法微调一下,测试就崩了,反而增加维护负担。
5.3 对团队效率的真实影响:改DAO不心慌
有了单测之后,改DAO的安全感完全不同。有一次要给users表加一个“来源渠道”字段,并且要同步到好几个历史查询里。加了Column之后,IDE直接给出所有还在用旧结构构造查询的位置;补齐Column定义后,把几个条件组合测试跑一遍,半天就把整个改动做完。如果还是XML,得一个select一个select手工加<if>,加完还要逐个人肉验证,至少两天起步。这种“改起来不怕改错”的安全感,是效率提升最隐形、却也最重要的一部分。
6. 第五个关键优势:与现有MyBatis工程无缝共存,迁移阻力小
6.1 @SelectProvider + SelectStatementProvider的接入方式
很多团队不换方案,不是不想换,是怕换起来伤筋动骨。MyBatis Dynamic SQL在这方面做得很好,它和XML不是二选一的关系,而是可以并行存在。接入方式很简单,Mapper接口里加一个方法就行:
@Mapper public interface UserMapper { @SelectProvider(type = SqlProviderAdapter.class, method = "select") List<User> selectMany(SelectStatementProvider selectStatement); }MyBatis在解析这个Mapper时,会调用SqlProviderAdapter的select方法,拿到SelectStatementProvider里保存的SQL字符串和参数,再走一遍普通的MappedStatement执行流程。也就是说,Dynamic SQL生成的最终也是一条普通语句,和XML Mapper可以在同一个SqlSessionFactory里和睦相处。
6.2 和XML共存,边界怎么划
既然能共存,那迁移就不需要“一刀切”。我在项目里的划分思路是这样的:
- 单表或简单多表查询、条件组合又多又变的,用Dynamic SQL
- 复杂报表、动态列、窗口函数满天飞的,继续用XML或直接写数据库视图
- 简单按主键查询,用Mapper内置方法就够了,没必要硬上DSL
技术上没有障碍,项目管理上反而要立规矩:新查询优先尝试DSL,XML只留给报表类复杂SQL。用规范引导,而不是发起一场轰轰烈烈的全量重写。跟着业务迭代一块一块替换,风险和成本都可控。
6.3 与MyBatis Generator、Spring Boot的配合,以及和MyBatis Plus的取舍
用MyBatis Generator可以自动生成Table类、基础Mapper方法和Provider,手写量会进一步减少。我现在的习惯是让Generator生成基础骨架,然后手写业务查询方法,两边互补。Spring Boot里也不需要特殊配置,@MapperScan正常扫描就能用。
至于和MyBatis Plus的取舍,我的看法是:如果你团队已经重度使用Plus,并且主要做单表CRUD,那没必要为了DSL再引一个库,Plus的LambdaQueryWrapper也能做类型安全的动态条件。但如果你需要更接近“SQL构建器”的定位,希望完全掌控SQL形状,并且想要官方生态同步更新,那Dynamic SQL更合适。它和MyBatis的映射机制整合最顺滑,学习成本也比独立框架低。
顺带提一句jOOQ。jOOQ同样提供类型安全DSL和代码生成,但它是一个相对独立的SQL构建框架,要跟MyBatis整合需要额外适配。除非你有全面替换整个数据访问层的计划,否则在MyBatis项目里,官方这个库是性价比更高的选择。
7. 落地一年的经验:从试点到全量,以及踩过的坑
7.1 推荐的三步迁移路径
如果你的团队也想引入,我建议按这个节奏来,而不是第一天就梭哈:
- 先在一个新模块或改动最频繁的查询上试点,熟悉DSL的API风格和渲染策略。
- 把Table类、基础Mapper用MyBatis Generator生成,保证列定义和实体映射一致性。
- 逐步评估存量XML,优先迁移那些条件组合复杂、复用度高的查询;大报表先放着,按模块迭代替换。
一个核心原则:不要为了统一风格而大规模重写。哪块经常被业务牵着改,就先动哪块,让收益跟着业务迭代走,否则很容易变成一次看不到收益的重构运动。
7.2 性能补充说明:预编译与执行计划缓存
不少人第一次听到“用Java构建SQL”,会担心多一层构建影响性能。实际测下来完全不需要担心。Dynamic SQL生成的都是参数化SQL,值通过绑定变量传入,数据库可以对相同形状的SQL复用执行计划,这反而比拼接字符串更友好。SQL构建本身发生在Java对象层,没有反射,没有解析XML的开销,和数据库执行时间相比基本可以忽略。
唯一值得留意的是执行计划缓存的数量。因为不同条件组合会生成不同SQL文本,条件组合特别多、请求量又大的查询,理论上可能让数据库端的计划缓存条目增多。从我的项目实践看,这个影响在绝大多数业务系统里都可以忽略。真遇到极端情况,还是得回到查询设计本身去优化,比如减少组合维度、走必要的物化手段。
7.3 使用中容易踩的三个坑
第一个坑是渲染策略选错。RenderingStrategy.MYBATIS3生成的是MyBatis可识别的#{}占位符形式,如果你的项目其他场景用Spring的JdbcTemplate或NamedParameterJdbcTemplate,得选SPRING_NAMED_PARAMETER策略,两者的占位符语法完全不同。混着用,配半天参数都配不对。
第二个坑是LIKE通配符处理。isLike不会自动在两侧加百分号,留给你自己拼。同时你还要做转义,否则用户输入%或_会变成通配符,影响查询准确率,也埋下语义问题。建议写一个统一的likePattern工具方法,在拼通配符的同时完成转义,所有查询都用它。
第三个坑是同一张表join两次时要小心表别名。DSL里可以通过SqlTable的别名机制给同一张表取不同的别名,Column引用也必须跟着对应用别名版本的Column,否则生成的SQL里列名和表名对不上。这类问题在XML里同样会有,但XML里别名是自己手写的,看起来直观;DSL里别名由库管理,刚上手时容易忘记设置。
7.4 哪些场景别硬用Dynamic SQL
不是什么场景都适合换成DSL。大型报表SQL里大量窗口函数、case when、透视和临时表,用XML或者直接建数据库视图会更直观;DSL写这种SQL不是不行,而是维护成本反而更高。极度动态的列,列的列表由外部配置决定,这种场景往往是设计问题,不该靠SQL构建方案硬扛。复杂更新任务如果本质上是存储过程逻辑,优先考虑数据库事务和存储过程,不要在DSL层做过度抽象。
团队协作维度也要考虑。如果团队里Java基础本身比较薄弱,还没养成函数式思维,先培训和试点,别急着铺开。工具是给人用的,效率提升最终还是看人上手后能省多少时间,而不是看用了多时髦的框架。
回头来看,Dynamic SQL给我的帮助不是把SQL写得多花哨,而是把“写SQL”这件事实实在在地重新变成了“写代码”。类型安全、可组合、可测试,这些都是Java语言本来就该带来的能力,只是MyBatis的XML模式把SQL隔离在了另一个文本世界里。我的建议是,别急着全量重写,先找一个条件最多、改动最频繁的查询模块试点,用三四个迭代攒够手感,你多半也会认同“3倍”这个数字。