ASP.NET Core学习笔记-2:EF Core 进阶知识

本文最后更新于:2024年11月2日 下午

EF Core 高级操作

IEnumerable 与 IQueryable

可以直接把IQueryable当成 EF Core 特供版IEnumerable(前者继承后者),它们的区别是:Enumerable类中定义的供普通集合用的Where等方法都是“客户端评估”,而Queryable类中定义的供DbSet等类用的Where等方法是“服务器端评估”。
那么,“客户端评估”与“服务器端评估”又有什么不同呢?使用 SQL 语句在数据库服务器上完成数据筛选的过程叫作“服务器端评估”;把数据首先加载到应用程序的内存中,然后在内存中进行数据筛选的过程叫作“客户端评估”。因此对大部分情况而言,“客户端评估”的性能是比较低的,所以应该避免。
IQueryableLINQ一样,对于非立即方法,都是延迟执行的。

“判断一个方法是否是立即执行的简单方式是:一个方法的返回值类型如果是IQueryable类型,这个方法一般就是非立即执行方法,否则这个方法就是立即执行方法。”

EF Core 分页展示

其实只要接触过LINQ,如何分页展示这个问题都能即答吧(确信:那就是使用Skip(n)Take(m)方法。以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void OutputPage(int pageIndex, int pageSize)
{
using TestDbContext ctx = new TestDbContext();
IQueryable<Book> books = ctx.Books.OrderBy(b => b.Id);
long count = books.LongCount(); // 总记录数
long pageCount = (long)Math.Ceiling(count / (double)pageSize); // 总页数
var currentPage = books.Skip((pageIndex - 1) * pageSize).Take(pageSize);
Console.BackgroundColor = ConsoleColor.DarkBlue;
Console.WriteLine($"第 {pageIndex} 页,共 {pageCount} 页,共 {count} 条记录");
foreach (var book in currentPage)
{
Console.WriteLine($"{book.Id} {book.Title} {book.Price} {book.PubTime}");
}
}

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

IQueryable 的底层运行

IQueryable是用类似DataReader的方式读取查询结果的(其实其内部的遍历就是在调用DataReader进行数据读取),所以在遍历IQueryable对象的过程,将数据库连接断开的话,程序就会报错。如果需要像DataTable那样,一次性将所有数据读入内存中,可以使用ToArray()ToList()等方法。
那么,什么时候需要将数据都读取到内存中呢?一是:方法需要返回查询结果的时候,因为IQueryable是依赖上下文的,如果上下文被Disposed了,依赖于此上下文的IQueryable也会随之失效。二是:多个IQueryable的遍历嵌套的时候,因为数据库可能不支持多个DataReader同时执行,所以需要考虑将一部分查询结果直接读入到内存中。

SQL Server 可以通过在连接字符串中设置MultipleActiveResultSets=true来开启“允许多个DataReader执行”。

如何执行原生 SQL 语句

如果需要执行 SQL 非查询语句,可以使用dbCtx.Database.ExecuteSqlInterpolated方法或其异步版本:await ctx.Database.ExecuteSqlInterpolated($"insert into T_Books (Title) Value ('{title}')")
如果需要执行 SQL 查询语句,并且查询的结果也能对应一个实体类,可以使用对应实体类的FromSqlInterpolated方法:ctx.Books.FromSqlInterpolated($"select * from T_Books where Title = '{title}'")
如果需要执行任意 SQL 查询语句,可以通过dbCtx.Database.GetDbConnection获得一个数据库连接,然后就可以直接调用 ADO.NET 的相关方法执行任意的 SQL 语句了。同时可以使用 Dapper 等轻量级的 ORM 工具来简化对 ADO.NET 的调用。

怎么知道实体类变化了

EF Core 默认采用:“快照跟踪改变”实现实体类改变的检测。在上下文首次跟踪一个实体类的时候,EF Core 会创建这个实体类的快照,当执行SaveChanges等方法时,EF Core 将会把存储的快照中的值与实体类的当前值进行比较,已确定哪些属性值被更改了。实体类有如下 5 种可能的状态:

  • 已添加(Added)
  • 未改变(Unchanged)
  • 已修改(Modified)
  • 已删除(Deleted)
  • 分离(Detached)
    他们的具体说明请参考书本 P130。
    使用上下文的Entry方法,向其传入一个实体类对象,就可以获得该对象在 EF Core 中的跟踪信息对象EntityEntryEntityEntry类的State属性存放实体类的状态,DebugView.LongView属性存放实体类的状态变化信息。

EF Core 性能优化

AsNoTracking

如果我们能够确认通过上下文查询出来的对象只是用来展示,不会发生状态改变(Added, Modified, Deleted),就可以在查询的时候使用AsNoTracking来禁用跟踪:Book[] books = ctx.Books.AsNoTracking().Take(3).ToArray();

实体类状态跟踪的妙用

我们在对一个实体类进行修改时,一般都是先查后改:

1
2
3
Book b1 = ctx.Books.Single(b => b.Id = 10);
b1.Title = "yzk";
ctx.SaveChanges();

上面的代码会执行两个SQL,一个Select,一个Update
那么我们是否可以不执行Select语句,直接进行Update呢?通过利用状态跟踪机制就可以了:

1
2
3
4
5
6
7
Book b1 = new Book { Id = 10 };
b1.Title = "yzk";
var entry1 = ctx.Entry(b1);
entry1.Property("Title").IsModified = true;
Book b2 = new Book { Id = 20 };
ctx.Entry(b2).State = EntityState.Deleted;
ctx.SaveChanges();

上面的代码中,对Id = 10的数据执行了一条Update语句,对Id = 20的数据执行了一条Delete语句。

“…不过以这种方式编写的代码可读性、可维护性都不强,而且使用不恰当有可能造成不容易发现的 bug。大部分情况下,采用这种技巧带来的性能提升也是微乎其微的…”,省流:大部分情况下不建议用。

Find 和 FindAsync 方法

这两个方法会先在上下文查找这个对象是否已经被跟踪,如果对象已经被跟踪,就直接返回被跟踪的对象,只有在本地没有找到这个对象时,才会执行数据库查询,而Single方法则是只执行数据库查询。(但是要注意的是Find方法拿到的可能并不是最新的数据)

EF Core 中高效地删除、更新数据

很遗憾,EF Core 暂不支持批量删除或更新数据(每个主键都执行一条SQL),只能通过第三方库来实现。能用的第三方库有:Zack.EFCore.Batch(杨中科老师自己写的库)、EFCore.BulkExtensions(流行的第三方库),用法可以参考它们各自的文档。

全局查询筛选器

常见应用场景:“软删除”,“多租户”。
“软删除”:系统中对数据执行删除操作,并不会真正从数据库中删除对应的数据,而是将这些数据标记为“已删除”(比如增加一列IsDeleted列,并把它的值设置为1)。然后在查询的时候,会加上Where IsDeleted != 1的条件,就达到了过滤被“删除”数据的目的。
但是每次执行查询都要自己手动加入这个条件会很麻烦,不过可以通过为实体类添加全局查询筛选器,让 EF Core 自动帮我们在查询时加上这个条件:

1
builder.HasQueryFilter(b => b.IsDeleted != 1)

如果一些查询需要忽略掉筛选器的话,可以在查询时使用IgnoryQueryFilters()ctx.Books.IgnoryQureyFilters().Where(b => b.Id > 10);

悲观并发控制

采用行锁、表锁等排他锁对资源进行锁定,确保同时只有一个使用者操作被锁定的资源。
但是不同类型的数据库对于悲观并发控制的实现差异很大,所以 EF Core 没有封装悲观并发控制,需要自行编写原生 SQL 语句。

乐观并发控制

允许多个使用者同时操作同一个资源,通过冲突的检测避免并发操作。
EF Core 内置了使用并发令牌实现的乐观并发控制,并发令牌列通常就是被并发操作影响的列。例如,在执行Update时,将某一列的旧值作为Where条件:Update T_Books Set Title = 'New Title', DataStamp = DataStamp + 1 Where DataStamp = '2',不难看出,在这个 SQL 中,DataStamp 列就是并发令牌列。
在 EF Core 中,要使用乐观并发控制,对并发修改的属性使用IsConcurrencyToken()即可将其设置成并发令牌列。如果上下文执行保存更改的时候抛出了DbUpdateConcurrencyException异常,则说明更新的时候出现了并发修改冲突

表达式树

“表达式树(Expression tree)是用树形数据结构来表示代码逻辑运算的技术,它让我们可以在运行时访问逻辑运算的结构。”
表达式树在 .NET 中对应Expression<TDelegate>类型,例如:Expression<Func<Book, bool>> e1 = b => b.Price > 5;

Expression 和 Func 的区别

  • Expression是“服务器端评估”,Func是“客户端评估”。
  • Expression对象存储了运算逻辑(使用Console.Write()将对象输出到控制台,就知道这里想表达什么了),它把运算逻辑保存成 AST(abstract syntax tree,抽象语法树),我们可以在运行时动态分析运算逻辑。

查看表达式树

可以使用可视化工具来查看表达式树,例如用开源调试查看器:Expression Tree Visualizer(目前仅在 VS2017/VS2019 上测试过)
也可以使用代码来查看表达式树,需要安装 NuGet 包 ExpressionTreeToString,然后编写代码即可:Console.WriteLine(e.ToString("Object notation", "C#"));Console.WriteLine(e.ToString("Factory Methods", "C#"));

表达式树的节点类型

  • ConstantExpression: 常量节点类型。
  • BinaryExpression: 二元运算符节点类型。
  • MemberExpression: 类成员访问操作节点类型。
  • MethodCallExpression: 方法调用操作节点类型。

通过代码动态构建表达式树

通过Expression类的ParameterMakeBinaryCallConstant等静态方法(构建表达式树的工厂方法),我们可以动态构建表达式树。
举个例子,将Expression<Func<Book, bool>> e1 = b => b.Price > 5;用工厂方法翻译一下就是:

1
2
3
4
5
ParameterExpression paramB =  Expression.Parameter(typeof(Book), "b");
MemberExpression exprLeft = Expression.MakeMemberAccess(paramB, typeof(Book).GetProperty("Price"));
ConstantExpression exprRight = Expression.Constant(5.0d, typeof(double));
BinaryExpression exprBody = Expression.MakeBinary(ExpressionType.GreaterThan, exprLeft, exprRight);
Expression<Func<Book, bool>> expr = Expression.Lambda<Func<Book, bool>>(exprBody, paramB);

当然,上面的代码还能更简单:

1
2
3
using static System.Linq.Expressions.Expression;
ParameterExpression paramB = Expression.Parameter(typeof(Book), "b");
var expr1 = Lambda(GreaterThan(MakeMemberAccess(paramB, typeof(Book).GetProperty("Price")), Constant(5.0d, typeof(double))), paramB);

了解了工厂方法的基本用法后,就可以着手构建动态表达式树了!下面给出一个根据传入的属性名与值来查找数据的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
IEnumerable<Book> QueryBooks(string propName, object value)
{
Type type = typeof(Book);
PropertyInfo propertyInfo = type.GetProperty(propName);
Type propertyType = propertyInfo.PropertyType;
var b = Parameter(type, "b");
Expression<Func<Book, bool>> expr;

if (propertyType.IsPrimitive) // 如果是 int、double 等基本数据类型
{
expr = Lambda<Func<Book, bool>>(Equal(MakeMemberAccess(b, propertyInfo), Constant(value)), b);
}
else // 如果是 string 等引用类型
{
expr = Lambda<Func<Book, bool>>(MakeBinary(ExpressionType.Equal, MakeMemberAccess(b, propertyInfo), Constant(value), false, propertyType.GetMethod("op_Equality")), b);
}

TestDbContext ctx = new TestDbContext();
return ctx.Books.Where(expr).ToArray();
}

在运行时动态设定 Select 查询出来的属性

此部分内容请参考书本 p152 页。(个人认为还蛮有用的)

何时使用表达式树?

“一般只有在编写不特定于某个实体类的通用框架代码的时候,由于无法在编译期确定要操作的类名、属性等,才需要编写动态构建表达式树的代码,否则为了提高代码的可读性和可维护性,我们要尽量避免动态构建表达式树。”
一种好的方式是:用分布构建IQueryable的方式来代替动态构建表达式树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Book[] QueryBooks(string title, double? lowerPrice, double? upperPrice, int orderByType)
{
using (TestDbContext ctx = new TestDbContext())
{
IQueryable<Book> books = ctx.Books;

if (!string.IsNullOrEmpty(title))
{
books = books.Where(b => b.Title.Contains(title));
}
if (lowerPrice != null) {
books = books.Where(b => b.Price >= lowerPrice);
}
if (upperPrice != null) {
books = books.Where(b => b.Price <= upperPrice);
}
if (orderByType != 0) {
books = orderByType switch
{
1 => books.OrderBy(b => b.Price),
2 => books.OrderByDescending(b => b.Price),
_ => books.OrderBy(b => b.Id),
};
}

return books.ToArray();
}
}

这里有一只爱丽丝

希望本文章能够帮到您~


ASP.NET Core学习笔记-2:EF Core 进阶知识
https://map1e-g.github.io/2024/08/18/asp-net-learning-2/
作者
MaP1e-G
发布于
2024年8月18日
许可协议