☰
MyBatis mapper.xml特殊字符转义与CDATA用法详解
2026/9/30 6:32:59 网站建设 项目流程

在 MyBatis 的 mapper.xml 里写 SQL 条件,凡是遇到><>=<=或者!=,新手基本都栽过一次跟头。明明 SQL 在数据库客户端里跑得好好的,一放进 mapper.xml 就飘红报错,或者启动时直接抛出The content of elements must consist of well-formed character data or markup,又或者org.apache.ibatis.builder.BuilderException。这篇文章我就把这几种写法彻底讲透,包括为什么 XML 里不能直接用小于号、什么时候可以不用转义、CDATA 和转义字符怎么选,以及我在实际项目里踩过的坑。

文章的适用对象很明确:正在写 MyBatis 的 Java 后端开发,尤其是刚接触 mapper.xml 不久、被各种符号报错折磨过的同学;有了一定经验但想系统梳理“test属性里要不要转义”“CDATA 里能不能写标签”这些边界问题的开发者,也能从这里找到答案。

1. 问题本质:为什么 XML 不允许裸写小于号

1.1 XML 解析规则决定了<是禁区

很多人把这个问题当成 MyBatis 的语法,其实根源在 XML 本身。mapper.xml 本质是一个 XML 文件,任何 XML 解析器在读取它时,都要遵循 XML 规范。XML 规范里定义了五个预定义实体,其中&和<是最严格的两个:

  • &是实体引用的起始符,裸写会导致解析器去解析一个不存在的实体。
  • <是标签的起始符,解析器遇到<后会认为后面跟着的是一个标签名,如果<后面跟的是空格、数字或者 SQL 关键字,立刻报格式错误。

>的情况稍微特殊一点。在 XML 规范中,>只有出现在字符串]]>里才必须转义,普通位置的>其实可以裸写。所以a > 10这段 SQL 放在 XML 里通常不会报错。但行业惯例是统一转义,因为你不确定这段 SQL 会不会出现在其他更严格的 XML 解析环境下,也为了代码审查时一眼能看出“这里是一个比较操作符”。

!=就更宽松了,因为它里面既没有<也没有&,XML 解析器完全不会拦它。但这里有个隐藏的问题:MyBatis 的if标签test属性里写!=和 SQL 语句里写!=,语义并不完全一样,文章后面我会专门讲这个坑。

1.2 MyBatis 的容错机制与 XML 解析的边界

MyBatis 解析 mapper.xml 时,底层用的是 XMLParser,它会先对整个文件做一次 XML 解析,然后再把解析结果交给 SQL 语句构建器。这就意味着:只要文件里有任何一个不符合 XML 规范的地方,整个 mapper 文件都会加载失败,连带着这个 mapper 里的所有 SQL 都不可用。

我见过一个真实案例:某个项目里一个 mapper.xml 大概有两百多行,某次有人在新增 SQL 里写了一个条件WHERE count < 10,启动时整个应用直接抛BuilderException,而且报错信息指的位置是文件开头那几行。排查了很久才发现是中间有一个<导致 XML 解析中断,解析器报错的位置和真正出错的位置差了很远。这一点大家一定记住:XML 解析报错的行号通常不可信,真正的凶手可能在整个文件任意一处。

2. 四种常见写法与选型指南

2.1 方法一:XML 转义字符

这是最朴素也最兼容的写法,在 SQL 里直接用实体引用替代特殊字符:

原符号转义写法
<&lt;
>&gt;
<=&lt;=
>=&gt;=
&&amp;
单引号&apos;
双引号&quot;

举个例子,查询年龄在某个区间内的用户:

<select id="selectByAgeRange" resultType="User"> SELECT * FROM user WHERE age &gt;= #{minAge} AND age &lt;= #{maxAge} </select>

这种写法最稳妥,XML 解析器一眼就能看明白,而且和 CDATA 相比它不会影响 MyBatis 动态标签的解析。但因为&lt;和&gt;可读性确实比较差,SQL 稍微一长就眼花。我的经验是:单个条件、SQL 片段较短时用转义,条件多、SQL 复杂时优先考虑 CDATA。

2.2 方法二:CDATA 区

CDATA 是 XML 里用来包裹“不需要解析的文本”的标记,写法是<![CDATA[ 你的内容 ]]>。放在 CDATA 区里的内容,XML 解析器会原封不动地当成纯文本,不解析任何标签和实体。所以里面可以放心裸写<、>、<=、>=、!=:

<select id="selectByAgeRange" resultType="User"> SELECT * FROM user WHERE age <![CDATA[ >= ]]> #{minAge} AND age <![CDATA[ <= ]]> #{maxAge} </select>

这是我最推荐的写法。但注意一个细节:我在例子里只把操作符包进了 CDATA,而不是把整条 SQL 包进去。为什么不把整条 SQL 都包起来?因为一旦把整条 SQL 放进 CDATA,MyBatis 的动态标签(<if>、<where>、<foreach>等)就会全部失效。CDATA 的内容是“原样文本”,MyBatis 的 SQL 节点构建器不会去解析 CDATA 内部的 MyBatis 标签,如果标签写在 CDATA 里,它们会被当作普通字符串拼进 SQL,最终执行时报数据库语法错误。

有人会问:那我只用 CDATA 包住特殊符号前后的部分,会不会把 SQL 截断?不会。CDATA 只是标记这一段文本不参与 XML 解析,拼接后的最终 SQL 是完整连续的。比如age <![CDATA[ >= ]]> #{minAge}解析后的 SQL 就是age >= #{minAge}。

2.3 方法三:借助<![CDATA[ ]]>包裹整个比较条件

如果你觉得上面的写法把 CDATA 拆得太碎,可以把整个条件表达式包进去:

<select id="selectByAgeRange" resultType="User"> SELECT * FROM user <![CDATA[ WHERE age >= #{minAge} AND age <= #{maxAge} ]]> </select>

注意这里没写<where>标签,因为整个条件都在 CDATA 内部,<where>标签如果放在 CDATA 外部,它无法识别 CDATA 内部的条件要不要加WHERE关键字。这种写法的最大问题是:一旦条件变成动态的,比如某些情况下 minAge 不传,这个方案就废了。所以它只适合条件完全固定的场景。

2.4 方法四:test属性里的特殊符号

这里必须区分两个完全不同的场景:一个是 SQL 语句里的比较符,一个是 MyBatis 动态 SQL 标签中test属性的比较表达式。

<select id="selectUsers" resultType="User"> SELECT * FROM user <where> <if test="minAge != null and minAge >= 0"> AND age &gt;= #{minAge} </if> </where> </select>

重点来了:test属性里写>=、<=、!=都是合法的,不需要转义。原因在于test属性值本身处于引号包裹的属性取值阶段,XML 解析器读取到引号内的内容后,不会继续把它当作 XML 标记来解析。MyBatis 拿到test表达式的字符串后,会用 OGNL 表达式引擎解析,而 OGNL 是支持>=、!=这些标准运算符的。

但是有个特例:test属性里的<仍然建议转义,因为在属性值里出现<,部分 XML 解析器在属性值规范化时也会出问题。稳妥起见,test里我统一写成&lt;或者直接用>=的等价形式,不赌解析器的容错度。

3. 实操案例:一个完整的分页范围查询

下面给出一个实际可用的 mapper.xml 片段,覆盖常见的“时间范围 + 状态过滤 + 金额大于/小于”组合查询。假设有一张订单表orders,字段包括order_id、user_id、amount、pay_time、status。

<select id="selectOrdersByCondition" resultType="Order"> SELECT order_id, user_id, amount, pay_time, status FROM orders <where> <if test="userId != null and userId > 0"> AND user_id = #{userId} </if> <if test="minAmount != null and minAmount >= 0"> AND amount <![CDATA[ > ]]> #{minAmount} </if> <if test="maxAmount != null and maxAmount >= 0"> AND amount <![CDATA[ <= ]]> #{maxAmount} </if> <if test="startTime != null"> AND pay_time <![CDATA[ >= ]]> #{startTime} </if> <if test="endTime != null"> AND pay_time <![CDATA[ <= ]]> #{endTime} </if> <if test="status != null and status != ''"> AND status = #{status} </if> </where> ORDER BY pay_time DESC </select>

这段 XML 里的几个典型写法,我在实际项目中这样用的理由如下:

  • test属性里直接用>和>=,因为它们在属性值里完全安全,不需要转义,写起来也清爽。
  • SQL 语句里的比较操作符全用 CDATA 包裹,保证 XML 解析和 MyBatis 动态标签互不干扰。
  • status != null and status != ''这个判断专门处理字符串,既过滤掉 null,又过滤掉空字符串。
  • <where>标签会自动处理第一个条件前面的AND,所以 MySQL 里不会出现WHERE AND amount > ...这种语法错误。

3.1 不同写法的混用原则

在实际项目中,一个 mapper.xml 里往往同时存在多种写法,这很正常。我的建议是定一个团队内统一的原则,避免每个人按自己习惯乱写。

我个人用的原则是:

  • test属性里的比较表达式:>、>=、<=、!=直接写,不转义;<写为&lt;。
  • SQL 片段里的比较操作符:用 CDATA 包住单个操作符,如<![CDATA[ > ]]>、<![CDATA[ <= ]]>。
  • 固定条件的 SQL 片段:允许用转义字符,但保持统一风格。
  • CDATA 内部绝对不写 MyBatis 动态标签,避免标签失效。

这个原则不一定适合所有团队,但一定要有。因为代码的可维护性有时候不在于唯一的正确方案,而在于“大家写的都一样,看到哪个文件都不陌生”。

3.2 小技巧:用注解或测试用例验证 SQL

很多人写完 mapper.xml 靠启动时不报错来判断 SQL 写得对不对,这个标准太低了。XML 合法只能说明文件结构没毛病,SQL 语法对不对只有真正执行了才知道。我的习惯是给这类复杂查询写一个单元测试,用 H2 内存数据库配合 MyBatis 直接跑 SQL,把 mapper 的输出 SQL 打到日志里人工确认一遍。

@SpringBootTest public class OrderMapperTest { @Autowired private OrderMapper orderMapper; @Test public void testSelectOrdersByCondition() { OrderQuery query = new OrderQuery(); query.setMinAmount(100.0); query.setMaxAmount(500.0); query.setStartTime(LocalDateTime.of(2024, 1, 1, 0, 0)); List<Order> orders = orderMapper.selectOrdersByCondition(query); System.out.println(orders.size()); } }

启动测试时把 MyBatis 的 SQL 日志级别调成 DEBUG,控制台会打印出完整的 SQL 和参数。对着日志看一秒钟 SQL,比盲猜半天强得多。

4. 常见报错与排查技巧实录

4.1 报错一:The content of elements must consist of well-formed character data or markup

这是最常见的 XML 格式错误,触发原因就是 SQL 里裸写了小于号。比如:

<select id="selectByAge" resultType="User"> SELECT * FROM user WHERE age < 18 </select>

解析器看到< 18的时候,会把<当标签起始符,后面的空格直接导致解析失败。处理方式就是转义&lt;或者用 CDATA 包住<。

4.2 报错二:CDATA 里写了<if>导致标签不生效

有人图省事,把整段 SQL 连同<if>标签一起写进 CDATA,结果发现条件过滤完全失效。原因我在前面说过:CDATA 里的内容被当成纯文本,MyBatis 不会解析其中的动态标签。

判断方法很简单:看日志里输出的 SQL 是否原样包含了<if>标签字符串。如果含了,说明标签写进了 CDATA。

4.3 报错三:test属性里用>匹配数值时结果诡异

比如:

<if test="minAge > 5"> AND age &gt;= #{minAge} </if>

这个写法本身合法,OGNL 会把minAge > 5解析成数值比较。但如果minAge是从前端传上来的字符串类型,这里比较的其实是字符串的大小,结果可能和你预期的完全不一样。所以test属性里的数值比较,建议先做类型转换和 null 判断:

<if test="minAge != null and minAge.toString().length() > 0"> AND age &gt;= #{minAge} </if>

或者更稳妥一点,在 DTO 层就用 Integer/Long 接收,避免 OGNL 处理字符串和数值混合比较的边界问题。

4.4 报错四:启动时这个 mapper 正常,跑批时 SQL 才报错

这种情况多见于${}拼接的场景。如果动态 SQL 用了${}而不是#{},字符串替换发生在 SQL 编译之前,比如:

<select id="selectByTable" resultType="map"> SELECT * FROM ${tableName} WHERE amount <![CDATA[ > ]]> #{minAmount} </select>

如果tableName被替换为orders WHERE status='ACTIVE'之类的字符串,最终 SQL 就变成SELECT * FROM orders WHERE status='ACTIVE' WHERE amount > ?,直接数据库语法错误。这种问题 XML 层是发现不了的,排查时优先打印 MyBatis 执行前的完整 SQL 日志,一眼就能看出拼接问题。

4.5 实战心得:统一处理 Symbol 的编码

我后来在团队里定了一个规矩:所有 mapper.xml 文件头加一行注释,明确写清楚“小于号统一用 CDATA,小于号只出现在 test 属性时才允许转义写法”。这样新来的同事看代码时,至少有个明确指引,不至于每个文件一个风格。

另外一个私藏技巧:用 IDE 的实时 XML 校验。IntelliJ IDEA 默认会对 XML 文件做格式校验,只要在 XML 里裸写了<,编辑器会立刻标红。建议所有写 mapper.xml 的同事都开着这个校验,不要关闭。它能帮你把问题拦截在编码阶段。

5. 写在最后的一点经验

如果你还在纠结到底哪种写法最标准,我的建议是:根据场景选,而不是根据“最标准”选。判断标准很简单——这段 SQL 是不是属于动态 SQL 的一部分。如果是,用 CDATA 包操作符;如果不是,用转义字符就够了。核心思路是避免 XML 解析器和 MyBatis 动态标签解析两层机制互相踩脚。

记住一个最关键的认知:mapper.xml 是 XML 文件,不是纯 SQL 文本文件。你写的每一段 SQL,在真正到达数据库之前,都要先过 XML 解析这一关。把“这个符号在 XML 里怎么表示”当成第一反应,而不是“为什么 SQL 客户端能跑这里却报错”,后面所有坑都能绕开。

这篇文章的内容都是我实际项目中踩过来、填平之后的记录,照着上面的方案写,不敢说一定最优,但至少能让你少折腾半天。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询