之前在某个云平台的开发者社区里,看到有人问了一个很典型的问题:MyBatis 的<if test="type == '1'">判断明明看着没问题,可真跑起来就是不生效,日志里动态 SQL 的片段始终没拼进去。底下回复五花八门,有人说用 equals,有人说把单引号改成双引号,也有人说要先调用 toString。其实这个问题的答案并不复杂,但背后牵扯到 OGNL 表达式的类型比较机制,很多写了两三年 MyBatis 的人都没完全搞清楚。这篇文章就把 MyBatis 字符串相等判断的正确方法彻底讲明白,包括为什么单引号会翻车、各种写法的底层原理、XML 转义、常见排查套路,以及我在实际项目中固定下来的一套规范写法。适合所有写 Java 后端、天天跟动态 SQL 打交道的同学,新手可以照着抄,老手也能查漏补缺。
1. 现象还原与根因分析
1.1 一个看似正常却不生效的动态 SQL
先看一个最常见的问题场景。某开发者在 Mapper XML 里写了一个动态查询,要根据参数 type 的值决定要不要追加一个过滤条件,代码长这样:
<select id="listBusinessData" resultType="com.example.BusinessData"> SELECT * FROM business_data <where> <if test="type == '1'"> AND type_code = '1' </if> <if test="type == '2'"> AND type_code = '2' </if> </where> </select>业务方法传入的type是一个 String 类型,值是"1"。照理说type == '1'应该成立,可实际运行起来,日志里打印的 SQL 却是全表查询,后面的AND type_code = '1'根本没拼接进去。数据量小的时候还察觉不出问题,等表里数据一多,这个查询直接变成慢 SQL。
更让人困惑的是,有人把test里的单引号改成双引号,写成type == "1",问题就消失了。还有试过type.equals('1')的,结果还是不行。这就说明问题不在 SQL 本身,而在于test这个属性里的表达式是如何被计算的。
1.2 根因:OGNL 里的单引号不是字符串
MyBatis 的<if>、<choose>、<when>、<otherwise>这些动态 SQL 标签,test属性里的内容全部交给 OGNL 表达式引擎去求值。OGNL 的语法和 Java 非常接近,但有一个容易被忽略的差异:在 OGNL 中,单引号包裹的字面量是char字符类型,双引号包裹的字面量才是String字符串类型。
所以type == '1'在实际求值的时候,是拿一个String类型的"1"去和一个Character类型的'1'做相等比较。两个对象类型都不一样,OGNL 在比较的时候直接返回false,后面的 SQL 片段自然就不会拼接。
这里可以用一个生活化的类比来帮助理解:你想问“这件衣服和这块布是不是同一个东西”,可实际上一个是衣服,一个是布片,连比较的前提都不成立。要比较,必须先统一到同一个维度上,让双方都是衣服,或者都是布片。写在test里也一样,必须让比较的双方类型一致。
还有一个经典误区是type.equals('1')。很多开发者觉得调用equals总该没问题了吧,但String.equals(Object)方法内部会先判断传入的参数是不是String类型,传进去一个Character,instanceof直接不通过,结果依旧是false。这也是为什么网上很多答案让人越试越迷糊。
2. 字符串相等判断的正确写法
2.1 四种可行写法对比
我把日常开发中真正可行的写法整理了一遍,每种都从原理角度验证过,可以直接抄作业。
| 写法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
test='type == "1"' | 双引号在 OGNL 中是字符串,类型与 String 参数一致 | 最简洁直观,推荐优先使用 | 属性值需要改用单引号包裹 |
test="type == '1'.toString()" | 先把字符'1'转成字符串,再和 String 参数比较 | 可以继续用双引号包裹属性值,老项目改造成本低 | 写起来略长,需要理解.toString()的意图 |
test="type != null and type.equals('1'.toString())" | 显式调用 Java 的 equals 方法,参数也转成 String | 语义清晰,符合 Java 习惯 | 必须判空,代码较长 |
test="type == '1'"(参数类型为 Character 时) | 只有参数本身就是 Character 类型才成立 | 字符类型场景下没问题 | 字符串参数下完全不适用,要格外小心 |
从表格能看出来,没有一种写法是“万能”的,关键要看参数的实际类型。大多数业务场景里,接口接收到的查询参数都是 String,所以前三种写法是主力方案。
2.2 为什么更推荐双引号写法
就我个人经验来说,最省心的写法其实是:
<if test='type == "1"'> AND type_code = '1' </if>这里用了一个 XML 的小技巧:属性值原本习惯用双引号,那就把整个test属性值改用单引号包起来,这样内部的字符串常量就可以名正言顺地使用双引号了,不需要任何转义。
这种写法的好处很明显:一是可读性好,一眼就能看出"1"是字符串;二是没有额外的方法调用,OGNL 直接按字符串比较;三是不容易再犯“单引号是字符”的错。XML 规范允许属性值用单引号或双引号任一种,不会影响解析。
如果项目里已经大量使用了test="..."这种双引号包裹的风格,又不想大范围改动,那就在字符串常量后面加.toString(),比如test="type == '1'.toString()"。这个写法在旧代码里兼容性最好,也能解决类型不一致的问题。
2.3 equals 方法的正确用法与判空
调用equals本身没有错,错的是用错了参数类型和忽略了空指针。正确写法应该是:
<if test="type != null and type.equals('1'.toString())"> AND type_code = '1' </if>注意两个关键点:第一,必须先判空,再调用equals。虽然 OGNL 表达式里的and有短路效果,如果type为null,后面的表达式不会执行,但写清楚判空条件能让代码意图更明确,也能避免未来调整表达式顺序时踩坑。第二,equals的参数必须是字符串,所以'1'后面必须带.toString()。如果直接写type.equals('1'),等于在调用String.equals(Character),结果永远是false。
在 MyBatis 的 OGNL 环境里,equals本质上就是调用 Java 方法。OGNL 只是一个求值引擎,它帮你把方法调用执行出来,但 Java 语法本身的规则一条都不会少。理解了这一点,很多关于 equals 的奇怪现象都能解释通。
2.4 不同类型参数的判断对照表
实际项目中参数类型不一定是 String,我把常见类型对应的正确写法整理成一张表,方便对照:
| 参数类型 | 判断字符串/字符/数值的写法 | 说明 |
|---|---|---|
String | test='type == "1"' | 双引号写法最推荐 |
String | test="type == '1'.toString()" | 不想改属性值引号风格时使用 |
Character | test="type == '1'" | 单引号正好匹配字符类型 |
Integer | test="age == 18" | 数值直接写,不用加引号 |
Long | test="id == 100L" | 注意加上类型后缀,避免与 Integer 混淆 |
BigDecimal | 建议 Java 层判断后传入布尔值 | OGNL 中比较小数容易踩精度坑 |
最后那一行是比较容易被忽略的。BigDecimal类型如果在test里直接比较,经常因为精度问题出现意料之外的结果。我的习惯是遇到这种情况,就在 Mapper 接口的 Java 方法里先把比较逻辑算好,再传入一个 Boolean 类型的标志位,让 XML 里的判断变得纯粹简单。
3. 动态 SQL 中经常一起踩的坑
3.1 null 与空字符串的判断顺序
字符串判断不可能只处理“等于某个值”,实际开发中更常见的是判断“非空”。很多人写出来的代码是这样的:
<if test="name != ''"> AND name = #{name} </if>如果name是null,这个表达式在 OGNL 里不会报错,但逻辑是有隐患的。更规范的做法是把判空写在前面:
<if test="name != null and name != ''"> AND name = #{name} </if>这里还有一个 OGNL 的细节:''在 OGNL 中被当作空字符串来处理。因为 Java 的char类型不能表示“空字符”,所以 OGNL 把''直接解析成String类型。这个细节解释了为什么很多人在test里写name != ''是有效的,但也提醒我们,OGNL 的类型推断和 Java 并不完全一致,不能靠直觉判断。
3.2 XML 特殊字符转义
动态 SQL 的test属性写在 XML 标签里,意味着表达式内容必须先经过 XML 解析器这一关。这就带来一个非常常见的坑:特殊字符不转义。
比如要判断年龄大于 18,写<if test="age > 18">在大多数 XML 解析器里还能通过,但写<if test="age < 18">就一定会报错,因为<是 XML 标签的开始符号。正确的写法是:
<if test="age < 18"> AND age < 18 </if>还有比较字符串场景里的逻辑运算符。OGNL 支持&&、||,但 XML 里的&也必须转义,写成&&。很多新人第一次写<if test="type != null && type != ''">时,项目启动直接报 XML 解析错误,就是这个原因。
我的建议很直接:OGNL 表达式里优先使用and、or、not这些英文关键字,避免使用符号。这样既不用转义,可读性也更好。另外,不要在test属性里尝试用 CDATA,CDATA 只能用在元素内容中,不能用在属性值里,这是一个很多人反复踩的误区。
3.3 大小写、空格与数据库排序规则
字符串等值判断还有一个经常被忽视的问题:数据库的排序规则不同,对大小写的敏感度也不同。比如type_code字段在某种排序规则下,用'01'查询可能查不出'01A',也可能查得出。更麻烦的是,用户从前端传入的值可能带了首尾空格,比如搜索关键词传了一个" 已失效 ",SQL 里直接等值匹配自然什么都查不到。
我的处理原则是:能用 Java 层处理的就尽量在 Java 层处理。进入 Mapper 之前先trim()掉首尾空格,统一成标准格式;业务允许忽略大小写的字段,在 Java 层统一转成大写或小写再传入。这样 XML 里就不用到处写upper()、trim(),既能保持 SQL 简洁,也能避免因为函数包裹导致索引失效。
不过有一点要提醒:如果历史数据本身有脏数据,比如在字段里混入了不可见字符,靠 Java 层trim()是处理不干净的,这时候需要先在数据层面做清洗,否则无论怎么改判断逻辑都拦不住。
4. 问题排查思路与实用技巧
4.1 四步定位法
遇到“MyBatis 字符串相等判断不生效”这类问题,我建议按下面四步来定位,基本不会跑偏。
第一步,打开 MyBatis 的 SQL 日志。在配置文件里加上:
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl如果用的是 Spring Boot 加 MyBatis-Plus,就设置:
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl然后看控制台输出的 SQL。如果条件没有拼接进去,说明test表达式求值结果为false,问题出在表达式本身上。如果条件拼接进去了但查不到数据,就要去检查数据库里实际的数据值。
第二步,确认参数类型。在 Java 层调用 Mapper 方法前,打印一下参数的实际类型和值。很多情况下,前端传来的是一个数字,Controller 层转成了Integer,而 XML 里却拿字符串的方式去比较,自然匹配不上。
第三步,检查 XML 特殊字符。观察test表达式里有没有<、>、&这类需要转义的字符,有没有 IDEA 里标红的语法问题。IDEA 对 XML 的语法提示虽然不够智能,但至少能帮你拦截一部分转义错误。
第四步,直接用 OGNL 单测验证表达式。这一步最有效,我单独展开讲。
4.2 用 OGNL 单测直接验证表达式
不想启动整个 Spring 容器,又想快速验证test表达式到底对不对,可以写一个简单的 JUnit 测试,直接调用 OGNL 的 API。MyBatis 自身就带了 OGNL 依赖,不需要额外引入。
import ognl.Ognl; import ognl.OgnlContext; import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; public class OgnlExpressionTest { @Test public void testStringEqualsExpression() throws Exception { Map<String, Object> root = new HashMap<>(); root.put("type", "1"); OgnlContext context = (OgnlContext) Ognl.createDefaultContext(null); Object expression1 = Ognl.parseExpression("type == '1'"); System.out.println("type == '1' -> " + Ognl.getValue(expression1, context, root)); Object expression2 = Ognl.parseExpression("type == \"1\""); System.out.println("type == \"1\" -> " + Ognl.getValue(expression2, context, root)); Object expression3 = Ognl.parseExpression("type == '1'.toString()"); System.out.println("type == '1'.toString() -> " + Ognl.getValue(expression3, context, root)); Object expression4 = Ognl.parseExpression("type != null and type.equals('1'.toString())"); System.out.println("type.equals('1'.toString()) -> " + Ognl.getValue(expression4, context, root)); } }运行这个测试,你会很直观地看到:第一行输出是false,后面三种写法都是true。这种验证方式完全不依赖数据库和 Spring 环境,排查效率非常高。尤其适合在写复杂动态 SQL 的时候,先把表达式在本地验证好,再贴到 XML 里,省去一茬又一茬的启动调试时间。
4.3 常见报错与原因速查表
把这几年代码评审和问题排查中遇到的报错信息整理成了一张速查表,留着备查即可。
| 报错或现象 | 常见原因 | 解决方案 |
|---|---|---|
There is no getter for property named 'type' | 多参数方法没有加@Param注解,OGNL 拿不到参数名 | 在 Mapper 接口方法参数上加@Param("type") |
org.apache.ibatis.ognl.ExpressionSyntaxException | 表达式语法错误,通常是引号未配对或特殊字符未转义 | 检查单双引号使用,检查<、>、&转义 |
java.lang.NullPointerException | 直接调用equals且参数为 null | 判断前先加!= null条件 |
| SQL 能执行但条件没拼接,日志里没有动态片段 | test表达式结果为 false,大概率是类型不匹配 | 按 4.1 四步法逐项排查 |
| 条件拼接了但查不到数据 | 数据库值与判断值不一致,或大小写、空格问题 | 先查数据库实际值,再检查前后端数据清洗 |
注意上面第三行,虽然 OGNL 的and有短路效果,但如果你写的是type != null && type.equals('1'.toString()),并且漏掉了判空,在表达式求值顺序调整时很容易触发空指针。最好的习惯就是把非空判断永远放在最前面。
5. 常见业务场景的写法建议与团队规范
5.1 状态字段过滤
业务系统中最常见的场景就是按状态字段过滤数据。比如订单系统中有一个order_status字段,类型是 varchar,取值范围是"10"待支付、"20"已支付、"30"已取消。此时 Mapper XML 里可以这样写:
<select id="selectOrderList" resultType="com.example.OrderInfo"> SELECT * FROM order_info <where> <if test='orderStatus == "10"'> AND order_status = '10' </if> <if test='orderStatus == "20"'> AND order_status = '20' </if> <if test='orderStatus == "30"'> AND order_status = '30' </if> </where> </select>这段代码读起来很清晰。如果状态分支很多,可以考虑用<choose>结构:
<choose> <when test='orderStatus == "10"'> AND order_status = '10' </when> <when test='orderStatus == "20"'> AND order_status = '20' </when> <otherwise> AND order_status IN ('10', '20', '30') </otherwise> </choose>这里要特别提醒:千万不要把状态值直接拼进 SQL,比如写成${orderStatus},这会带来注入风险。动态 SQL 的意义是“条件动态变化”,而不是“值动态拼接”,值永远应该通过#{}参数绑定传入。
5.2 动态排序字段拼接
动态排序也是一个高频使用场景。有些开发者会写成这样:
<if test="orderBy == 'createTime'"> ORDER BY create_time </if>结果发现传了createTime也不排序。原因还是老问题:'createTime'是字符类型,不是字符串。正确写法:
<if test='orderBy == "createTime"'> ORDER BY create_time </if> <if test='orderBy == "updateTime"'> ORDER BY update_time </if>排序字段这种场景还容易引发另一个安全问题:如果把orderBy的内容直接拼到 SQL 里,用户就可以通过参数控制 ORDER BY 后面的任何内容。稳妥的做法,是在 Java 层做一个白名单映射,只允许传入固定几个字段名,XML 里只做等值匹配,不直接用传入值拼 SQL。比如前端传来"time",Java 层映射为create_time字段名再传入,XML 只判断映射后的值是否匹配白名单项。
5.3 Java 层判断兜底
有些“字符串判断”本质上是业务分支逻辑,并不需要让 XML 去做。比如要根据用户角色展示不同数据范围,这个判断完全可以在 Java 层完成,把判断结果作为一个布尔值传给 Mapper。
boolean needFilter = "admin".equals(currentUserRole); // 然后调用 mapper,传入 needFilterMapper XML 里就很简单:
<if test="needFilter"> AND org_id = #{orgId} </if>这样做的好处是:OGNL 表达式只判断Boolean类型的true/false,完全避开字符串类型比较的坑。在代码评审时,我经常建议大家思考一个问题:这个判断真的需要放在 XML 里吗?如果 Java 层能算出来,就把它算完再传进去,XML 只做最小逻辑。
5.4 团队规范建议
踩过足够多的坑之后,团队规范比个人技巧更重要。我这里给出一套可以直接搬进团队开发规范的写法约定:
<if>、<when>等标签的test属性中,字符串常量一律使用双引号,即test='field == "value"';如果项目历史代码大量使用双引号包裹属性值,则统一使用'value'.toString()形式,同一项目内保持一种风格。- 调用
equals方法前,必须显式判断field != null。 - 表达式中的逻辑运算统一使用
and、or、not关键字,不使用&&、||。 - 涉及
<、>、&的表达式,在 XML 中必须使用对应的实体引用<、>、&。 - 能在 Java 层完成类型转换或业务判断的,不放到 XML 里做。
- 代码评审时重点关注
== '这种模式,一旦出现就要求改成规范的字符串写法。
这些规范看起来简单,但能显著减少动态 SQL 相关的低级 bug。尤其是团队里有新人加入时,有一条明确的规则摆在面前,比自己摸索试错有效率得多。
最后说一个我自己的习惯。从踩过几次坑之后,我在 Mapper XML 里写test表达式就遵循三条铁律:字符串常量一律用双引号,并且属性值用单引号包;凡是调用equals,前面必须判空;遇到语法层面的断言,宁可多写一层括号也不要让类型隐式转换来掺和。如果你也在为同样的现象苦恼,不妨先打开日志看看条件到底有没有拼进去,再回来检查类型。很多时候问题不在 SQL,而在表达式本身。