MyBatis-Plus的条件构造器(Wrapper)之所以能防止SQL注入,核心原理在于它底层使用的是预编译SQL(PreparedStatement)机制,而不是简单的字符串拼接。当你调用wrapper.eq("name", userInput)时,MyBatis-Plus不会把userInput直接塞进SQL语句里,而是生成带有占位符"?"的参数化SQL,然后通过JDBC的PreparedStatement将参数安全地绑定到占位符上。这样一来,即使攻击者输入了类似" ' OR '1'='1 "这样的恶意内容,数据库也只会把它当作一个普通字符串参数来处理,而不会将其解析为SQL语法的一部分。这就是条件构造器防注入的本质——参数化查询。

很多开发者用MyBatis-Plus时觉得"用了Wrapper就安全了",这个理解大方向没错,但细节上容易踩坑。比如你如果自己手写了一段SQL片段,然后用${}把参数拼进去,那照样会被注入。所以真正的安全边界在于是否使用了#{}参数绑定,而不是Wrapper本身。下面我把这套机制拆开讲清楚。

一、MyBatis-Plus条件构造器的工作流程

MyBatis-Plus提供了QueryWrapper、LambdaQueryWrapper、UpdateWrapper等一系列条件构造器。它们的本质是一个SQL条件的构建器对象,最终会生成一段完整的SQL语句和对应的参数列表。

以QueryWrapper为例,当你写出如下代码时:

QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("username", inputName);
wrapper.like("email", inputEmail);
List<User> users = userMapper.selectList(wrapper);

MyBatis-Plus内部会将这个wrapper转换成类似这样的SQL:

SELECT * FROM user WHERE username = ? AND email LIKE ?

同时生成一个参数列表:[inputName, "%" + inputEmail + "%"]。注意,这里的"?"就是预编译占位符,参数是单独传递给JDBC驱动的,不是拼在SQL字符串里的。这就是防注入的第一道防线。

二、预编译SQL为什么能防注入

要理解这个问题,得先知道SQL注入是怎么发生的。传统的字符串拼接方式是这样的:

String sql = "SELECT * FROM user WHERE username = '" + inputName + "'";

如果inputName是"admin' OR '1'='1",最终SQL就变成了:

SELECT * FROM user WHERE username = 'admin' OR '1'='1'

这就被注入了,因为恶意输入改变了SQL的语法结构。而预编译机制的做法完全不同。数据库在执行之前会先对SQL模板进行编译和优化,生成执行计划。参数在执行时才被填入,而且是以数据的形式填入,不会参与SQL语法解析。数据库引擎根本不会把参数内容当作SQL指令来执行,所以无论参数里写什么,都不会改变SQL的结构。

从JDBC层面来说,PreparedStatement接口就是干这个事的。MyBatis-Plus的Wrapper最终生成的MappedStatement,在执行时就是通过PreparedStatement来完成参数绑定的。这是Java数据库访问的标准安全实践,不是MyBatis-Plus自己发明的,但MyBatis-Plus把这套机制封装得很好,让开发者用起来几乎无感知。

三、条件构造器中不同方法的参数处理方式

MyBatis-Plus的条件构造器提供了很多方法,但它们在参数处理上有细微差别,理解这些差别很重要。

1. eq、ne、gt、lt、ge、le等比较方法

这些方法生成的都是等号或比较运算符加占位符的形式,参数全部通过#{}绑定。例如:

wrapper.eq("age", 25);
// 生成: WHERE age = ?

参数25会被安全地绑定到占位符,不存在注入风险。

2. like、notLike方法

这类方法会自动在参数值前后拼接通配符,但拼接操作是在Java代码层面完成的,不是在SQL层面。最终传给数据库的仍然是参数化查询:

wrapper.like("name", "张");
// 生成: WHERE name LIKE ?
// 参数值: "%张%"

注意,通配符"%"是在Java字符串里拼好的,作为一个完整的字符串参数传进去。即使输入里包含"%"或"_"这些LIKE通配符,也只是被当作普通字符处理,不会造成注入。

3. in、notIn方法

当传入集合时,MyBatis-Plus会生成多个占位符:

wrapper.in("id", Arrays.asList(1, 2, 3));
// 生成: WHERE id IN (?, ?, ?)

每个元素都是独立的参数绑定,同样安全。

4. apply方法——需要特别注意

apply方法允许你直接拼接一段SQL片段到WHERE条件中。这个方法有两个参数,第二个参数可以控制是否使用参数化。如果你不小心,就会引入注入风险:

// 安全写法:使用{0}占位符,参数通过#{}绑定
wrapper.apply("date_format(create_time, '%Y-%m-%d') = {0}", "2024-01-01");

// 危险写法:直接拼接,没有参数化
wrapper.apply("date_format(create_time, '%Y-%m-%d') = '" + userInput + "'");

apply方法默认使用${}进行字符串替换,如果你传入的是用户可控的数据,就等于自己打开了注入的大门。所以使用apply时一定要用{0}、{1}这样的占位符语法,让MyBatis-Plus帮你做参数绑定。

四、LambdaQueryWrapper为什么更推荐使用

LambdaQueryWrapper使用Java Lambda表达式来指定字段,比如:

LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, inputName);

这种写法的好处不仅仅是避免了手写字段名导致的拼写错误,从安全角度来说,它强制你通过实体类的getter方法来引用字段,而不是直接写字符串。这减少了因为字段名写错而导致的逻辑漏洞,也让代码更容易审查。在防注入层面,它和QueryWrapper没有本质区别,都是走参数化查询,但从工程实践角度,Lambda写法更规范、更不容易出错。

五、常见的防注入误区和正确做法

误区一:以为用了Wrapper就万事大吉

如果你在Wrapper之外还有自定义SQL,比如在Mapper XML里写了动态SQL,用了${}而不是#{},那注入风险依然存在。Wrapper只管它自己生成的那部分SQL,管不了你手写的部分。

误区二:认为前端过滤就够了

前端验证只能提升用户体验,不能作为安全防线。攻击者完全可以绕过前端直接发HTTP请求。真正的防注入必须在后端、在数据库访问层完成。

误区三:对apply方法掉以轻心

前面已经讲过,apply是Wrapper体系里最容易出问题的地方。原则就是:凡是用户输入的内容,都不要直接拼接到SQL里,必须用占位符。

正确做法总结:

1. 优先使用eq、like、in等标准方法,不要自己拼接SQL片段。

2. 必须用apply时,用{0}占位符,把参数放到第二个参数里。

3. 自定义Mapper XML时,所有动态参数都用#{},绝对不用${}(除非是表名、列名等无法参数化的部分,但这种情况要做白名单校验)。

4. 开启MyBatis-Plus的日志功能,在开发阶段检查生成的SQL是否符合预期。

六、从底层源码看参数绑定的实现

如果你对源码感兴趣,可以去看MyBatis-Plus的Wrapper类和MyBatis核心的MappedStatement。Wrapper最终会调用AbstractWrapper的getSqlSegment方法,生成SqlSegment对象。这个对象包含了SQL片段和参数列表。在执行时,MyBatis的Executor会拿到这个MappedStatement,通过PreparedStatementHandler创建PreparedStatement,然后调用setString、setInt等方法把参数一一绑定上去。

整个过程中,用户输入的数据从来没有被当作SQL代码的一部分来解析。这就是为什么条件构造器能防注入的根本原因——它从架构设计上就把数据和代码分开了。

七、总结与建议

MyBatis-Plus条件构造器的防注入能力,本质上是继承了MyBatis框架的参数化查询机制。Wrapper帮你自动生成带占位符的SQL和参数列表,让你不需要手动拼接字符串就能完成复杂查询。只要你遵循"不用${}拼接用户输入"这一条铁律,基本上就不会有SQL注入问题。

但安全是一个系统工程,Wrapper只是其中一环。你还需要关注输入验证、权限控制、最小权限原则等多个层面。把每一层都做好,才能真正构建起牢固的安全防线。对于日常开发来说,记住一句话就够了:用Wrapper的标准方法,别自己拼SQL,apply要用占位符,XML里用#{}不用${}。