EF Core原理揭秘

1. EF Core有哪些做不到的事情

很多复杂的C#代码都能被EF Core转换为合适的SQL语句。

但是C#语法是千变万化的,而SQL语句相对来讲是比较简单的,因此就存在一些语法上合法的C#代码无法被翻译为SQL语句的情况。

比如说在.NET6中,如果使用Microsoft SQL Server,字符串的PadLeft方法就无法被翻译为SQL语句

SQL
 var articles = TDC.Comments.Where(b => b.Message!.PadLeft(5) == "hello");

 foreach (var article in articles)
 {
     await Console.Out.WriteLineAsync(article.Message);
 }

上面的代码运行后会抛出异常信息”The LINQ expression could not be translated”。

这句话翻译成中文就是”表达式无法被翻译”。

当然这是目前程序在.NET8下运行的结果,也许在后续版本中当EF Core框架升级后,就能翻译PadLeft方法了。

但是C#语法是千变万化的,总会有EF Core翻译不了的C#代码。

EF Core框架只提供了”将C#表达式翻译成抽象语法树”等基础的功能。

由于不同数据库的语法不尽相同,因此具体的把抽象语法树翻译为SQL语句的工作是由各个数据库的EF Core数据库提供程序来完成的。

这样就存在C#语句可以被翻译为SQL Server数据库中的SQL语句,而无法被翻译为MySQL数据库中的SQL语句的情况。

在使用EF Core的时候,一旦遇到EF Core无法支持的C#语法,可以尝试变换不同的写法直到能够被其支持为止。

如果一条C#语句无论怎么些都不被EF Core支持,EF Core可以直接编写原生SQL语句的。

2. 既生瑜IEnumerable,何生IQueryable

我们已经知道,可以使用LINQ中的Where等方法对普通集合进行处理。

比如下面的C#代码可以把int数组中大于10的数据取出来:

C#
 int[] nums = [1, 23, 546, 1645, 15];

 IEnumerable<int> items = nums.Where(n=> n > 10);

在Where方法上右击,单击转到定义按钮(或F12),可以看到,这里调用的Where方法是Enumerable类中的扩展方法,方法的声明如下:

C#
 public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, 
 Func<TSource, bool> predicate);

我们也可以在EF Core的DbSet类型上调用Where之类的方法进行数据的筛选。

比如下面的代码可以把价格高于1.1元的书筛选出来:

C#
  using BookDbContext TDC = new();
  IQueryable<Book> books = TDC.Books.Where(b => b.Price > 1.1);

查看这里调用的Where方法的声明,我们会发现它是定义在Queryable类中的扩展方法,方法的声明如下:

C#
public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, 
Expression<Func<TSource, bool>> predicate);

这个Where方法是一个IQueryable接口<TSource>类型的扩展方法,返回值是IQueryable<TSource>类型。

IQueryable其实就是一个继承了IEnumerable接口的接口,如下所示:

C#
 public interface IQueryable<out T> : IEnumerable<T>, IEnumerable, IQueryable
 {
 }

这就奇怪了,IQueryable接口就是继承自IEnumerable接口的;

Queryable类中的Where方法除了参数和返回值的类型是IQueryable,其他用法和Enumerable类中的Where方法没有什么不同。

那微软为什么还要推出一个IQueryable接口以及一个新的Where方法呢?

对于普通集合,Where方法会在内存中对每条数据进行过滤。

而EF Core如果也把全部数据都在内存中进行过滤的话,我们就需要把一张数据库表中的所有数据都加载到内存中。

然后通过条件判断逐条进行过滤,如果数据量非常大,就会有性能问题。

因此EF Core中的Where实现必须有一套”把Where条件转换为SQL语句“的机制,让数据的筛选在数据库服务器上执行。

使用SQL语句在数据库服务器上完成数据筛选的过程叫作”服务器端评估”;

把数据首先加载到应用程序的内存中,然后在内存中进行数据筛选的过程叫作”客户端评估”。

很显然,对于大部分情况来讲,”客户端评估”性能比较低,我们要尽量避免”客户端评估”。

Enumerable类中定义的供普通集合用的Where等方法都是”客户端评估”,因此微软创造了IQueryable类型,并且在Queryable等类中定义了和Enumerable类中类似的Where等方法。

Queryable中定义的Where方法则支持把LINQ查询转换为SQL语句。

因此,在使用EF Core的时候,为了避免”客户端评估”,我们要尽量调用IQueryable版本的方法,而不是直接调用IEnumerable版本的方法。

下面举两个例子来说明。

IQueryable版数据查询
C#
 using BookDbContext TDC = new();
 IQueryable<Book> books = TDC.Books.Where(b => b.Price > 1.1);
 foreach (var item in books.Where(b=>b.Price>1.1))
 {
     await Console.Out.WriteLineAsync($"Id={item.Id},Title={item.Title}");
 }

上面的代码生成的SQL语句如下:

SQL
 SELECT [t].[Id], [t].[AuthorName], [t].[Price], [t].[PubTime], [t].[Title]
 FROM [T_Books] AS [t]
 WHERE [t].[Price] > 1.1000000000000001E0 AND [t].[Price] > 1.1000000000000001E0

可以看到,这里是EF Core在数据库服务器上用SQL语句进行的”服务器端评估”,因为books变量是IQueryable<Book>类型的,所以这里调用的是IQueryable版本的Where方法。

接下来,我们对代码稍微进行改变,把books变量的类型从IQueryable<Book>改为IEnumerable<Book>,其他代码不做任何改变。

IEnumerable版数据查询
C#
 using BookDbContext TDC = new();
 IEnumerable<Book> books = TDC.Books.Where(b => b.Price > 1.1);
 foreach (var item in books.Where(b=>b.Price>1.1))
 {
     await Console.Out.WriteLineAsync($"Id={item.Id},Title={item.Title}");
 }

我们再查看生成的对应的SQL语句:

SQL
SELECT [t].[Id], [t].[AuthorName], [t].[Price], [t].[PubTime], [t].[Title]
FROM [T_Books] AS [t]
WHERE [t].[Price] > 1.1000000000000001E0

额… 我不知道是不是因为.NET8将IEnumerable升级了 也可以进行”服务器端评估”了。

但是,Queryable类中不仅定义了Where方法,还定义了Select、OrderBy、GroupBy、Min、Max等方法,这些方法和Enumerable类中定义的同名方法的用法几乎一模一样。

唯一不同的就是,它们都是”服务器端评估”的版本。

总之,在使用EF Core的时候,我们要尽量避免”客户端评估”,能用IQueryable<T>的地方就不要直接用IEnumerable<T>。

3. IQueryable的延迟执行

IQueryable不仅可以带来”服务器端评估”这个功能,而且提供了延迟执行的能力。

下面我们看这一段代码:

C#
using BookDbContext TDC = new();

IQueryable<Book> books = TDC.Books.Where(b => b.Price > 1.1);

Console.WriteLine(books);

这段代码只是查询价格大于1.1元的书,但是对于返回值没有遍历输出,我们对BookDbContext启用了日志输出代码执行的SQL语句。

输出如下:

从日志结果输出可以看出,上面的代码竟然没有执行SQL语句,而我们明明执行了Where方法进行数据的过滤查询。

接下来,我们把代码修改一下,遍历查询结果。

C#
  using BookDbContext TDC = new();
  await Console.Out.WriteLineAsync("1. Where之前");
  IQueryable<Book> books = TDC.Books.Where(b => b.Price > 1.1);

  await Console.Out.WriteLineAsync("2. 遍历IQueryable之后");

  foreach (var item in books.Where(b => b.Price > 1.1))
  {
      await Console.Out.WriteLineAsync($"Id={item.Id},Title={item.Title}");
  }
  await Console.Out.WriteLineAsync("3. 遍历IQueryable之后");

我们再观察上面程序的日志输出结果。

SQL
1. Where之前
dbug: 2024/5/7 23:20:18.073 CoreEventId.ShadowPropertyCreated[10600] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'StudentTeacher (Dictionary<string, object>).StudentsId' was created in shadow state because there are no eligible CLR members with a matching name.
dbug: 2024/5/7 23:20:18.079 CoreEventId.ShadowPropertyCreated[10600] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'StudentTeacher (Dictionary<string, object>).TeachersId' was created in shadow state because there are no eligible CLR members with a matching name.
dbug: 2024/5/7 23:20:18.079 CoreEventId.ShadowPropertyCreated[10600] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'Leave.ApperoverId' was created in shadow state because there are no eligible CLR members with a matching name.
dbug: 2024/5/7 23:20:18.079 CoreEventId.ShadowPropertyCreated[10600] (Microsoft.EntityFrameworkCore.Model.Validation)
      The property 'Leave.RequesterId' was created in shadow state because there are no eligible CLR members with a matching name.
dbug: 2024/5/7 23:20:18.272 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 8.0.3 initialized 'BookDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:8.0.3' with options: None
2. 遍历IQueryable之后
dbug: 2024/5/7 23:20:18.369 CoreEventId.QueryCompilationStarting[10111] (Microsoft.EntityFrameworkCore.Query)
      Compiling query expression:
      'DbSet<Book>()
          .Where(b => b.Price > 1.1)
          .Where(b => b.Price > 1.1)'
dbug: 2024/5/7 23:20:18.622 CoreEventId.QueryExecutionPlanned[10107] (Microsoft.EntityFrameworkCore.Query)
      Generated query execution expression:
      'queryContext => new SingleQueryingEnumerable<Book>(
          (RelationalQueryContext)queryContext,
          RelationalCommandCache.QueryExpression(
              Projection Mapping:
                  EmptyProjectionMember -> Dictionary<IProperty, int> { [Property: Book.Id (long) Required PK AfterSave:Throw ValueGenerated.OnAdd MaxLength(50), 0], [Property: Book.AuthorName (string) Required MaxLength(20), 1], [Property: Book.Price (double) Required, 2], [Property: Book.PubTime (DateTime) Required, 3], [Property: Book.Title (string) Required MaxLength(50), 4] }
              SELECT t.Id, t.AuthorName, t.Price, t.PubTime, t.Title
              FROM T_Books AS t
              WHERE (t.Price > 1.1000000000000001E0) && (t.Price > 1.1000000000000001E0)),
          null,
          Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, Book>,
          demo.BookDbContext,
          False,
          False,
          True
      )'
dbug: 2024/5/7 23:20:18.659 RelationalEventId.ConnectionCreating[20005] (Microsoft.EntityFrameworkCore.Database.Connection)
      Creating DbConnection.
dbug: 2024/5/7 23:20:18.712 RelationalEventId.ConnectionCreated[20006] (Microsoft.EntityFrameworkCore.Database.Connection)
      Created DbConnection. (46ms).
dbug: 2024/5/7 23:20:18.727 RelationalEventId.ConnectionOpening[20000] (Microsoft.EntityFrameworkCore.Database.Connection)
      Opening connection to database 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01'.
dbug: 2024/5/7 23:20:19.316 RelationalEventId.ConnectionOpened[20001] (Microsoft.EntityFrameworkCore.Database.Connection)
      Opened connection to database 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01'.
dbug: 2024/5/7 23:20:19.324 RelationalEventId.CommandCreating[20103] (Microsoft.EntityFrameworkCore.Database.Command)
      Creating DbCommand for 'ExecuteReader'.
dbug: 2024/5/7 23:20:19.335 RelationalEventId.CommandCreated[20104] (Microsoft.EntityFrameworkCore.Database.Command)
      Created DbCommand for 'ExecuteReader' (8ms).
dbug: 2024/5/7 23:20:19.342 RelationalEventId.CommandInitialized[20106] (Microsoft.EntityFrameworkCore.Database.Command)
      Initialized DbCommand for 'ExecuteReader' (17ms).
dbug: 2024/5/7 23:20:19.357 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
      Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [t].[Id], [t].[AuthorName], [t].[Price], [t].[PubTime], [t].[Title]
      FROM [T_Books] AS [t]
      WHERE [t].[Price] > 1.1000000000000001E0 AND [t].[Price] > 1.1000000000000001E0
info: 2024/5/7 23:20:19.420 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (72ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [t].[Id], [t].[AuthorName], [t].[Price], [t].[PubTime], [t].[Title]
      FROM [T_Books] AS [t]
      WHERE [t].[Price] > 1.1000000000000001E0 AND [t].[Price] > 1.1000000000000001E0
dbug: 2024/5/7 23:20:19.482 CoreEventId.StartedTracking[10806] (Microsoft.EntityFrameworkCore.ChangeTracking)
      Context 'BookDbContext' started tracking 'Book' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values.
Id=4,Title=WIJQE
dbug: 2024/5/7 23:20:19.525 CoreEventId.StartedTracking[10806] (Microsoft.EntityFrameworkCore.ChangeTracking)
      Context 'BookDbContext' started tracking 'Book' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values.
Id=5,Title=AWEQA
dbug: 2024/5/7 23:20:19.527 CoreEventId.StartedTracking[10806] (Microsoft.EntityFrameworkCore.ChangeTracking)
      Context 'BookDbContext' started tracking 'Book' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values.
Id=9,Title=WIJQE
dbug: 2024/5/7 23:20:19.529 CoreEventId.StartedTracking[10806] (Microsoft.EntityFrameworkCore.ChangeTracking)
      Context 'BookDbContext' started tracking 'Book' entity. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see key values.
Id=10,Title=AWEQA
dbug: 2024/5/7 23:20:19.534 RelationalEventId.DataReaderClosing[20301] (Microsoft.EntityFrameworkCore.Database.Command)
      Closing data reader to 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01'.
dbug: 2024/5/7 23:20:19.540 RelationalEventId.DataReaderDisposing[20300] (Microsoft.EntityFrameworkCore.Database.Command)
      A data reader for 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01' is being disposed after spending 114ms reading results.
dbug: 2024/5/7 23:20:19.544 RelationalEventId.ConnectionClosing[20002] (Microsoft.EntityFrameworkCore.Database.Connection)
      Closing connection to database 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01'.
dbug: 2024/5/7 23:20:19.549 RelationalEventId.ConnectionClosed[20003] (Microsoft.EntityFrameworkCore.Database.Connection)
      Closed connection to database 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01' (5ms).
3. 遍历IQueryable之后
dbug: 2024/5/7 23:20:19.553 CoreEventId.ContextDisposed[10407] (Microsoft.EntityFrameworkCore.Infrastructure)
      'BookDbContext' disposed.
dbug: 2024/5/7 23:20:19.558 RelationalEventId.ConnectionDisposing[20007] (Microsoft.EntityFrameworkCore.Database.Connection)
      Disposing connection to database 'demo1' on server 'DESKTOP-8DO7T4C\SQLEXPRESS01'.
dbug: 2024/5/7 23:20:19.562 RelationalEventId.ConnectionDisposed[20008] (Microsoft.EntityFrameworkCore.Database.Connection)
      Disposed connection to database '' on server '' (2ms).

请仔细观察上面输出结果的截图中的SQL语句、”2. 遍历IQueryable之前”和”3. 遍历IQueryable之后”的输出顺序。

按照C#中的代码,Where调用的代码在”2. 遍历IQueryable之前”的前面执行,但是在执行结果中,SQL语句反而在”2. 遍历IQueryable之前”的后面执行,这是为什么呢?

其实,IQueryable只是代表”可以放到数据库服务器中执行的查询”,它没有立即执行,只是”可以被执行”而已。

这一点其实可以从IQueryable类型名的英文含义看出来,”IQueryable”的意思是”可查询的”,可以查询,但是没有执行查询,查询的执行被延迟了。

那么IQueryable什么时候才会执行查询呢?

一个原则就是:对于IQueryable接口,调用”非立即执行”方法的时候不会执行查询,而调用”立即执行”方法的时候则会立即执行查询。

除了遍历IQueryable操作之外,还有ToArray、ToList、Min、Max、Count等立即执行方法;

GroupBy、OrderBy、Include、Skip、Take等方法是非立即执行方法。

判断一个方法是否是立即执行方法的简单方式是:

一个方法的返回值类型如果是IQueryable类型,这个方法一般就是非立即执行方法,否则这个方法就是立即执行方法。

EF Core为什么要实现”IQueryable延迟执行”这样复杂的机制呢?

因为我们可以先使用IQueryable拼接出复杂的查询条件,再去执行查询。

比如,下面的代码中定义了一个方法,这个方法用来根据给定的关键字searchWords查询匹配的书;

如果searchAll参数是true,则书名或者作者名中含义给定的search Words的都匹配,否则只匹配书名;

如果orderByPrice参数为true,则把查询结果按照价格排序,否则就自然排序;

upperPrice参数代表价格上限。

C#
  void QueryBooks(string searchWords,bool searchAll,bool orderByPrice,double upperPrice)
  {
      using BookDbContext bookDb = new BookDbContext();

      IQueryable<Book> books = bookDb.Books.Where(b=>b.Price<=upperPrice);

      if (searchAll)//匹配书名或作者名
      {
          books = books.Where(b => b.Title!.Contains(searchWords) || b.AuthorName!.Contains(searchWords));
      }
      else //只匹配书名
      {
          books = books.Where(b => b.Title!.Contains(searchWords));
      }
      if (orderByPrice) //按照价格排序
      {
          books = books.OrderBy(b => b.Price);
      }
      foreach (var book in books)
      {
          Console.WriteLine($"{book.Id},{book.Title},{book.Price},{book.AuthorName}");
      }
  }

以上代码中,我们根据用户传递的参数对bookDb.Books.Where(b=>b.Price<=upperPrice)返回的IQueryable<Book>对象进一步使用Where、OrderBy等方法进行过滤,只有到了使用foreach遍历books的时候才会执行查询。

我们编写如下代码调用QueryBooks方法:

C#
QueryBooks("itachi",true,true,30);

查看上面的代码执行的SQL语句:

可以看到,我们对IQueryable的拼接过程中并没有执行SQL语句,只有在最后遍历IQueryable的时候才执行SQL语句,而且这个SQL语句把我们设定的两个Where过滤条件合并成了一个Where条件,SQL语句中也包含我们设置的Order By语句。

我们再尝试调用QueryBooks方法

C#
QueryBooks("itachi",false,false,18);

可以看到,由于传递的参数不同,我们拼接完成的IQueryable不同,因此最后执行查询的时候生成的SQL语句也不同。

如果不使用EF Core而使用SQL语句实现”根据参数不同执行不同SQL语句”的逻辑。

我们就需要手动拼接SQL语句,这个过程是很麻烦的,而EF Core把”动态拼接生成查询逻辑”变得非常简单。

总之,IQueryable代表一个对数据库中的数据进行查询的逻辑,这个查询是一个延迟查询。

我们可以调用非立即执行方法向IQueryable中添加查询逻辑,当执行立即执行方法的时候才真正生成SQL语句执行查询。

4. IQueryable的复用

IQueryable是一个待查询的逻辑,因此它是可以被重复使用的,如以下代码所示。

C#
  using BookDbContext bookDbContext = new BookDbContext();
  IQueryable<Book>books = bookDbContext.Books.Where(b=>b.Price>=8);
  Console.WriteLine(books.Count());
  Console.WriteLine(books.Max(b=>b.Price));
  foreach (var book in books.Where(b=>b.PubTime.Year>2008))
  {
      await Console.Out.WriteLineAsync(book.Title);
  }

上面的代码首先创建了一个获取价格大于等于8元的书的IQueryable对象,然后调用Count方法执行IQueryable对象获取满足条件的数据条数,接下来调用Max方法执行IQueryable对象获取满足条件的最高的价格,最后对于books的变量调用Where方法进一步过滤获取2008年之后发布的书名。

上面的代码会生成如下SQL语句:

SQL
SELECT COUNT(*)FROM T_Books AS t WHERE t.Price >= 8.0E0)
SELECT MAX(t.Price)FROM T_Books AS t WHERE t.Price >= 8.0E0)
SELECT t.Id, t.AuthorName, t.Price, t.PubTime, t.Title FROM T_Books AS t WHERE (t.Price >= 8.0E0) && (DATEPART(year, t.PubTime) > 2008)),

可以看到,由于Count、Max和foreach都是立即执行操作,因此对IQueryable的这三个操作都各自执行了相应的查询逻辑。

5. EF Core分页查询

如果数据库表中的数据比较多,在把查询结果展现到前端的时候,我们通常要对查询结果进行分页展示

在实现这样的分页展示效果时,程序需要实现从数据库中分页获取数据的方法,比如每页显示10条数据,如果要显示第三页(页码从1开始)的数据,我们就要从第20条开始的10条数据。

在学习LINQ的时候,我们知道可以使用Skip(n)方法实现”跳过n条数据”,可以使用Take(n)方法实现”取最多n条数据”,这两个方法配合起来就可以分页获取数据,比如Skip(3).Take(8)就是”获取从第3条开始的最多8条数据”。

在EF Core中也同样支持这两个方法。

提示:

在使用分页查询的时候有一个问题需要注意,那就是尽量显式地指定排序规则,因为如果不指定排序规则,那么数据库的查询计划对于数据的排序可能是不确定的。

在实现分页的时候,为了显示页码条,我们需要知道满足条件的数据的总条数是多少。

可以使用IQueryable的复用,分别实现数据的分页查询和获取满足条件数据总条数这两个查询操作。

了解了上面的基础知识之后,我们开始实现分页。

下面封装一个方法,用来输出标题不包含”张三”的第n页(页码从1开始)的内容,并且输出总页数,每页最多显示5条数据。

如下面代码所示:

C#
   static void OutputPage(int pageIndex,int pageSize)
   {
       using BookDbContext dbContext = new BookDbContext();

       IQueryable<Book> books = dbContext.Books.Where(b=>!b.Title!.Contains("张三"));

       //总条数
       long count = books.LongCount();

       //页数
       long pageCount = (long)Math.Ceiling(count*1.0/pageSize);

       Console.WriteLine("页数"+pageCount);

       var pagedBooks = books.Skip((pageIndex-1)*pageSize).Take(pageSize);

       foreach (var book in pagedBooks)
       {
           Console.WriteLine(book.Id+","+book.Title);
       }

   }

OutputPage方法的pageIndex参数代表页码,pageSize参数代表页大小。

在OutputPage方法中,我们首先把查询规则books创建出来,然后使用LongCount方法获取满足条件的数据的总条数。

使用count*/pageSize可以计算出数据总页数,考虑到有可能最后一页不满,因此我们用Ceiling方法获得整数类型的总页数。

由于pageIndex的序号是从1开始的,因此我们要使用Skip方法跳过(pageIndex-1)*pageSize条数据,再获取最多pageSize条数据就可以获取正确的分页数据了。

接下来我们用以下代码测试输出第一页和第二页的数据

C#
 static async Task Main(string[] args)
 {

     OutputPage(1, 10);
     await Console.Out.WriteLineAsync("*******************");
     OutputPage(2, 5);


 }

程序运行结果如图:

6. IQueryable的底层运行

我们知道,ADO.NET中有DataReader和DataTable两种读取数据库查询结果的方式。

如果查询结果有很多条数据,DataTable会把所有数据一次性从数据库服务器加载到客户端内存中,而DataReader则会分批从数据库服务器读取数据。

DataReader的优点是客户端内存占用小,缺点是如果遍历读取数据并进行处理的过程缓慢的话,会导致程序占用数据库连接的时间较长,从而降低数据库服务器的并发连接能力;

DataTable的优点是数据库被快速地加载到了客户端内存中,因此不会较长时间地占用数据库连接,缺点是如果数据量大的话,客户端的内存占用会比较大。

IQueryable遍历读取数据的时候,用的是类似DataReader的方式还是类似DataTable的方式呢?

我们在T_Books表中插入几十万条数据,然后使用代码遍历IQueryable。

C#
  using BookDbContext db = new();
  for (int i = 0; i <= 500000; i++)
  {
          var books = db.Add(
          new Book { 
          AuthorName = $"aid{i}",
          Price = i+0.1,
          Title = Guid.NewGuid().ToString()
      });
  }
 await db.SaveChangesAsync();
 
  IQueryable<Book> books = bookDbContext.Books.Where(b => b.Id > 2);
  foreach (Book book in books)
  {
      await Console.Out.WriteLineAsync(book.Id + "," + book.Title);
  }

在遍历执行的过程中,如果我们关闭SQL Server服务器或者断开服务器的网络,程序就会出错,这说明IQueryable是用类似DataReader的方式读取查询结果的。

其实IQueryable内部的遍历就是在调用DataReader进行数据读取。

因此,在遍历IQueryable的过程中,它需要占用一个数据库连接。

如果需要一次性把所有数据都读取到客户端内存中,可以用IQueryable的ToArray、ToList、ToListAsync等方法。

如以下代码所示,读取前50万条数据,然后使用ToListAsync把查询结果一次性地读取到内存中,再去遍历输出数据。

C#
 using BookDbContext bookDbContext = new BookDbContext();
 var books = bookDbContext.Books.Take(500000).ToListAsync();
 foreach (Book book in await books)
 {
     await Console.Out.WriteLineAsync(book.Id + "," + book.Title);
 }

在遍历数据的过程中,如果我们关闭SQL Server服务器或断开服务器的网络,程序是可以正常运行的,这说明ToListAsync方法把查询结果加载到客户端内存中了。

除非遍历IQueryable并且进行数据处理的过程很耗时,否则一般不需要一次性把查询结果读取到内存中。

但是在以下场景下,一次性把查询结果读取到内存中就有必要了。

  • 场景一:方法需要返回查询结果

如果方法需要返回查询结果,并且在方法里销毁上下文的话,方法是不能返回IQueryable的。

例如实现如下代码所示的方法,用于查询Id>5的书,再返回查询结果。

C#
 static IQueryable<Book> QueryBooks()
 {
     using BookDbContext db = new();
     return db.Books.Where(b => b.Id > 5);
 }
 
 //调用
 foreach(var b in QueryBooks())
 {
     await Console.Out.WriteLineAsync(b.Title);
 }

上面的代码运行后,程序会抛出”Cannot access a disposed context instance”异常,因为在QueryBooks方法中销毁了BookDbContext对象,而遍历IQueryable的时候需要上下文从数据库中加载数据,因此程序就报错了。

如果在QueryBooks方法中,采用ToList等方法把数据一次性加载到内存中就可以了,如以下代码所示

C#
 static IEnumerable<Book> QueryBooks()
 {
     using BookDbContext db = new();
     return db.Books.Where(b => b.Id > 5)
         .ToArray();
 }
  • 场景二:多个IQueryable的遍历嵌套

在遍历一个IQueryable的时候,我们可能需要同时遍历另外一个IQueryable。

IQueryable底层是使用DataReader从数据库服务器读取查询结果的,而很多数据库是不支持多个DataReader同时执行的。

我们使用SQL Server数据库,实现两个IQueryable一起遍历,如以下代码所示。

C#
 using BookDbContext db = new();
 var books = db.Books.Where(b => b.Id > 1);
 foreach (var b in books)
 {
     await Console.Out.WriteLineAsync(b.Id+","+b.Title);
     foreach (var a in db.Users)
     {
         await Console.Out.WriteLineAsync(a.Id.ToString());
     }
 }

上面的程序执行的时候会报错”There is already an open DataReader associated with this Connection Which must be closed first.”,这个错误就是因为两个foreach循环都在遍历IQueryable,导致同时有两个DataReader在执行。

虽然可以在连接字符串中通过设置MultipleActiveResultSets=true开启”允许多个DataReader执行”,但是只有SQL Server支持MultipleActiveResultSets选项,其他数据库有可能不支持。

因此作者建议采用”把数据一次性加载到内存”以改造其中一个循环的方式来解决,比如只要把var books = db.Books.Where(b=>b.Id>1)改为.ToList()就可以了。

综上所述,在进行日常开发的时候,我们直接遍历IQueryable即可。

但是如果方法需要返回查询结果或者需要多个查询嵌套执行,就要考虑把数据一次性加载到内存的方式,当然一次性查询的数据不能太多,以免造成过高的内存损耗。

7. EF Core中的异步方法

我们知道,异步编程通常能够提升系统的吞吐量,因此如果实现某个功能的方法既有同步方法又有异步方法,我们一般应该优先使用异步方法。

保存上下文中数据变更的方法既有同步的SaveChanges,也有异步的SaveChangesAsync,同样EF Core中其他的很多操作也都既有同步方法又有异步方法。

这些异步方法大部分是定义在Microsoft.EntityFrameworkCore命名空间代码下的EntityFrameworkQueryableExtensions等类中的扩展方法,因此使用这些方法之前,请在代码中添加对Microsoft.EntityFrameworkCore命名空间的引用。

IQueryable的异步方法有AllAsync、AnyAsync、AverageAsync、ContainsAsync、CountAsync、CountAsync、FirstAsync、FirstOrDefaultAsync、ForEachAsync、LongCountAsync、MaxAsync、MinAsync、SingleAsync、SingleOrDefaultAsync、SumAsync等。

IQueryable的这些异步的扩展方法都是立即执行方法,而GroupBy、OrderBy、Join、Where等非立即执行方法则没有对应的异步方法。

因为这些非立即执行方法并没有实际执行SQL语句,并不是消耗I/O的操作,因此不需要定义这些方法的异步版本。

8. 如何执行原生SQL语句

尽管EF Core已经非常强大,但是仍然存在无法被写成标准EF Core调用方法的SQL语句,因此在少数场景下,我们仍然需要在EF Core中执行原生SQL语句。

执行原生SQL语句有SQL非查询语句、实体类SQL查询语句、任意SQL查询语句等几种用法。

8.1 执行SQL非查询语句

我们可以通过db.Database.ExecuteSqlInterpolated或者异步的db.Database.ExecuteSqlInterpolatedAsync方法执行原生的SQL非查询语句。

下面举一个例子:

insert into …select语法是一种”先查询出数据,再把查询结果插入数据库表”的语法。

如以下代码所示,要求用户输入最低价格和姓名,然后执行原生SQL语句完成把查询结果再次插入数据库表的操作。

C#
  using BookDbContext db = new();
  
  await Console.Out.WriteLineAsync("请输入最低价格");
  
  double price = double.Parse(Console.ReadLine()!);
  
  await Console.Out.WriteLineAsync("请输入姓名");
  
  string aName = Console.ReadLine()!;
  
  int rows = await db.Database.ExecuteSqlInterpolatedAsync(
      @$"insert into T_Books(Title,PubTime,Price,AuthorName)
             select Title,PubTime,Price,{aName} from T_Books where Price>{price}"
       );

可以看到,ExecuteSqlInterpolatedAsync中使用{price}这样的内插值方式为SQL语句提供参数值。

大家可能会有疑惑,这样字符串内插的方式不会有SQL注入攻击漏洞吗?

答案是:不会有。

查看上面的操作生成的SQL语句,如下:

SQL
insert into T_Books(Title,PubTime,Price,AuthorName)
select Title,PubTime,Price,@p0 from T_Books where Price>@p1

可以看到,我们编写的内插变量{aName}、{price}被翻译成了@p0、@p1这样的参数,而不是简单的字符串拼接,因此这样的操作不会有SQL注入攻击漏洞。

这是什么原理呢?

因为ExecuteSqlInterpolatedAsync的参数是FormattableString类型,当一个C#的字符串内插表达式被赋值给FormattableString类型变量的时候,编译器会把字符串内插表达式的格式字符串、参数值等信息构造为一个FormattableString对象。

FornattableString对象中包含插值格式的字符串以及每个参数的值,这样ExecuteSqlInterpolatedAsync方法就可以根据FormattableString对象的信息去构建参数化查询SQL语句。

除了ExecuteSqlInterpolated、ExecuteSqlInterpolatedAsync方法之外,EF Core的ExecuteSqlRaw、ExecuteSqlRawAsync等方法也可以执行原生SQL语句,但使用这两个方法需要开发人员自己处理查询参数等问题,因此不推荐使用。

8.2 执行实体类SQL查询语句

如果我们要执行的SQL语句是一个查询语句,并且查询的结果也能对应一个实体类,就可以调用对应实体类的DbSet的FromSqlInterpolated方法执行一个SQL查询语句,方法的参数是FormattableString类型,因此同样可以使用字符串内插传递参数。

编写一个程序要求用户输入一个年份,然后使用SQL语句获取出版年份大于指定年份的书,并且使用order by newid()这个SQL Server的特有用法进行随机排序。

如以下代码所示:

C#
   using BookDbContext db = new();
   await Console.Out.WriteLineAsync("请输入年份");
   int year = int.Parse(Console.ReadLine()!);
   IQueryable<Book> books = db.Books.FromSqlInterpolated(
       $@"select * from T_Books where DatePart(year,PubTime)>{year} order by newid()"
       );

   foreach (var b in books)
   {
       await Console.Out.WriteLineAsync(b.Title);
   }

FromSqlInterpolated方法的返回值是IQueryable类型的,因此我们可以在实际执行IQueryable之前,对IQueryable进行进一步的处理。

如以下代码所示:对IQueryable执行Skip和Take方法进行分页查询。

C#
   using BookDbContext db = new();
   await Console.Out.WriteLineAsync("请输入年份");
   int year = int.Parse(Console.ReadLine()!);
   IQueryable<Book> books = db.Books.FromSqlInterpolated(
       $@"select * from T_Books where DatePart(year,PubTime)>{year}"
       );

   foreach (var b in books.Skip(3).Take(6))
   {
       await Console.Out.WriteLineAsync(b.Title);
   }

上面代码运行后所生成的SQL语句如下:

SQL
      SELECT [d].[Id], [d].[AuthorName], [d].[Price], [d].[PubTime], [d].[Title]
      FROM (
          select * from T_Books where DatePart(year,PubTime)>@p0
      ) AS [d]
      ORDER BY (SELECT 1)
      OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

可以看到,我们编写的原生SQL语句被翻译成了子查询,这个子查询被放到了使用OFFSET实现的分页查询语句中。

由于IQueryable这种强大的”查询再加工”能力,我们可以把只能用原生SQL语句写的逻辑用FromSqlInterpolated执行,然后把分页、分组

二次过滤、排序、Include等其他逻辑仍然用EF Core的标准操作实现。

FromSqlInterpolated的使用有如下局限性。

  • SQL查询必须返回实体类型对应数据库表的所有列。
  • 查询结果集中的列名必须与属性映射到的列名匹配。
  • SQL语句只能进行单表查询,不能使用Join语句进行关联查询,但是可以在查询后面使用Include方法进行关联数据的获取。
8.3 执行任意SQL查询语句

FromSqlInterpolated 只能执行单实体类的查询,但是在实现报表查询的时候,SQL语句通常是非常复杂的,不仅要多表关联,而且返回的查询结果一般也都不会和一个实体类完整对应,因此我们需要一种执行任意SQL查询语句的方式。

EF Core中允许把一个视图或者一个存储过程映射为实体类,因此我们可以把复杂的查询语句写成视图或者存储过程,然后声明对应的实体类,并且在上下文中配置对应的DbSet属性。

不过,目前大部分公司都不推荐编写存储过程,而推荐创建视图。

但是项目的报表等复杂查询通常很多,因此对应的视图也会很多,我们就需要在上下文类中配置很多本质上不是实体类的”实体类”,这会造成项目中”实体类”的膨胀,不利于项目的管理。

我们可以通过dbcontext.Database.GetDbConnection获得一个数据库连接,然后就可以直接调用ADO.NET的相关方法执行任意的SQL语句了。

由于ADO.NET是比较底层的API,使用起来非常麻烦,可以使用Dapper等轻量级的ORM工具简化对ADO.NET的调用。

软件工程界有一句经典的话”没有银弹”,但是它并不能解决所有问题,因此在做项目开发的时候,如果遇到使用普通的EF Core操作无法完成的地方,我们可以跳过EF Core而编写原生的SQL语句来完成。

9. 怎么知道实体类变化了

当我们修改从上下文中查询出来的对象并且调用SaveChangs方法时,EF Core会检测对象的状态改变,然后把变化保存到数据库中。

但是实体类没有实现属性值改变的通知机制,EF Core是如何检测到实体类的这些变化的呢?

EF Core默认采用”快照更改跟踪”实现实体类改变的检测。

在上下文首次跟踪一个实体类的时候,EF Core会创建这个实体类的快照,当执行SavaChanges等方法的时候,EF Core将会把存储的快照中的值与实体类的当前值进行比较,以确定哪些属性值被更改了。

EF Core还支持”通知实体类””更改跟踪代理”等检测实体类改变的机制,但是这些机制用起来比较麻烦,带来的好处也不明显,因此我们一般都用默认的”快照更改跟踪”机制。

实体类的改变并不只有”属性值改变”这样一种情况,实体类被删除等也属于改变。

实体类有如下五种可能的改变:

  • 已添加(Added):上下文正在跟踪此实体类,但数据库中尚不存在此实体类。
  • 未改变(Unchanged):上下文正在跟踪此实体类,此实体类存在于数据库中,属性和数据库中一致,未发生改变
  • 已修改(Modified):上下文正在跟踪此实体类,此实体类存在于数据库中,并且七部分属性值已被修改。
  • 已删除(Deleted):上下文正在跟踪此实体类,此实体类存在于数据库中,并且其调用SavaChanges时要从数据库中删除对应数据。
  • 分离(Detached):上下文未跟踪该实体类。

对于不同状态中的实体类,执行SavaChanges的时候,EF Core会执行如下不同的操作。

  • 对于分离和未改变的实体类,SavaChanges会忽略它们。
  • 对于已添加的实体类,SaveChanges把它们插入数据库。
  • 对于已修改的实体类,SaveChanges把对它们的修改更新到数据库
  • 对于已删除的实体类,SavaChanges把它们从数据库中删除。

我们可以使用上下文的Entry方法获得一个实体类在EF Core中的跟踪信息对象EntityEntry。

EntityEntry类的State属性代表实体类的状态,而通过DebugView.LongView属性我们可以看到实体类的状态变化信息。

下面使用代码进行演示:

C#
 using BookDbContext bookDbContext = new BookDbContext();

 Book[] books = [.. bookDbContext.Books.Take(3)];

 Book b1 = books[0];
 Book b2 = books[1];
 Book b3 = books[2];
 Book b4 = new Book { AuthorName = "aksx", Price = 16.5, Title = "akkzz" };

 //State
 Book b5 = new Book { AuthorName = "az", Price = 1231.1, Title = "我比狗困" };

 //Modified
 b1.Title = "阿里九九";

 //Delete
 bookDbContext.Remove(b3);

 //Add
 bookDbContext.Add(b4);



 //Entry
 EntityEntry e1 = bookDbContext.Entry(b1);
 EntityEntry e2 = bookDbContext.Entry(b2);
 EntityEntry e3 = bookDbContext.Entry(b3);
 EntityEntry e4 = bookDbContext.Entry(b4);
 EntityEntry e5 = bookDbContext.Entry(b5);

 //打印
 await Console.Out.WriteLineAsync("b1.State:  "+e1.State);
 await Console.Out.WriteLineAsync("b1.DebugView:  "+e1.DebugView.LongView);
 await Console.Out.WriteLineAsync("b2.State:  "+e2.State);
 await Console.Out.WriteLineAsync("b3.State:  "+e3.State);
 await Console.Out.WriteLineAsync("b4.State:  "+e4.State);
 await Console.Out.WriteLineAsync("b5.State:  "+e5.State);

运行结果:

从程序运行结果可以看出来,b1对象由于被修改了,因此状态是”Modified”,而且从DebugView输出中的”Titel:’阿里九九’ Modified Originally ‘KUAC'”,这句话可以看出,b1的Title属性的旧值是”KUAC”;

b2对象被从对象被从数据库中查询出来后没有任何修改,因此状态是”Unchanged”;

b3对象被使用Remove方法标记删除,因此状态是”Deleted”;

b4、b5都是新创建的对象,由于b4通过Add方法被添加到上下文,因此b4的状态是”Added”,而b5这个新创建的对象没有通过任何形式和上下文产生跟踪关系,因此b5的状态是”Detached”。

由此可见,上下文会跟踪实体类的状态,在执行SaveChanges的时候,EF Core会根据实体类状态的不同,生成对应的Update、Delete、Insert等SQL语句,从而把内存中实体类的变化同步到数据库中。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部