1. 现实问题:SQL 重复和字符串拼接会一起失控
文章列表要支持状态、作者、关键词和分页,直接写四个近似 SQL 会重复,直接字符串拼接又会引入注入和空格错误。MyBatis 的 Mapper 接口让 Java 侧调用像普通方法,XML 或注解保存 SQL;动态标签负责可选条件,但最终仍然要符合数据库和参数安全规则。
理解 Mapper 的关键不是背 <where> 写法,而是能从方法调用追到 SQL 模板、参数对象、PreparedStatement 和结果映射。
2. 最小可运行示例:接口、XML 与参数绑定
import java.util.List;
public interface ArticleMapper {
List<ArticleRow> search(ArticleQuery query);
record ArticleQuery(String status, String keyword, int limit, int offset) {}
record ArticleRow(long id, String title) {}
}
<select id="search" resultType="com.example.ArticleMapper$ArticleRow">
SELECT id, title FROM article
<where>
<if test="status != null and status != ''">
status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND title LIKE CONCAT('%', #{keyword}, '%')
</if>
</where>
ORDER BY id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
为了让示例能保存为一个 ArticleMapper.java 直接通过 JDK 21 编译,两个记录暂时嵌套在 Mapper 中;真实项目可以把查询对象和结果行拆到各自文件,并同步修改 XML 的全限定类名。
#{status} 会变成参数占位符并通过 PreparedStatement 绑定;${column} 是文本替换,不能直接接受用户输入,动态列名应由固定白名单映射后再使用。示例中的 limit 和 offset 也应在 Java 参数对象中限制范围。
3. 调用链与对象变化
Service 调用 Mapper 接口,MyBatis 生成的代理根据方法签名找到 statement id,读取 XML 配置并用 OGNL 判断 if 条件。查询参数记录的字段被绑定到 SQL 参数,SqlSession 获取连接并执行 PreparedStatement。ResultSet 每行按 resultType 或 resultMap 创建 ArticleRow,最终返回 List。
动态 SQL 的 <where> 会处理前后多余 AND,但它不会验证 status 的业务值,也不会限制 keyword 长度。Mapper 返回的行对象是查询快照,修改它不会自动回写数据库,除非再次调用 update 方法。
4. 为什么这样设计
Mapper 把 SQL 和 Java 方法连接起来,适合查询复杂、需要精确控制列与关联的项目;XML 把大段 SQL 从 Java 字符串中解放出来,便于审查和数据库客户端复现。动态 SQL 让可选条件集中,但条件太多时会变成不可测试的“SQL 程序”,应拆成明确查询或查询对象。
#{} 是默认选择,因为值和结构分离;${} 只有在受控标识符、表名或排序方向经过白名单映射时才可用。参数校验和 SQL 安全不是 Mapper 独有职责,Controller、Service 和数据库约束要共同收口。
5. 项目落点:Mapper、Service 和事务的接缝
Mapper 只描述数据访问,Service 组合多个 Mapper 并划定事务,Controller 负责 DTO 校验。分页查询要与 count 查询保持同一过滤条件,排序必须固定并稳定。日志记录 statement id 和耗时,慢 SQL 能复制到客户端用同样参数解释。
练习:为查询增加 authorId、publishedAfter 和 sort 字段,sort 只允许 newest、oldest 两个 key,并在 Java 中映射为固定 SQL 片段。测试每种条件组合的 SQL 结果,确认空条件不会产生多余 WHERE 或全表扫描。
6. 易错排查
BindingException:方法名、namespace、参数名或扫描路径不一致;先打印 statement id。#{}与${}混用:把所有用户文本先改为#{},动态结构走白名单。- XML
<if>条件判断错:记录参数对象实际字段名和 null/空字符串语义。 - Mapper 查询很快但接口很慢:看映射、序列化、N+1 关联和分页返回量,不要只看 SQL。
7. 一页复习
Mapper 代理接住 Java 调用,XML 动态生成 SQL,#{} 绑定值,PreparedStatement 执行,ResultSet 映射行对象。动态 SQL 只解决条件组合,不解决输入可信度和事务边界。把 SQL 模板、参数范围、排序白名单和执行计划都作为可审计证据。
动态查询的状态可以按参数组合追踪:只有 status 时生成一个 WHERE;status 加 keyword 时多一个带 #{keyword} 的条件;没有条件时由 <where> 删除多余前缀,但这可能变成全表扫描,因此 Service 要决定是否允许空筛选。Java Query 对象的空值、空字符串、limit 和 offset 先校验,XML 只负责组合合法条件。
Mapper 代理的输入是方法名、参数对象和 statement namespace,输出是 SQL 执行结果和映射 List。不要把 XML 当黑盒:日志中记录 statement id、绑定参数类型、数据库耗时和返回行数;慢 SQL 复制到客户端用同一条件执行。#{} 对应值绑定,${} 改变 SQL 文本,后者只能接收经过白名单映射的排序列或受控标识符。
项目中查询 DTO 放 application,Mapper XML 放 persistence/resources/mapper,结果 Row 放 persistence,API View 放 web。复杂搜索可以拆成基础条件、排序和分页三个方法,避免一个 XML 充满相互依赖的 if。事务由 Service 或统一代理开启,Mapper 不管理连接的提交和回滚。
排错时 BindingException 查 namespace/方法名/扫描,SQL 语法错误查动态空格和条件组合,参数注入查是否出现 ${},结果少字段查 resultType/别名和 null 映射;接口慢而 SQL 快还要查 N+1、序列化和网络。练习是给四种条件组合写预期 SQL 和结果,并用日志或测试确认没有意外全表查询。
Mapper 调用的输入不是一条 SQL 字符串,而是 namespace、method、参数对象和当前 SqlSession。代理先定位 statement,动态节点根据参数生成 SQL,#{} 变成参数槽,驱动绑定后才进入数据库;返回阶段再由 resultType/resultMap 把行变成对象。把 XML 直接复制成一条“看起来能执行”的 SQL,可能漏掉动态条件和真实参数。
动态 SQL 的安全边界可以用三个例子说明:status 和 keyword 是值,使用 #{};排序方向是结构,只允许由固定 enum 映射成 ASC/DESC;表名只能来自受控配置,不能接受请求原文。空条件是否允许查询全部文章要由 Service 决定,XML 的 <where> 只负责语法,不负责权限、租户和结果规模。
项目中可以让 ArticleSearch 成为 application 查询对象,Mapper XML 只承担 SQL,ArticleRow 只承担持久化字段,Assembler 转成 API View。事务和分页一起由 Service 组织,count 与 page 使用同一过滤规则。statement id、参数摘要、耗时和返回行数是最有用的日志;原始正文和 token 不应进入 MyBatis 日志。
排错时先把 Query 对象打印成字段摘要,再查看最终 SQL 和绑定参数;XML 标签空格错误会造成语法错,命名空间错误会造成 BindingException,${} 会造成结构注入,错误 resultMap 会造成字段为空。接口慢而数据库快,还要检查嵌套查询、对象组装和 JSON 序列化,不能把所有时间都归给 MyBatis。
练习是实现四种筛选组合并写预期 SQL:仅状态、状态+关键词、作者+时间范围、空条件拒绝;再为排序 key 建白名单和单测。用真实数据库执行 EXPLAIN,记录动态条件改变后计划和扫描行数,说明为什么有时要拆 Mapper 而不是继续增加 if。
验证清单:给 ArticleSearch(status=null, keyword="", authorId=null, page=1, size=20),确认 Service 在进入 Mapper 前拒绝空条件或按产品规则选择安全的默认范围;给合法 status 和 keyword,记录 Query 对象、statement id、绑定参数、最终 SQL、返回行数。给排序 key createdAt 和恶意文本,前者只能映射到白名单列,后者必须在排序映射处失败,不能进入 ${}。再检查分页 count 和 page 的过滤条件是否完全一致,避免总数与列表来自两套 SQL 语义。
调用链复盘要写对象变化:HTTP 查询字符串先成为 Request DTO,Service 校验后得到不可变 ArticleSearch,Mapper 代理把它交给动态节点,节点生成带参数槽的 SQL,驱动绑定值并返回 ResultSet,Row 再组装成 View。若日志只打印一条拼好的 SQL,就无法区分空字符串被忽略、参数未绑定和结果映射失败;应保留安全的字段摘要、耗时和扫描证据。
验证清单补充:将 status、keyword、authorId、时间范围、排序 key 和分页参数组合成几组固定 fixture,逐组对照 Query 对象、statement id、绑定参数、生成 SQL、EXPLAIN 扫描行数和结果数量。空条件必须由 Service 明确拒绝或限制范围,排序文本只能经过白名单映射;count 与 page 的过滤条件不同就是契约错误。若最终 SQL 看似正确但接口仍慢,继续拆分数据库耗时、对象组装和 JSON 序列化,不能把所有时间归给 Mapper。
进阶附录:Provider、插件与批量执行
复杂动态 SQL 可以使用 Provider 或自定义插件,但扩展点越底层越需要集成测试和版本边界。批量 Executor 要确认一级缓存、返回行数和事务回滚行为;不要为了减少 XML 直接把一大段 SQL 拼到 Java 字符串里。
本课按「MyBatis 3 Mapper、XML 动态 SQL 与参数映射」的学习范围组织,正文与示例均为本站原创整理。