谈谈EF Core是怎么将内插字符串转为参数化SQL查询的

小柊 发表于 2019年07月22日 00时45分42秒

序、扯淡

不要问笔者这消失的五月和六月,笔者不想谈……Endless work Limited life……

然后还在考虑一个问题,以后博客还是像以前一样一月一水呢,还是好好研究点技术,不定期发表。但总觉得后者会导致笔者进入无限鸽的状态,还是算了。

 

在文章的开始,我需要先提三个名词:Entity Framework、SQL注入和字符串插值。

 

1.Entity Framework

Entity Framework(下面简称EF),是微软自己推出的一款ORM框架,与Linq完美融合,可以通过lmbda表达式来进行数据库查询,面向对象的ORM框架。

在进入.net Core时代后,EF也随之跟进,推出了Entity Framework Core(下面简称EF Core)。

EF Core完全开源,github地址是:https://github.com/aspnet/EntityFrameworkCore

 

2.SQL注入

SQL注入是一种常见的攻击手法,如果开发人员未对用户提交的数据进行检查和转义就直接拼入SQL语句中,则有可能产生此安全问题。

SQL注入攻击的本质和其他攻击手法(如溢出等)一致,没有将命令与数据进行区分。

 

有关SQL注入这种攻击方式更加简单易懂的解释可以参考这篇文章:《我该如何向非技术人解释SQL注入?

避免SQL注入漏洞的最有效方式就是使用参数化查询。当然,如果是使用ORM进行数据库操作的话,就不用特别担心SQL注入,因为大部份的ORM框架内部就使用了参数化查询,如EntityFramework,Dapper等。但这并不意味着使用ORM框架就能高枕无忧,当开发人员没有正确的使用ORM框架提供的方法时,仍有可能带来SQL注入漏洞,就比如笔者下面要提到的例子。

 

3.字符串插值

字符串插值是C# 6 中新引入的语法糖,在C#中,由于字符串是不可变类型,所以直接对字符串进行拼接会产生不必要的字符串对象影响性能。在C# 6前,开发人员经常使用string.Format函数进行字符串插值工作:

 

在C# 6中,如果字符串前有“$”符号,则字符串中的花括号中允许直接填入变量名或属性等,在运行中,字符串中的花括号部分将使用其变量值替换掉。

 

一、前言

Entity Framework Core 3.0到笔者写这篇文章的时候,已经出到Preview 6了,关于它的新功能与特性,相信.net core开发员都已经或多或少听说过了。当然,如果您还没来得及了结,可以看看的这篇博客:《Entity Framework Core 3.0 Preview 6 发布》。

在Entity Framework Core 3.0中,有一个破坏性的更改:原先的FromSql方法被重命名为FromSqlRaw和FromSqlInterpolated,原因是此方法容易被开发人员错误的调用造成SQL注入问题。

那什么是正确的调用,什么是错误的调用呢?有开发者在EF Core的github Issues中给出了对应的示例(见:《Separate methods for raw SQL that leverage interpolated strings》)。

 

正确的使用方式:

 

此时EF Core向数据库提交的SQL命令是:

 

数据库响应时间 0.560ms,一切正常。

 

错误的使用方式:

 

此时EF Core向数据库提交的命令为:

 

数据库响应时间 10001.162ms,显然用户名中的sleep命令注入成功。

 

同样是字符串插值,只不过一个直接传递给FromSql函数,而一个在外面的变量中放了一放,居然产生了两种结果。

这就让笔者很好奇了,字符串插值之后的结果不是字符串吗,为什么将字符串插值直接传递给FromSql函数,EF Core就能进行参数化查询呢?字符串插值这个语法糖的本质又是什么呢?

 

二、难不成是编译器做了什么?

首先我们必须搞懂C# 6引入的字符串插值这个语法糖的本质是什么,指不定编译器又帮我们做了什么。

我们新建一个控制台项目,编写下面的代码:

 

编写完成后按F6进行生成,然后使用ILSpy查看编译后的程序源代码,看看编译器都做了什么:

 

可以看到我们上面的内插字符串被编译成了string.Format方法,而string.Format方法我们都知道会利用给定的参数对字符串模板进行渲染,返回值还是一个字符串。

如果是一个字符串EF Core肯定区别不出来哪个是SQL语句哪个是参数啊,难不成是别的原因?

 

注:

这一节其实有两个问题:

1.ILSpy在反编译时,需要将C#版本调整至C# 6.0以下的版本,否则反编译的结果依然还是字符串插值表达式,如下图:

 

这是因为ILSpy根据C#版本进行了代码调整,我们可以切换ILSpy的反编译目标语言为“IL with C#”,可以看到字符串插值在这个例子中是被编译为string.Format函数调用的:

 

2.C#编译器并不会任何时间都将内插字符串编译为string.Format调用,如果给定的内插字符串可以被编译为少量的字符串拼接,则C#编译器就会直接将几个字符串直接相加:

例如下面的代码(编译模式:Release):

 

按F6生成,然后用ILSpy看一下编译后的结果,发现上面代码中的内插字符串并没有编译为string.Format函数调用。

 

这可能是因为string.Format的底层实现是通过StringBuilder实现的,C#编译器在遇到简单的内插字符串时,如果发现调用string.Format的代价大于直接相加,所以就直接编译成简单的字符串相加,而不是使用string.Format。

 

三、那是为什么?

在上面的章节中,我们发现C# 6的字符串插值最终仍会返回一个字符串,如果只是一个字符串的话,那EF Core是没有办法准确区分出SQL命令和参数的,那EF Core到底是怎么做到内插字符串准确的转换为参数化SQL语句的呢?

还是直接去翻EF Core的源码吧:

这边笔者下的是EntityFrameworkCore 3.0.0 Preview6.19304.10的源码,FromSql方法已经被重命名成FromSqlRaw方法和FromSqlInterpolated方法了。从方法名看,能将内插字符串转换为参数化SQL的一定就是FromSqlInterpolated方法了,我们直接在项目中搜索FromSqlInterpolated方法的定义即可,FromSqlInterpolated方法被定义在RelationalQueryableExtensions.cs文件中,这是一个拓展方法,方法的第一个参数是需要被拓展的类型实例,我们看到,这个方法接收的参数类型居然是一个FormattableString类型,明明在一开始的例子里,传入的参数是一个内插字符串!

 

难不成内插字符串还能直接赋值给FormattableString类型?

光猜没有用,我们得编写个代码试一试:

 

按F6生成,生成通过!这下我们再拿ILSpy看一下编译后的结果:

 

我们看到,这次内插字符串并没有被编译为string.Format或者是字符串拼接,而是被编译器编译为一个FormattableStringFactory.Create调用,其函数返回结果是一个FormattableString实例!

 

看来,C# 6的字符串插值,既可以被编译为一个string类型,又可以被编译为一个FormattableString类型,具体被编译成哪个类型,由编译器根据内插字符串的赋值对象类型决定。这就和lambda表达式一样,既可以被编译为一个匿名函数赋值给委托,又可以被编译成一个LambdaExpression对象以生成表达式树。

 

四、FormattableString是啥

翻了一下微软的文档,FormattableString类用于表示需要格式化的字符串及其参数,可以通过访问它的的Format属性以获得格式化字符串;也可以通过它的GetArguments方法,来获得用于格式化的参数。

 

既然能够得到原始的SQL命令,也能得到参数对象,这下EF Core是怎么对内插字符串进行参数化SQL语句转化的秘密也就揭开了。

 

五、重载顺序

本来写到这里就准备结尾的,但突然发现了一件事情:

EF Core 2.0引入了内插字符串自动转参数化SQL,但EF Core 2.x中,FromSql语句既能够接受内插字符串作为参数,又能接受普通字符串作为参数。

内插字符串是能被编译器自动编译为FormattableString类型的,但普通字符串就只能被编译为string类型,这就说明EF Core 2.x对FromSql函数进行了重载。

我们可以翻源码看一下,来证实我们的猜想,还是RelationalQueryableExtensions这个文件(此文件在2.2.6版本与3.0版本存放位置并不相同):

 

可以看到,稍稍和之前的猜测有些差距,FromSql函数确实有两个重载函数,一个接受类型为RawSqlString参数,另一个则是上文中提到的接受FormattableString类型参数。并没有接受string类型的重载。

那为外面调用时可以直接传入string类型参数呢?翻了一下RawSqlString类的定义后发现,RawSqlString类型可以由string类型隐式转换而来。

当内插字符串作为参数调用FromSql时,编译器优先匹配FormattableString参数类型的函数而不匹配需要隐式转换的参数类型。

另外,根据测试,当重载参数分别为string和FormattableString类型时,内插字符串会优先匹配重载参数为string的函数,故可得出结论如下:

 

内插字符串重载时优先级:

string > FormattableString > 其他可以隐式转换的对象

 

六、结尾

不得不说EF真的是微软呕心沥血打造出来的ORM框架,看似简单框架的函数方法里,隐藏着不少C#的冷门&底层知识。

总之,EF牛逼!

 

 

小柊

2019年7月22日 00:40:28

相关文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注