EF Core 8 Preview 4:原始集合和改进的 Contains

作者:Shay Rojansky
翻译:Alan Wang
排版:Alan Wang

Entity Framework Core (EF Core) 8 预览版4今天在 NuGet 上发布

基本信息

EF Core 8,或简称 EF8,是 EF Core 7 的后续,计划于2023年11月与 .NET 8 同时发布。

EF8 预览版本当前面向 .NET 6,因此可以与 .NET 6(LTS)或 .NET 7 一起使用。随着我们即将发布,这可能会更新到 .NET 8。

作为一个长期支持版本(LTS),EF8 将与 .NET 8 保持一致。详细信息请查看 .NET 支持策略

EF8 Preview 4 的更新

EF Core 8.0 preview4 包含一些令人兴奋的查询转换新功能,以及一个重要的性能优化。让我们深入地看一看!

使用内联集合转换 LINQ Contains

在 EF 寻求将越来越多的 LINQ 查询转换为 SQL 的过程中,我们有时会遇到一些奇怪且有问题的情况。让我们来看看这样一个案例,它恰好也与一个投票率很高的 EF 性能问题有关。从简单的事情开始,假设你有一堆博客,并想查询出两个你知道名字的博客。您可以使用以下 LINQ 查询来执行此操作:

var blogs = await context.Blogs
    .Where(b => new[] { "Blog1", "Blog2" }.Contains(b.Name))
    .ToArrayAsync();

这将导致在 SQL Server 上生成以下 SQL 查询:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (N'Blog1', N'Blog2')

看起来很棒!LINQ Contains 运算符有一个匹配的 SQL 构造——IN 表达式——它为我们提供了一个完美的转换。然而,这个查询中的名称作为常量嵌入到了 LINQ 查询中——因此也嵌入到了 SQL 查询中,通过我所说的内联集合(就是 new[] { ... } 部分):集合在查询本身中以行的形式指定。在许多情况下,我们无法做到这一点:博客名称有时只能作为变量使用,因为我们从其他来源读取它们,甚至可能从另一个 EF LINQ 查询中读取。

转换带有参数集合的 LINQ Contains

那么,当我们尝试做同样的事情,但在查询中嵌入一个变量而不是内联集合时,会发生什么呢?

var names = new[] { "Blog1", "Blog2" };

var blogs = await context.Blogs
    .Where(b => names.Contains(b.Name))
    .ToArrayAsync();

names 等变量嵌入到查询中时,EF 通常会通过数据库参数原样发送。这在大多数情况下都行得通,但对于这种特殊情况,数据库根本不支持将 IN 表达式与参数一起使用。换句话说,以下内容并不是有效的 SQL:

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN @names

更广泛地说,关系数据库实际上并没有“列表”或“集合”的概念;它们通常处理逻辑上无序的、结构化的集合,例如表。SQL Server 确实允许发送表值参数,但这涉及到各种复杂情况,因此这并不是一个合适的解决方案(例如,在查询之前必须预先定义表类型及其特定结构)。

唯一的例外是 PostgreSQL,它完全支持数组的概念:你可以在表中有一个 int 数组列,查询它,并将数组作为参数发送,就像你可以使用任何其他数据库类型一样。这允许 EF PostgreSQL 提供程序执行以下转换:

Executed DbCommand (10ms) [Parameters=[@__names_0={ 'Blog1', 'Blog2' } (DbType = Object)], CommandType='Text', CommandTimeout='30']

SELECT b."Id", b."Name"
FROM "Blogs" AS b
WHERE b."Name" = ANY (@__names_0)

这与上面使用 IN 的内联集合转换非常相似,但使用了 PostgreSQL 特定的 ANY 构造,该构造可以接受数组类型。利用这一点,我们将博客名称数组作为 SQL 参数直接传递给 ANY—— @__names_0 ——并得到完美的转换。但是,我们能为其他数据库做些什么呢?在没有 PostgreSQL 的情况下?

目前为止,所有版本的 EF 都提供了以下转换

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (N'Blog1', N'Blog2')

但是等一下,这看起来很熟悉——这是我们上面看到的内联集合转换!事实上,由于我们无法参数化数组,我们只是将其值作为常量嵌入到 SQL 查询中。虽然 EF LINQ 查询中的 .NET 变量通常会成为 SQL 参数,但在这种特殊情况下,该变量已消失,其内容则被直接插入到了 SQL 中。

不幸的是,EF 生成的 SQL 因不同的数组内容而异——这是一种非常不正常的情况!通常,当您一次又一次地运行相同的 LINQ 查询(只更改参数值)时,EF 会向数据库发送完全相同的 SQL。这对于良好的性能至关重要:SQL Server 缓存 SQL,只在第一次看到特定的 SQL 时执行昂贵的查询计划(类似的 SQL 缓存在 PostgreSQL 的数据库驱动程序中实现)。此外,EF 本身有一个用于查询的内部 SQL 缓存,而这种 SQL 差异使缓存变得不可能,从而导致每个查询的 EF 消耗进一步增加。

但至关重要的是,不断变化的 SQL 对性能的负面影响超出了这个特定的查询。SQL Server(和 Npgsql)只能缓存一定数量的 SQL;在某些情况下,它们必须删除旧条目,以避免使用过多内存。如果您经常将 Contains 与变量数组一起使用,那么每个单独的调用都会导致在数据库中获取有价值的缓存条目,因为 SQL 很可能永远不会被使用(因为它们有特定的数组值)。这意味着您还将清除其他需要使用的重要 SQL 的缓存条目,并要求一次又一次地重新规划它们。

简而言之——不太好!事实上,这一性能问题是 EF Core repo 中投票率第二高的问题;与大多数性能问题一样,您的应用程序可能会在您不知情的情况下受到影响。当集合是一个参数时,我们显然需要一个更好的解决方案来转换 LINQ Contains 运算符。

使用 OpenJson 转换参数集合

让我们看看 SQL preview4 为这个 LINQ 查询生成了什么:

Executed DbCommand (49ms) [Parameters=[@__names_0='["Blog1","Blog2"]' (Size = 4000)], CommandType='Text', CommandTimeout='30']

SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__names_0) AS [n]
    WHERE [n].[value] = [b].[Name])

这个 SQL 确实是一个完全不同的东西;但是,即使不了解到底发生了什么,我们也可以看到博客名称是作为一个参数传递的,在 SQL 中通过 @__names_0 表示——类似于我们上面的 PostgreSQL 转换。那么这是怎么回事呢?

现代数据库内置了对 JSON 的支持;尽管具体情况因数据库而异,但所有数据库都支持在 SQL 中直接解析和查询 JSON 的一些基本形式。SQL Server 的 JSON 功能之一是 OpenJson 函数:这是一个“表值函数”,它接受 JSON 文档,并从其内容返回一个标准的关系行集。例如,以下 SQL 查询:

SELECT * FROM OpenJson('["one", "two", "three"]');

返回以下行集

[key]valuetype
0one1
1two1
2three2

输入的 JSON 数组已被有效地转换为关系型“表”,然后可以使用常用的 SQL 运算符对其进行查询。EF 利用这一点来解决“参数集合”问题:

  1. 我们将您的 .NET 数组变量转换为 JSON 数组…
  2. 我们将 JSON 数组作为一个简单的 SQL nvarchar 参数发送…
  3. 我们使用 OpenJson 函数来解压缩参数…
  4. 我们使用 EXISTS 子查询来检查是否有任何元素与博客的名称匹配。

这实现了我们的目标,即为 .NET 数组中的不同值提供一个单一的、不可变的SQL,并解决了 SQL 缓存问题。重要的是,在单独查看时,这个新转换实际上可能比以前的转换运行得慢一点——SQL Server 有时可以比新转换更有效地执行以前的 IN 转换;具体何时发生取决于数组中元素的数量。但关键的一点是,无论这个特定查询运行得多快,它都不会再导致其他查询从 SQL 缓存中被逐出,从而对整个应用程序产生负面影响。

我们正在研究对上述基于 OpenJson 的转换的进一步优化——preview4实现只是该功能的第一个版本。请继续关注此领域进一步的性能改进。

SQL Server 的旧版本

OpenJson 函数是在 SQLServer 2016(13.x)中引入的;虽然这是一个相当旧的版本,但它仍然受到支持,我们不想因为依赖它而破坏它的用户。因此,我们为您引入了一种通用的方法来告诉 EF 哪个 SQL Server 是目标 SQL Server,这将使我们能够利用较新的功能,同时为旧版本的用户保留向后的兼容性。要做到这一点,只需在配置上下文选项时调用新的 [UseCompatibilityLevel] 方法:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(@"<CONNECTION STRING>", o => o.UseCompatibilityLevel(120));

120 参数是所需的 SQL Server 兼容性级别120 对应 SQL Server 2014(12.x)。完成此操作后,EF 将生成先前的转换,将数组内容嵌入到 IN 表达式中。

可查询的原始集合列

我可以到此为止,我们已经有了一个很好的功能,解决了一个长期存在的性能问题。但让我们走得更远些!上述 Contains 的解决方案支持将原始集合表示为 JSON 数组,然后像查询中的任何其他表一样使用该集合。上面转换的 Contains 只是一个非常具体的例子,但我们可以做得更多。

假设每个博客也关联到一个标签集合。在经典的关系建模中,我们将其表示为 Blogs 表和 Tags 表之间的多对多关系,使用 BlogTags 联接表将两者链接在一起;EF Core 非常支持这种映射(参阅文档)。但是这种传统的建模可能有点重,需要两个额外的表和 JOIN,以及一个 .NET 类型来包装简单的字符串 Tag。让我们试着从不同的角度来看待这个问题。

由于 EF 现在支持原始集合,我们可以简单地在我们的博客类型中添加一个字符串数组属性:

public class Blog
{
    public int Id { get; set; }
    // ...
    public string[] Tags { get; set; }
}

这将导致 EF 生成以下表格:

CREATE TABLE [Blogs] (
    [Id] int NOT NULL IDENTITY,
    -- ...
    [Tags] nvarchar(max) NULL,
);

我们的新标签属性现在映射到了数据库中的单个 nvarchar(max) 属性。您现在可以添加带有一些标记的博客:

context.Blogs.Add(new Blog { Name = "Blog1", Tags = new[] { "Tag1", "Tag2" } });
await context.SaveChangesAsync();

…EF 将自动将您的 Tags.NET 数组编码为数据库中的 JSON 数组字符串:

Executed DbCommand (47ms) [Parameters=[@p0='foo' (Nullable = false) (Size = 4000), @p1='["Tag1","Tag2"]' (Size = 4000)], CommandType='Text', CommandTimeout='30']

INSERT INTO [Blogs] ([Name], [Tags])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1);

类似地,当从数据库中读取博客时,EF 将自动解码 JSON 数组并填充 .NET 数组属性。这一切都很美妙,但人们很长一段时间以来已经在通过在数组属性上定义值转换器来实现这一点了。事实上,我们的值转换器文档中有一个示例正好说明了这一点。那有什么大不了的?

正如我们使用 SQL EXISTS 子查询来转换 LINQ Contains 运算符一样,EF 现在允许您在此类原始集合列上使用任意 LINQ 运算符,就像它们是常规 DbSet 一样;换句话说,原始集合现在是完全可查询的。例如,要查找所有具有特定标记的博客,您现在可以使用以下 LINQ 查询:

var blogs = await context.Blogs
    .Where(b => b.Tags.Contains("Tag1"))
    .ToArrayAsync();

…EF 将转换为以下内容:

SELECT [b].[Id], [b].[Name], [b].[Tags]
FROM [Blogs] AS [b]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([b].[Tags]) AS [t]
    WHERE [t].[value] = N'Tag1')

这与我们在上面看到的参数的 SQL 完全相同,但应用于列!但让我们做一些更有趣的事情:如果我们不想查询所有有特定标签的博客,而是想查询有多个标签的博客呢?现在可以使用以下 LINQ 查询来完成此操作:

var tags = new[] { "Tag1", "Tag2" };

var blogs = await context.Blogs
    .Where(b => b.Tags.Intersect(tags).Count() >= 2)
    .ToArrayAsync();

这利用了更复杂的 LINQ 操作符:我们将每个博客的标签与一个参数集合相交,并查询出至少有两个匹配的博客。这将转化为以下内容:

Executed DbCommand (48ms) [Parameters=[@__tags_0='["Tag1","Tag2"]' (Size = 4000)], CommandType='Text', CommandTimeout='30']

SELECT [b].[Id], [b].[Name], [b].[Tags]
FROM [Blogs] AS [b]
WHERE (
    SELECT COUNT(*)
    FROM (
        SELECT [t].[value]
        FROM OpenJson([b].[Tags]) AS [t] -- column collection
        INTERSECT
        SELECT [t1].[value]
        FROM OpenJson(@__tags_0) AS [t1] -- parameter collection
    ) AS [t0]) >= 2

这很难理解,但我们使用了相同的基本机制:我们在列原始集合([b].[Tags])和参数原始集合(@__tags_0)之间执行交集,使用 OpenJson 将 JSON 数组字符串解包到行集中。

让我们来看最后一个例子。由于我们将原始集合编码为 JSON 数组,因此这些集合是自然有序的。在关系数据库中,这是一种非典型的情况——关系集在逻辑上总是无序的,必须使用 ORDER BY 子句才能获得任何确定性排序。

现在,标签列表通常是一个无序的袋子:我们不在乎哪个标签排在第一位。但是,为了这个例子,让我们假设您的博客的标签是有序的,更“重要”的标签排在第一位。在这种情况下,以某个值作为第一个标签查询所有博客可能是有意义的:

var blogs = await context.Blogs
    .Where(b => b.Tags[0] == "Tag1")
    .ToArrayAsync();

这将生成以下 SQL:

SELECT [b].[Id], [b].[Name], [b].[Tags]
FROM [Blogs] AS [b]
WHERE (
    SELECT [t].[value]
    FROM OpenJson([b].[Tags]) AS [t]
    ORDER BY CAST([t].[key] AS int)
    OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) = N'Tag1'

EF 生成一个 ORDER BY 子句,以确保 JSON 数组的自然顺序得到保留,然后使用限制来获取第一个元素。这种过于复杂的 SQL 已经得到了改进,之后的预览版将生成以下更严格的 SQL:

SELECT [b].[Id], [b].[Name], [b].[Tags]
FROM [Blogs] AS [b]
WHERE JSON_VALUE([b].[Tags], '$[0]') = N'Tag1'

总之,现在可以在原始集合上使用全套 LINQ 运算符,无论它们是列还是参数。这为以前从未转换过的查询打开了令人兴奋的转换可能性;我们期待看到您将使用的查询类型!

在使用基于 JSON 的原始集合之前,请仔细考虑索引和查询性能。大多数数据库允许将至少某些形式的查询索引到 JSON 文档中;但是任意的、复杂的查询(如上面的 intersect)可能无法使用索引。在某些情况下,传统的关系建模(例如多对多)可能更合适。
我们在上面提到,PostgreSQL 对数组有原生支持,所以在处理原始集合时不需要使用 JSON 数组编码。相反,原始数组集合(默认情况下)映射到数组,PostgreSQL unnest 函数用于将本机数组扩展到行集。

还有最后一件事:可查询的内联集合

我们讨论了包含原始集合的列和参数,但省略了最后一种类型——内联集合。您可能还记得,我们以以下 LINQ 查询开始了这篇文章:

var blogs = await context.Blogs
    .Where(b => new[] { "Blog1", "Blog2" }.Contains(b.Name))
    .ToArrayAsync();

查询中的 new[] { ... } 位表示内联集合。到目前为止,EF 仅在一些非常有限的场景中支持这些,例如 Contains 运算符。Preview 4现在提供了对可查询内联集合的完全支持,允许您在它们上使用全套 LINQ 运算符。

作为一个示例查询,让我们挑战自己,做一些更复杂的事情。以下查询为搜索至少有一个以ab开头标签的博客:

var blogs = await context.Blogs
    .Where(b => new[] { "a%", "b%" }
        .Any(pattern => b.Tags.Any(tag => EF.Functions.Like(tag, pattern))))
    .ToArrayAsync();

请注意,模式的内联集合——new[] { "a%", "b%" }—— 由 Any 运算符组成。现在这会转换为以下 SQL:

SELECT [b].[Id], [b].[Name], [b].[Tags]
FROM [Blogs] AS [b]
WHERE EXISTS (
    SELECT 1
    FROM (VALUES (CAST(N'a%' AS nvarchar(max))), (N'b%')) AS [v]([Value]) -- inline collection
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([b].[Tags]) AS [t] -- column collection
        WHERE [t].[value] LIKE [v].[Value]))

有趣的是“内联集合”行。与参数和列集合不同,我们不需要使用 JSON 数组和 OpenJson:SQL 已经有了通过VALUES 表达式指定内联表的通用机制。这就完成了这幅图——EF 现在支持查询任何类型的原始集合,无论是列、参数还是内联集合。

支持的和不支持的

Preview 4 带来了对 SQL Server 和 SQLite 的原始集合支持;PostgreSQL 提供程序也将更新以支持它们。然而,如上所述,这是关于原始集合的第一波工作——预计在未来的版本中会有进一步的改进。具体来说:

  • 还不支持自有的 JSON 实体中的原始集合
  • 某些提供程序尚不支持某些原始数据类型;例如,空间类型就是这样。
  • 我们可以围绕 OpenJSON 优化 SQL,以提高查询效率。

如何获取 EF8 Preview 4

EF 8 仅作为一组 NuGet 软件包分发。例如,要将 SQL Server 提供程序添加到项目中,可以使用 dotnet 工具执行以下命令:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 8.0.0-preview.4.23259.3

安装 EF8 命令行界面(CLI)

在执行 EF8 Core 迁移或脚手架命令之前,必须安装dotnet-ef 工具。

要全局安装该工具,请使用:

dotnet tool install --global dotnet-ef --version 8.0.0-preview.4.23259.3

如果您已经安装了该工具,则可以使用以下命令对其进行升级:

dotnet tool update --global dotnet-ef --version 8.0.0-preview.4.23259.3

The .NET Data Community Standup

.NET 数据访问团队现在每星期三太平洋时间上午10点、东部时间下午1点或 UTC 时间18:00进行直播。加入直播学习并询问有关 .NET 数据相关的问题。

文档与反馈

所有 EF Core 文档:docs.microsoft.com/ef/。 请在 dotnet/efcore GitHub repo 上填写发现的问题或其他任何反馈。

帮助链接

可参考和访问以下链接:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值