大厂面试题:从源码的角度分析MyBatis中#{}与${}的区别

大家好,我是王有志。

今天我会通过源码来分析一道京东,联储证券和爱奇艺都考察过的 MyBatis 面试题:MyBatis 中“#{}”和“${}”有什么区别?是否可以使用“#{}”来传递 order by 的动态列?

“#{}”和“${}”有什么区别?

“#{}”在 MyBaits 中表示一个占位符,MyBatis 在解析 Mapper 文件的 SQL 语句时会将“#{}”的部分替换成占位符“?”,执行时使用 JDBC 编程中的 PreparedStatement 预编译语句,相当于如下代码:

Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8", "root", "123456");

String sql = "select * from user where user_id = ?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, 1);

需要注意的是,在 PreparedStatement 中使用占位符时 Java 会明确要求数据类型,因此 MyBatis 在处理通过“#{}”设置的参数时会进行转义,这种方式带来的优点是,在 MyBatis 中使用“#{}”会有效的避免 SQL 注入,提高系统的安全性。

“${}”在 MyBatis 中表示字符串拼接,不会进行转移,无论传入的参数为哪种类型,最后都会被作字符串类型拼接到 SQL 语句中,这会导致传入特殊字符串时,SQL 注入的风险大大增加。

可以使用“#{}”来传递 order by 语句的动态列吗?

先说结论:可以使用“#{}”传递参数,但是没有任何效果,如果想要起到 order by 效果,必须使用“${}”传递参数

使用“#{}”传递参数,MyBatis 会进行转义,当传入的类型为字符串类型时,最后设置到 SQL 语句中的参数前后会添加单引号,我们有如下的 Mapper 接口的方法:

List<UserDO> selectAll(@Param("orderColumn") String orderColumn);

Mapper 接口对应的 MyBatis 映射器为:

<select id="selectAll" resultType="com.wyz.entity.UserDO">
  select * from user order by #{orderColumn} desc
</select>

为其编写测试方法:

@Test
public void testSelectAll() {
  Reader mysqlReader = Resources.getResourceAsReader("mybatis-config.xml");
  SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(mysqlReader);
  SqlSession sqlSession = sqlSessionFactory.openSession();
  UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
  List<UserDO> users = userMapper.selectAll("user_id");
  log.info("查询到的数据:{}", JSON.toJSONString(users));
}

执行测试方法后,发现查询到的结果并没有按照“user_id”字段进行倒序排序,我们看到 MyBatis 的打印日志,映射器中#{orderColumn}被替换成了占位符“?”:

==>  Preparing: select * from user order by ? desc
==> Parameters: user_id(String)

而在最终执行时 Java 会使用转义后的 orderColumn 参数替换掉占位符,最终执行的 SQL 语句如下:

select * from user order by 'user_id' desc

使用“${}”传递参数,是直接将传递的参数拼接到 SQL 语句中,我们修改UserMapper#selectAll方法的 SQL 语句映射,使用“${}”传递参数:

<select id="selectAll" resultType="com.wyz.entity.UserDO">
  select * from user order by ${orderColumn} desc
</select>

同样的,我们执行测试代码,可以看到控制台输出的 SQL 语句直接将参数“id”拼接到了 SQL 语句当中,并且查询结果也符合我们的预期。

==>  Preparing: select * from user order by user_id desc
==> Parameters: 

因此,当我们希望通过参数传递参与 order by 排序的字段时,我们应该使用“${}”去传递参数,同理,对于查询字段,以及条件语句中,如果想要动态的拼接字段,我们都应该使用“${}”传递参数。

SQL 注入案例

在互联网的早期时代,网站对于用户的密码存储并不规范,且登录权限校验并不完善,非常容易出现 SQL 注入的情况。例如,我们有如下用户表:

create table user (
  user_id     int         not null comment '用户Id' primary key,
  account     varchar(30) not null comment '账户',
  password    varchar(30) not null comment '密码',
  name        varchar(50) not null comment '用户名',
  age         int         not null comment '年龄',
  gender      varchar(50) not null comment '性别',
  id_type     int         not null comment '证件类型',
  id_number   varchar(50) not null comment '证件号'
);

在登录验证中,只做了账户和密码的非空验证后,就直接使用账户和密码查询用户信息,并且在 MyBatis 映射器中使用了“${}”来拼接参数,如下:

<select id="selectUserByPassword" resultType="com.wyz.entity.UserDO">
  select * from user where account = ${account} and password = ${password}
</select>

我们为 user 表中插入两条数据:

接着我们来写一段测试代码:

@Test
public void testSelectUserByPassword() {
  SqlSession sqlSession = sqlSessionFactory.openSession();
  UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

  String account = "'root' or 1 = 1";
  String password = "'大傻瓜'";
  UserDO user = userMapper.selectUserByPassword(account, password);

  System.out.println("root 用户信息:" + JSON.toJSONString(user));

  sqlSession.close();
}

执行这段测试代码后,可以看到控制台输出了账户为 root 的用户信息:

可以看到,最后生成的 SQL 语句是:

select * from user where account = 'root' or 1 = 1 and password = '大傻瓜'

无论密码输入什么,都可以被轻松的绕过去,这就是 SQL 注入攻击。

源码分析

MyBatis 中 SQL 语句的处理可以分为两个部分:

  1. MyBatis 构建 SqlSessionFactory 时,读取所有映射器文件,将 SQL 语句信息加载到 MyBatis 的运行环境中;
  2. MyBatis 执行映射器方法时,查找 MyBatis 运行环境中的 SQL 语句,替换参数并执行 SQL 语句。

其中第一部分的 SQL 处理中不涉及到参数处理,只做映射器中 SQL 语句的配置解析,因此我们直接来看第二部分,MyBatis 在执行 SQl 语句时的处理流程。

我们写一条同时使用含“#{}”和“${}”的 SQL 语句:

<select id="selectById" resultType="com.wyz.entity.UserDO">
  select * from user where user_id = #{userId} order by ${orderColumn} desc
</select>

这里自行补充下对应的接口方法以及测试案例即可,下面我们通过这句 SQL 语句,来看下 MyBatis 是如何处理“#{}”和“${}”的。

由于 MyBatis 是默认开启缓存的,因此在没有特殊配置的时候,MyBatis 的默认使用的执行器是 CachingExecutor,我们通过一张调用流程图来看下 CachingExecutor 是如何处理 SQL 语句的。

我们重点关注DynamicSqlSource#getBoundSql方法,源码如下:

public BoundSql getBoundSql(Object parameterObject) {
  DynamicContext context = new DynamicContext(configuration, parameterObject);
  // 处理通过 ${} 设置的参数,直接拼接到 SQL 语句中
  rootSqlNode.apply(context);
  SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
  Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
  // 处理通过 #{} 设置的参数,将其替换为占位符 ?
  SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
  BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
  context.getBindings().forEach(boundSql::setAdditionalParameter);
  return boundSql;
}

其中第 5 行调用的rootSqlNode.apply(context)会处理通过“${}”方式设置的参数,将参数值直接拼接到 SQL 语句中;而第 9 行中调用的SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings())会处理通过“#{}”方式设置的参数,将其替换为占位符“?”。

处理通过“${}”设置的参数

我们接着rootSqlNode.apply(context)这行代码往下看,这段调用的MixedSqlNode#apply方法,源码如下:

public boolean apply(DynamicContext context) {
  contents.forEach(node -> node.apply(context));
  return true;
}

注意,这里的入参 context 与 MixedSqlNode 的成员变量 contents 是不同的:

  • context,存储传递到 SQL 语句中的参数;
  • contents,存储 MyBatis 映射器中的 SQL 语句(未处理参数的)。

接着来看 forEach 中调用的node.apply(context),由于我们的 SQL 语句非常简单,这里会调用到 SqlNode 的实现类 TextSqlNode 的 apply 方法中,源码如下:

public boolean apply(DynamicContext context) {
  GenericTokenParser parser = createParser(new BindingTokenParser(context, injectionFilter));
  context.appendSql(parser.parse(text));
  return true;
}

首先是第 2 行构建 GenericTokenParser 实例对象的方法:

public class TextSqlNode implements SqlNode {
  private GenericTokenParser createParser(TokenHandler handler) {
    return new GenericTokenParser("${", "}", handler);
  }
}

public class GenericTokenParser {
  public GenericTokenParser(String openToken, String closeToken, TokenHandler handler) {
    this.openToken = openToken;
    this.closeToken = closeToken;
    this.handler = handler;
  }
}

这段代码非常简单,我们可以清晰的看到创建的 GenericTokenParser 实例对象是用于处理“${}”的,这里需要注意,GenericTokenParser 实例对象中的成员变量 handler 中包含 context 对象,而该对象中存储着 SQL 语句的参数数据。

下面我们来看第 3 行调用的parser.parse(text)方法,该方法的部分源码如下:

public String parse(String text) {
	// 省略部分代码
	// 获取“${”在SQL语句中的位置
	int start = text.indexOf(openToken);
	// 省略部分代码
	char[] src = text.toCharArray();
	int offset = 0;
	final StringBuilder builder = new StringBuilder();
	StringBuilder expression = null;
	do {
		if (start > 0 && src[start - 1] == '\\') {
			// 省略部分代码
		} else {
			if (expression == null) {
				expression = new StringBuilder();
			}
			// 省略部分代码
			builder.append(src, offset, start - offset);
			offset = start + openToken.length();
			// 获取与“${”对应的“}”在 SQL 语句中的位置,注意这里的 offset
			int end = text.indexOf(closeToken, offset);
			while (end > -1) {
				if ((end <= offset) || (src[end - 1] != '\\')) {
					expression.append(src, offset, end - offset);
					break;
				}
				// 省略部分代码
			}
			if (end == -1) {
				// 省略部分代码
			} else {
				// 拼接通过“${}”传入的参数
				builder.append(handler.handleToken(expression.toString()));
				offset = end + closeToken.length();
			}
		}
		start = text.indexOf(openToken, offset);
	} while (start > -1);
	if (offset < src.length) {
		builder.append(src, offset, src.length - offset);
	}
	return builder.toString();
}

代码看起来很复杂,但实际上就做了一件事,反映到我们在源码分析开篇中的 SQL 语句中,就是将${orderColumn}替换为传入的参数。这段代码的复杂性主要来自于 SQL 语句中可能包含多个“ ”,处理时需要考虑到“ {}”,处理时需要考虑到“ ,处理时需要考虑到{”与“}”的对应关系。

最后来看下第 33 行中调用的handler.handleToken(expression.toString())方法,需要注意这里的 handler 是 BindingTokenParser 的实例对象,源码如下:

public String handleToken(String content) {
	Object parameter = context.getBindings().get("_parameter");
	if (parameter == null) {
		context.getBindings().put("value", null);
	} else if (SimpleTypeRegistry.isSimpleType(parameter.getClass())) {
		context.getBindings().put("value", parameter);
	}
	Object value = OgnlCache.getValue(content, context.getBindings());
	String srtValue = value == null ? "" : String.valueOf(value);
	checkInjection(srtValue);
	return srtValue;
}

这段代码不难理解,方法的入参是“${}”中间的参数名,反映到开篇的 SQL 语句中,即是参数名“orderColumn”。BindingTokenParser#handleToken方法的核心作用是通过参数名在 context 对象中查找与之对应的参数值,需要特别注意的是,这里查找到参数值后直接“暴力”的将值的类型转换为了 String 后返回,并直接通过StringBuilder#append直接拼接到 SQL 语句中。

处理通过“#{}”设置的参数

我们来看SqlSourceBuilder#parser方法的源码:

public SqlSource parse(String originalSql, Class<?> parameterType, Map<String, Object> additionalParameters) {
	ParameterMappingTokenHandler handler = new ParameterMappingTokenHandler(configuration, parameterType, additionalParameters);
	GenericTokenParser parser = new GenericTokenParser("#{", "}", handler);
	String sql;
	if (configuration.isShrinkWhitespacesInSql()) {
		sql = parser.parse(removeExtraWhitespaces(originalSql));
	} else {
		sql = parser.parse(originalSql);
	}
	return new StaticSqlSource(configuration, sql, handler.getParameterMappings());
}

这段代码会调用到第 8 行的parser.parse(originalSql),该方法与之前我们看到的 parser 方法类似,这里我们就不展示其源码,我们直接找到 handleToken 方法,这里调用的是SqlSourceBuilder#handleToken方法,源码如下:

public String handleToken(String content) {
  parameterMappings.add(buildParameterMapping(content));
  return "?";
}

该方法有两个作用:

  1. 第 2 行调用的buildParameterMapping(content)用于处理参数信息,获取该参数的 Java 类型,JDBC 类型以及映射关系等;
  2. 第 3 行直接返回了占位符“?”,用于拼接到 SQL 语句中。

实际上到了这里 SQL 语句中使用到的“#{}”都已经被设置成了占位符“?”,例如,开头的的 SQL 语句实际上已经被转换成了如下的内容:

select * from user where user_id = ? order by user_id desc

通过断点调试源码也可以看到,在执行到sql = parser.parse(originalSql)时,originalSql 中的 SQL 语句还是未处理的#{userId},而调用该方法之后返回的 SQL 已经将 originalSql 中的#{userId}替换为了占位符“?”。

image.png


好了,今天的内容就到这里了,如果本文对你有帮助的话,希望多多点赞支持,如果文章中出现任何错误,还请批评指正。最后欢迎大家关注分享硬核 Java 技术的金融摸鱼侠王有志,我们下次再见!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/574304.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【Linux】网络与守护进程

欢迎来到Cefler的博客&#x1f601; &#x1f54c;博客主页&#xff1a;折纸花满衣 &#x1f3e0;个人专栏&#xff1a;题目解析 &#x1f30e;推荐文章&#xff1a;进程状态、类型、优先级、命令行参数概念、环境变量(重要)、程序地址空间 目录 &#x1f449;&#x1f3fb;守护…

Netty 进阶

文章目录 1. 粘包与半包1.1 粘包现象1.2 半包现象1.3 现象分析1.4 解决方案1) 方法1&#xff0c;短链接2) 方法2&#xff0c;固定长度3) 方法3&#xff0c;固定分隔符4) 方法4&#xff0c;预设长度 2. 协议设计与解析2.1 为什么需要协议&#xff1f;2.2 redis 协议举例2.3 http…

【小白版】最简单的 goland 自定义package 教程

正文 直奔主题&#xff0c;针对小白无法正确使用自定义的package包进行讲解。 在自己的go项目下执行 mod go mod init 项目名创建mod。mod是go管理依赖包的工具&#xff0c;类似Java的pom文件调整goland的配置&#xff0c;具体操作步骤如下面视频 通过视频可以看到原先报红的…

【分配】linear_sum_assignment函数

every blog every motto: You can do more than you think. https://blog.csdn.net/weixin_39190382?typeblog 0. 前言 分配问题小结&#xff0c; linear_sum_assignment 函数使用的是Jonker-Volgenant algorithm算法 1. 分配问题 有工人和相应的工作&#xff0c;每个工作…

51单片机中断和定时的结合应用

#include <reg52.h>unsigned int cnt 0;sbit led P1^1;// 初始化定时器 void TimerSetup(){TMOD 0x01; // 定时器的第1个模式TH0 0xB8; // 定时器的初始值-高位TL0 0x00; // 定时器的初始值-低位TR0 1; //启动定时器cnt 0;EA 1; // 开启总中断ET0 1; // 时间中断…

DFS和回溯专题:全排列 II

DFS和回溯专题&#xff1a;全排列 II 题目链接: 全排列 II 参考题解 代码随想录 题目描述 代码纯享版 class Solution {public List<List<Integer>> list_all new ArrayList();public List<Integer> list new ArrayList();public int[] res;public Lis…

NVIDIA CUDA Toolkit

NVIDIA CUDA Toolkit CUDA Toolkit 12.4 Update 1 Downloads | NVIDIA Developer CUDA Toolkit是用于CUDA开发的软件包&#xff0c;主要包括CUDA编译器、运行时库、GPU驱动程序和开发工具等。它允许开发者使用通用编程语言&#xff08;如C、C&#xff09;来利用NVIDIA GPU进行…

echart-better基于最新的echarts5.5标题旋转功能

使用教程以及相关的echarts-better最新的包在这里&#xff1a;https://edu.csdn.net/course/detail/24569 echarts在侧边竖向展示标题&#xff0c;以及次标题 主标题和次标题进行旋转&#xff0c;适用于移动端或其他场景。

如何安装mysl驱动程序jar包

简介&#xff08;为什么要安装mysql驱动jar包&#xff09; MySQL 驱动程序&#xff08;通常以 JAR 文件的形式提供&#xff09;用于在 Java 应用程序中连接和与 MySQL 数据库进行交互。这些驱动程序提供了一组 API&#xff0c;使 Java 应用程序能够执行诸如查询、插入、更新和…

大数据真题讲解系列——拼多多数据分析面试题

拼多多数据分析面试题&#xff1a;连续3次为球队得分的球员名单 问题&#xff1a; 两支篮球队进行了激烈的比赛&#xff0c;比分交替上升。比赛结束后&#xff0c;你有一个两队分数的明细表&#xff08;名称为“分数表”&#xff09;。表中记录了球队、球员号码、球员姓名、得…

parallels desktop 19密钥分享 附PD虚拟机安装教程 支持M/intel

PD19虚拟机安装破解教程 Parallels Desktop 百度网盘下载&#xff1a;https://pan.baidu.com/s/1ezQmJAjIx796NEr9WZbcOg 提取码: 8w61 &#xff08;地址容易失效&#xff0c;来之不易&#xff0c;务必点赞和收藏&#xff0c;如果失效了请到评论区留言反馈&#xff09; 注意&…

猫咪吃主食罐头的好处盘点,附高营养高适口猫罐头推荐清单

关于是否要给猫咪喂食罐头&#xff0c;这可真是个让人头疼的争议话题啊&#xff01;有的猫主人觉得&#xff0c;罐头能让猫咪尝到更多美味&#xff0c;营养也更全面&#xff1b;而有些则觉得&#xff0c;猫粮就足够了&#xff0c;何必多此一举呢&#xff1f;作为一位拥有两只6岁…

LeetCode54. 螺旋矩阵

LeetCode54.螺旋矩阵 题解思路 代码 class Solution { public:vector<int> spiralOrder(vector<vector<int>>& matrix) {vector<int> res;int n matrix.size();// 行int m matrix[0].size(); // 列vector<vector<bool>> st(n, v…

C#基础|构造方法相关

哈喽&#xff0c;你好&#xff0c;我是雷工。 以下为C#方法相关的学习笔记。 01 方法的概述 概念&#xff1a;方法表示这个对象能够做什么&#xff0c;也就是封装了这个对象行为。 类型&#xff1a;实例方法—>静态方法&#xff08;抽象方法、虚方法&#xff09;—>特殊…

阿斯达年代记下载注册+短信验证教程分享

阿斯达年代记&#xff1a;三强争霸》预计将于4月24日盛大发布&#xff0c;标志着一款新颖的MMORPG游戏面世&#xff0c;它跨越安卓、苹果和PC三大平台&#xff0c;实现数据互通&#xff0c;满足多元化玩家群体的需求。无论是追求移动便捷的手游爱好者&#xff0c;还是偏爱高性能…

揭秘神器:智能私信破局获客难!

在数字营销的海洋中&#xff0c;每个企业都如同一艘努力航行的船&#xff0c;希望能在广阔的客户蓝海中获得丰收。然而&#xff0c;现实却往往充满挑战&#xff0c;尤其是当面对如何吸引并维系客户这一核心难题时。传统的获客手段逐渐显得力不从心&#xff0c;而智能科技的介入…

OpenHarmony语言基础类库【@ohos.util.Deque (线性容器Deque)】

Deque&#xff08;double ended queue&#xff09;根据循环队列的数据结构实现&#xff0c;符合先进先出以及先进后出的特点&#xff0c;支持两端的元素插入和移除。Deque会根据实际需要动态调整容量&#xff0c;每次进行两倍扩容。 Deque和[Queue]()相比&#xff0c;Queue的特…

Postman获取接口返回值设置为变量

//设置环境变量&#xff0c;提取token // 把responseBody转为json字符串 var jsonData JSON.parse(responseBody); // 设置环境变量&#xff0c;提取token pm.environment.set("Authorization", jsonData.token_type " " jsonData.access_token); //设…

如何分析和优化慢sql语句

前言 sql查询速度比较慢容易成为性能瓶颈,这时我们可以优化我们的sql语句或数据库表 一般sql语句执行很慢的种类分为: 1.聚合查询 2.多表查询 3.表数据量过大查询 4.深度分页查询 这四种的前三种都可以通过优化sql语句来优化sql查询速度 正文 聚合查询 我们可以通过尝…

洗地机什么牌子比较好?4款洗地机品牌型号深度推荐

随着科技的不断发展&#xff0c;清洁工具也在不断进化。手持洗地机作为一种新型的清洁工具&#xff0c;因其便捷、高效的特点受到了消费者的青睐。然而&#xff0c;市场上的洗地机品牌众多&#xff0c;消费者在选择时常常感到困惑。那么&#xff0c;哪些洗地机品牌在口碑上表现…