ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis mapper.xml比较运算符转义与CDATA写法详解

MyBatis mapper.xml比较运算符转义与CDATA写法详解 写MyBatis的mapper.xml文件几乎每个Java后端开发都会被大于号、小于号、不等于号卡过那么一两次。我记得第一次在XML里写WHERE age 18启动项目后接口直接报错控制台提示XML解析失败当时还以为是SQL写错了查了半天才发现是尖括号惹的祸。后来在这个问题上踩的坑多了慢慢摸清了背后的机制也整理出了一套自己的写法习惯。这篇文章就从根上讲清楚mapper.xml里比较运算符到底该怎么写转义、CDATA、动态SQL三种场景怎么选以及我这些年遇到过的几个古怪问题。这个问题虽然看起来简单但它同时牵扯到XML语法、SQL语法和MyBatis的动态SQL机制三件事很多人只记住了要转义这个结论却不知道为什么换一个场景就不会了。下面我会把原理、写法、排错一条龙讲透新人和写过几年的老手应该都能从里面找到点有用的东西。1. 为什么mapper.xml里不能直接写大于小于号1.1 XML语法和SQL语法在这个地方撞车了先说结论不是MyBatis限制了你而是XML解析器限制了你。XML文件本质上是一个有严格语法的文本文件它自己定义了一套规则。在这套规则里尖括号和并不是普通的数学符号表示一个标签从这里开始表示一个实体引用从这里开始。XML解析器读到时会立刻进入标签解析模式如果后面跟的是字母比如if、where它会认为这是一个标签如果后面跟的是空格、数字、等号这类内容它不知道该怎么处理直接抛异常。SQL语句里的age 18其中的在XML规范里其实并不强制转义真正致命的是。比如你要写age 18XML解析器读到后后面跟着空格和18它不认为这是一个合法的标签结构所以报错。而!这个写法里!和都不是XML特殊字符从解析层面来说可以直接写。但是SQL标准里不等于的标准写法是如果按照标准写那个就又踩中了XML的雷区。这就好像一个人同时说两种语言在数据库客户端里写SQLSQL的语法规则说了算把同样的SQL放到XML文件里XML的语法规则也要插一脚。两个规则在这个地方互相冲突必须用某种方式告诉XML解析器这段内容别按XML规则解析给我原样放过去。1.2 直接写符号常见的两个典型报错我见过不少新手包括当年的自己把SQL从Navicat里粘到mapper.xml然后启动项目报错第一反应是我的SQL在数据库里明明能跑啊。确实能跑因为数据库客户端不解析XML你的SQL没有问题问题出在你把SQL放进了XML这个容器里。最常见的报错之一是这样的The content of elements must consist of well-formed character data or markup.翻译过来就是元素内容必须是格式良好的字符数据或标记。这句话基本可以翻译成XML解析器在某个地方读到了一个它不认识的符号。只要看到这个错误优先排查SQL里有没有没转义的比较符号。还有另一种报错看起来更迷惑元素类型 if 必须由匹配的结束标记 /if 终止。这种情况往往是你并没有写if标签但XML解析器把某个后面跟着的字母组合当成了标签名于是它认为你开了一个标签却没闭合整个文档结构全部乱套。比如你写status 0解析器读到后后面的内容被当作标签处理导致文档结构被破坏。这种报错和第一种报错本质上是同一个原因只是表现形式不同。明白了这一层后面所有写法就都好理解了不管用转义还是CDATA目的都是让XML解析器不要误解你的比较符号。2. 三种主流写法转义字符、CDATA、函数变通2.1 最基础的转义字符方案转义字符是解决问题最直接的方式。XML规范里预定义了几个实体引用把它们写到XML文本里解析器会用对应的字符替换回去。和SQL比较符号相关的对照表大概是这样的你想要的符号XML转义写法说明lt;less than必须转义gt;greater than推荐转义保持风格统一lt;gt;SQL标准不等于写法!!可以直接写没有XML语法冲突组合lt;小于等于小于号必须转义组合gt;大于等于推荐转义amp;如果你在SQL里写逻辑与amp;amp;OGNL表达式里的逻辑与举个例子查询年龄大于18且状态不等于0的用户SELECT * FROM user WHERE age gt; 18 AND status lt;gt; 0这段XML解析后MyBatis拿到的SQL是SELECT * FROM user WHERE age 18 AND status 0这里要注意#{}参数占位符放在gt;后面完全没问题因为MyBatis是在XML解析完成之后再去处理#{}的两者不在同一个阶段互不干扰。转义方案的最大缺点是可读性差。SQL一长满屏都是gt;和lt;眼睛看过去第一反应是乱码排查问题的时候非常痛苦。所以我的习惯是比较符号只有一两个的时候才用转义一旦条件多起来果断换CDATA。2.2 CDATA包裹方案SQL保持原样CDATA的全称是 Character Data翻译过来是字符数据。在XML里![CDATA[ ... ]]是一个特殊的标记区域在这个区域内部所有字符都会被视为纯文本XML解析器不会去解析、、这些特殊字符。这是XML规范专门为需要放一段任意文本这种需求设计的机制。用法很简单![CDATA[ SELECT * FROM user WHERE age 18 AND status 0 ]]CDATA区域里的SQL和数据库客户端里写的一模一样不需要任何转义读起来非常直观。一个区域里放多少内容都行整个SQL包进去也没问题只要注意区域里不能出现字符串]]因为解析器看到]]会认为CDATA区域结束了这个限制在实际开发中基本不会遇到。我个人的建议是一段SQL中如果大于、小于、不等于这类符号超过两个优先考虑CDATA。比如查询最近30天内有登录记录、分数大于60、状态不等于关闭的用户这种多条件SQL用CDATA包住一眼就能看清业务逻辑。2.3 两种方式的边界与选型转义和CDATA并不是二选一的关系它们各有适用场景。我在实际开发中总结了一套选型标准可以参考一下比较维度转义字符CDATA可读性符号多时很差保持SQL原样可读性好适用范围单个符号、短SQL长SQL、多个比较符号与动态SQL标签配合可以随意组合CDATA内部不能放动态标签出错概率容易漏写、写错嵌套错误后不好排查这里有一个非常关键的坑必须单独拿出来说CDATA区域内部不能再嵌套MyBatis动态SQL标签比如if、where、foreach。原因很简单CDATA的语义是这里面的内容全是文本一旦MyBatis的XML解析阶段遇到CDATA起始标记整段内容都会原样提取出来动态标签根本不会被识别成标签而是被当作普通字符串拼进SQL里。我当年写过这样的代码![CDATA[ if testminAge ! null AND age #{minAge} /if ]]运行后不报错但SQL执行结果完全不对日志里能看到SQL语句里明晃晃地出现了if test...字符串。这个问题排查了很久才发现就是因为对CDATA的特性理解不够。正确的做法是动态标签放在CDATA外面CDATA只包裹比较符号那一小段。比如if testminAge ! null AND age ![CDATA[ ]] #{minAge} /if这样MyBatis能正常识别if标签同时CDATA区域里的符号又不会被XML解析器干扰。后面的实操部分我会展示更完整的写法。2.4 函数变通换个思路绕开符号有时候也可以从SQL函数层面绕开比较符号虽然这不是主流的解法但某些情况下挺好用。比如要判断一个日期是否大于当前时间除了写create_time NOW()也可以写成DATE_ADD(NOW(), INTERVAL -1 DAY) create_time把符号方向换一下用小于号避开大于号。再比如要查询年龄小于某个值的用户可以用BETWEEN 0 AND #{maxAge}替代age #{maxAge}。这种变通方式的优点是能绕开XML语法冲突缺点是可读性会变差而且不是所有场景都能用函数替代。我的看法是没必要为了不用转义符号强行改SQL逻辑比较符号该写还是写转义或者CDATA才是正规解法。3. 实操在一个真实mapper.xml里写各种比较条件3.1 mapper.xml基础结构回顾先从一个标准的mapper骨架开始后面所有示例都基于这个结构。?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper resultMap idUserResultMap typecom.example.demo.entity.User id propertyid columnid/ result propertyname columnname/ result propertyage columnage/ result propertystatus columnstatus/ result propertycreateTime columncreate_time/ /resultMap /mappernamespace对应Mapper接口的全限定名resultMap把数据库列名和Java实体字段映射起来这是最常用的基础配置。如果实体字段名和列名一致也可以用resultTypecom.example.demo.entity.User简化不需要resultMap。3.2 大于、小于、等于、不等于的完整示例以一个用户表为例字段包括 id、name、age、status、create_time。先看几个最基础的查询写法。查询年龄大于18岁的用户select idselectByAgeGreaterThan resultMapUserResultMap SELECT id, name, age, status, create_time FROM user WHERE age gt; 18 /select也可以写成CDATA的形式select idselectByAgeGreaterThan resultMapUserResultMap ![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age 18 ]] /select这里有一个容易忽略的小知识点如果CDATA把整条SQL包裹起来SQL语句末尾的分号;可写可不写但不要写在CDATA外面否则某些数据库驱动可能会把分号当成SQL的一部分传过去导致执行异常。我一般习惯不在mapper里写分号和MyBatis原生风格保持一致。查询年龄在18到30之间同时状态不等于0的用户select idselectByCondition resultMapUserResultMap ![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age 18 AND age 30 AND status 0 ]] /select这个SQL里既有又有还有如果用转义写法那行SQL眼睛都要看花所以这里CDATA是最合适的。查询创建时间晚于某个日期的用户select idselectByCreateTime resultMapUserResultMap ![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE create_time 2024-01-01 00:00:00 ]] /select关于日期字符串要特别注意和数据库字段格式保持一致。MySQL里如果字段是datetime类型字符串比较时MySQL会做隐式转换但格式不一致很容易出问题比如2024-1-1和2024-01-01虽然表示同一个时间转换时可能产生意外结果。更稳妥的做法是用STR_TO_DATE函数明确指定格式或者让Java层把参数格式化成标准格式再传进来。3.3 动态SQL里怎么处理比较条件实际项目里很少会把条件写死通常都是前端传入查询参数后端根据参数动态拼SQL。这是MyBatis动态SQL的主场但也是比较符号最容易翻车的地方。先看完整的动态SQL示例select idselectUsersByCondition resultMapUserResultMap SELECT id, name, age, status, create_time FROM user where if testminAge ! null AND age ![CDATA[ ]] #{minAge} /if if testmaxAge ! null AND age ![CDATA[ ]] #{maxAge} /if if teststatus ! null AND status lt;gt; #{status} /if if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if /where ORDER BY create_time DESC /select这里有三个关键点值得展开。第一where标签会自动处理掉第一个条件前面多余的AND。比如只传入了maxAge拼出来的SQL是WHERE age #{maxAge}AND会被自动去掉。如果没有where而直接写WHERE加if当所有条件都为null时SQL会变成WHERE后面什么都没有直接语法错误。第二if标签的test属性里写的是OGNL表达式走的是MyBatis自己的解析器不是XML标签的内容区。所以testminAge ! null and minAge 0里面的和!不需要转义直接写没问题。这里有一个细节OGNL表达式里的逻辑与如果写在XML里就必须转义成amp;amp;因为是XML特殊字符。为了避免这个麻烦我通常建议在test属性里直接用and比如minAge ! null and minAge 0简洁又安全。第三SQL片段里的比较符号要放在CDATA里但CDATA只包住符号本身不要包住if标签。第一次写的人很容易把整个条件用CDATA包起来结果动态标签就失效了这一点我在前面已经强调过。再看一个查询所有状态不为关闭的用户且年龄在指定区间的完整案例。select idselectActiveUsersByAgeRange resultMapUserResultMap ![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE status 0 AND age #{minAge} AND age #{maxAge} ]] /select这个SQL里的比较符号多但都是静态条件直接整段CDATA最简单。#{minAge}放CDATA里面完全没问题因为CDATA只是让XML解析器跳过这段文本#{}参数替换是在SQL解析阶段进行的阶段不同互不冲突。3.4 参数空值和类型转换陷阱写完动态SQL还有一个非常隐蔽的坑参数为null时SQL不报错但结果不对。比如你写了select idselectByAge resultMapUserResultMap ![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age #{minAge} ]] /select如果minAge传了null进去拼接后的SQL是WHERE age null。在SQL中任何值和NULL比较结果都是UNKNOWN最终查询结果为空。而且这个错误非常隐蔽不报错、不警告就是查不到数据你要是不知道原因排查起来相当费劲。解决办法就是加if判空select idselectByAge resultMapUserResultMap SELECT id, name, age, status, create_time FROM user where if testminAge ! null AND age ![CDATA[ ]] #{minAge} /if /where /select还有一种类型陷阱数据库字段类型和传入参数类型不匹配。比如字段是varchar类型你传入了一个数字MySQL会做隐式类型转换。表面上看没毛病但索引可能失效性能变得很慢。更严重的是字符串比较的问题如果年龄字段设计成了varchar12 9会返回true因为字符串比较按字符顺序走先比较第一位1和91小于9所以结果是false和数字比较的结果完全相反。这种问题只能从表结构设计上解决数字字段就用数字类型别把数值存成字符串。4. 常见问题与排查技巧4.1 XML解析报错的定位思路排序问题的时候先看日志里有没有XML解析异常。MyBatis启动阶段加载mapper.xml时如果解析失败应用会直接启动不了或者运行到某条SQL时报错。定位思路按优先级来第一看报错信息里有没有 well-formed 字样有的话基本就是XML结构不合法优先排查比较符号。第二看报错信息里提到的行号直接跳到那一行检查注意XML报错的行号对应的是XML文件的行号不是SQL的行号。第三用IDE的XML校验功能。IDEA里打开XML文件如果文件里有语法错误右侧会显示红色波浪线鼠标放上去就能看到具体的错误原因。养成写完mapper看一眼的习惯很多低级错误当场就能发现。我整理过一张速查表这些年遇到的异常情况基本都能对上号现象可能原因解决办法XML解析失败报well-formedSQL里直接写了未转义用lt;或CDATA包裹报错提到某个元素未闭合后的内容被误认为标签文档结构混乱检查所有比较符号是否处理SQL执行结果为空但无异常参数为nullSQL变成字段 null加if判空CDATA里的动态SQL不生效if被包进了CDATA区域if移到CDATA外面字符串比较结果不对字段类型为varchar隐式类型转换导致按字符比较修改表结构或使用CAST转换test表达式里用报错XML不识别裸字符用and替代或写amp;amp;4.2 CDATA吞掉动态标签的深层坑前面已经讲了CDATA不能包动态标签这里再深入说一个更隐蔽的变体。有时候你会出于习惯把写好的SQL直接整个包进CDATA然后里面又想加动态条件比如select idselectBadExample resultMapUserResultMap ![CDATA[ SELECT * FROM user WHERE age 18 if teststatus ! null AND status #{status} /if ]] /select这段代码不会报错但if会被当成字符串拼进SQL。真正执行的时候SQL长这样SELECT * FROM user WHERE age 18 if teststatus ! null AND status ? /if数据库看到if这种语法直接抛SQL语法错误报错的位置还特别靠后很难第一时间想到是CDATA的问题。这种问题怎么破我的习惯是写SQL之前先想清楚这段SQL最终是静态的还是动态的静态就整段CDATA动态就把动态标签放在CDATA外面CDATA只管符号。动态SQL和不等于符号结合时我通常这样写if testexcludeStatus ! null AND status lt;gt; #{excludeStatus} /if只一个不等于号用转义就够没必要再上CDATA。如果比较符号多单个条件里可以混合if testminAge ! null and maxAge ! null AND age ![CDATA[ ]] #{minAge} AND age ![CDATA[ ]] #{maxAge} /if这种写法动态标签在外面CDATA只包住符号两个阶段的解析各管各的互不干扰是最稳妥的组合方式。4.3 不等于符号在不同数据库下的行为差异和!在MySQL里都表示不等于但是有个null的坑很多人第一次踩到都会懵。举个例子SELECT * FROM user WHERE status 0这条SQL不会返回status IS NULL的记录因为在SQL的Three-Valued Logic三值逻辑里NULL 0的结果是UNKNOWNUNKNOWN在WHERE条件里等价于false。如果你的业务语义是状态不等于0的都查出来而状态字段又允许为null那这个null的数据就会悄无声息地被漏掉。解决办法是显式处理null![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE (status 0 OR status IS NULL) ]]不同数据库对不等于的支持也有细微差别MySQL两种写法都支持Oracle也支持和!但SQL Server里两种也都支持。为了统一规范我一般推荐统一写这一方面是SQL标准写法另一方面和XML转义的lt;gt;对应性强团队协作时风格也统一。4.4 动态比较符号的进阶处理有些场景里比较符号本身也是前端传过来的。比如查询条件里有个大于/小于/等于的下拉框这种时候你可能会想把符号直接拼进SQLselect idselectByDynamicSymbol resultMapUserResultMap SELECT id, name, age, status, create_time FROM user WHERE age ${symbol} #{value} /select这个写法能跑但\${}是字符串拼接不做任何预编译处理如果symbol从前端直接传进来等于给SQL注入留了个大口子。我见过有人传symbol1 OR 11把整个表的数据都查出来的这种问题属于高危漏洞日常开发中一定要避免。我的建议是用choose分支白名单处理把符号控制在固定几个选项里select idselectByDynamicSymbol resultMapUserResultMap SELECT id, name, age, status, create_time FROM user where choose when testsymbol gt AND age ![CDATA[ ]] #{value} /when when testsymbol lt AND age ![CDATA[ ]] #{value} /when when testsymbol eq AND age #{value} /when /choose /where /select这样前端只能传gt、lt、eq这几个固定值映射到固定的SQL片段既满足需求又没有注入风险。如果前端想直接传符号后端也要做严格校验把符号限定在白名单里再拼接绝对不要裸拼参数。4.5 关于SQL日志排查比较符号问题的技巧最后分享一个排查技能。当mapper.xml里的SQL执行结果不对时先别急着改XML把MyBatis生成的SQL打印出来看。常见的做法是在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样MyBatis会在控制台打印完整SQL和参数列表。你会发现XML里写的gt;到了SQL日志里已经变成了![CDATA[ ]]也已经还原成了因为MyBatis拿到的是XML解析后的真实内容。通过对比打印出的SQL和预期SQL基本能快速定位问题出在XML解析阶段还是SQL执行阶段。这一步非常关键很多人排查方向从一开始就错了用日志定位可以少走很多弯路。这几年代码写下来我对mapper.xml里比较符号的处理形成了一套自己的习惯单个符号、SQL不长用转义一段逻辑比较符号多用CDATA涉及动态标签动态标签放外面符号用转义或者CDATA包一小段。其实不管哪种写法本质都是让XML解析器别把比较符号当标签处理理解了这个原理遇到再奇怪的报错也能顺着根因排查下去。希望这篇从原理到实操再到排错的总结能帮你把这个问题一次解决干净。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进