宣布发布实体框架核心 7 预览版 6:性能版

内容纲要

实体框架 7 (EF7) 预览版 6 已发布,nuget.org 提供。继续阅读各个软件包的链接。这篇博客文章将重点介绍优化以更新性能;

更新性能改进

在 EF7 中,SaveChanges 的性能已得到显著提高,特别关注的是删除不需要的网络往返数据库。在某些情况下,我们看到花费的时间减少了74% - 这是四倍的改进!

背景

性能始终是我们 EF Core 的优先事项。对于 EF Core 6.0,我们专注于提高非跟踪查询的性能,实现非常显著的加速,并使 EF Core 与使用 Dapper 的原始 SQL 查询相媲美。对于 EF Core 7.0,我们以 EF Core 的“更新管道”为目标:这是实现 SaveChanges 的组件,负责将插入、更新和删除应用于数据库。

EF Core 6.0 中的查询优化主要与运行时性能有关:目标是减少 EF Core 的直接开销,即执行查询时在 EF Core 代码中花费的时间。EF Core 7.0 中的更新管道改进有很大不同。事实证明,EF发送到数据库的SQL存在改进的机会,更重要的是,在调用SaveChanges时,在后台发生的网络往返次数方面存在改进机会。优化网络往返对于现代应用程序性能尤为重要:

  • 网络延迟通常是一个重要因素(有时以毫秒为单位),因此消除不需要的往返可能比代码本身的许多微优化更具影响力。
  • 延迟也因各种因素而异,因此消除往返的效果越高,延迟越高。
  • 在传统的本地部署中,数据库服务器通常位于应用程序服务器附近。在云环境中,数据库服务器往往离得更远,从而增加了延迟。

无论下面描述的性能优化如何,我强烈建议在与数据库交互时牢记往返。

事务和往返

让我们检查一个非常简单的 EF 程序,它将单个行插入到数据库中:

var blog = new Blog { Name = "MyBlog" };
ctx.Blogs.Add(blog);
await ctx.SaveChangesAsync();

使用 EF Core 6.0 运行此项将显示以下日志消息(经过筛选以突出显示重要内容):

dbug: 2022-07-10 17:10:48.450 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 17:10:48.521 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (30ms) [Parameters=[@p0='Foo' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 2022-07-10 17:10:48.549 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

主命令(耗时 30 毫秒)包含两个 SQL 语句(忽略哪个不相关):预期的语句,后跟 a 来获取我们刚刚插入的新行的 ID。在 EF Core 中,当实体的键是 int 时,EF 通常会将其设置为默认为数据库生成;对于 SQL Server,这意味着一列。由于您可能希望在插入该行后继续执行进一步的操作,因此 EF 必须取回 ID 值并将其填充到博客实例中。NOCOUNTINSERTSELECTIDENTITY

到目前为止,一切顺利;但这里还有更多的事情发生:在执行命令之前启动事务,然后在命令之后提交事务。通过我的表现分析眼镜来看,这笔交易花费了我们两个额外的数据库往返 - 一个用于启动它,另一个用于提交。现在,事务存在是有原因的:SaveChanges 可能需要应用多个更新操作,我们希望将这些更新包装在事务中,以便在发生故障时,所有内容都会回滚,数据库将保持一致状态。但是,如果只有一个操作,会发生什么情况,就像上面的例子一样?

好吧,事实证明,数据库保证了(大多数)单个SQL语句的事务性;如果发生任何错误,您不必担心语句仅部分完成。这很好 - 这意味着当涉及单个语句时,我们可以完全删除事务。果然,以下是 EF Core 7.0 生成的相同代码:

info: 2022-07-10 17:24:28.740 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (52ms) [Parameters=[@p0='Foo' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

更短 - 交易消失了!让我们看看这个优化的价值,用 BenchmarkDotNet 进行基准测试(你还没有用秒表手动滚动你自己的基准测试,是吗?)。

方法EF 版本服务器意味 着错误标准开发
Insert_one_row6.0本地主机522.9 微秒5.76 微秒5.10 微秒
Insert_one_row7.0本地主机390.0 微秒6.78 微秒8.82 微秒

很好,这是133微秒的改进,或25%!但是,由于我们正在讨论往返,因此您必须问:数据库在哪里,它的延迟是多少?上图用于针对本地计算机上运行的 SQL Server 实例运行。这是您在基准测试时通常不应该做的事情:将应用程序和数据库放在同一台计算机上可能会导致干扰并扭曲结果;毕竟,你不会在生产中这样做,对吧?但对我们来说更重要的是,联系localhost的延迟非常低 - 我们正在研究可能改进的下限

让我们对远程计算机执行另一次运行。在此基准测试中,我将通过wifi连接从笔记本电脑连接到台式机。这也不太现实:wifi不是这种事情的最佳媒介,就像你可能没有在生产中的同一台机器上运行数据库一样,你可能不会通过wifi连接到它,对吧?我们不会讨论这与现实世界与云数据库等的连接有多接近 - 您可以在您的环境中轻松对其进行基准测试并找出答案。结果如下:

方法EF 版本服务器意味 着错误标准开发
Insert_one_row6.0远程8.418 毫秒0.1668毫秒0.4216毫秒
Insert_one_row7.0远程4.593 毫秒0.0913毫秒0.2531毫秒

这是一个完全不同的球场:我们节省了3.8毫秒,即45%。在响应式Web应用程序或API中,3.8ms已经被认为是一个重要的时间,所以这是一个重大的胜利。

在我们继续之前,您可能已经注意到上面的其他SQL更改,除了事务消除之外:

  • 新的出现了。SQL Server 具有选择加入的“隐式事务”模式,在该模式下,在事务外部执行语句不会自动提交,而是隐式启动新事务。我们希望禁用此功能以确保实际保存更改。这样做的开销可以忽略不计。SET IMPLICIT_TRANSACTIONS OFF
  • 新的 SQL 不是插入然后选择数据库生成的 ID,而是使用“OUTPUT 子句”来告诉 SQL Server 直接从 INSERT 发送值。除了更严格的SQL之外,还需要这样做来获得事务性保证,而不需要显式事务,正如我们上面所讨论的那样。碰巧EF Core 6的两个语句是安全的,因为最后插入的标识值()是连接的本地值,并且ID在EF中不会更改,但是还有其他各种情况不成立(例如,如果ID之外还有其他数据库生成的值)。scope_identity

插入多行

让我们看看如果我们插入多行会发生什么:

for (var i = 0; i < 4; i++)
{
    var blog = new Blog { Name = "Foo" + i };
    ctx.Blogs.Add(blog);
}
await ctx.SaveChangesAsync();

使用 EF Core 6.0 运行此程序会在日志中显示以下内容:

dbug: 2022-07-10 18:46:39.583 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 18:46:39.677 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (52ms) [Parameters=[@p0='Foo0' (Size = 4000), @p1='Foo1' (Size = 4000), @p2='Foo2' (Size = 4000), @p3='Foo3' (Size = 4000)], CommandType='Text', CommandTimeout
='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 2022-07-10 18:46:39.705 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

那有点...出乎意料(而且不容易理解)。SQL Server 有一个 MERGE 语句,该语句最初用于将两个表合并在一起,但可用于其他目的。事实证明,使用 MERGE 插入四行比 4 个单独的 INSERT 语句快得多 - 即使批处理也是如此。因此,上述内容如下:

  1. 创建一个临时表(就是位)。DECLARE @inserted0
  2. 使用 MERGE 根据我们发送的参数插入到四行中,然后插入到表中。OUTPUT 子句(还记得吗?)将数据库生成的 ID 输出到临时表中。
  3. 选择以从临时表中检索 ID。

顺便说一句,这种高级的、特定于 SQL Server 的技术是一个很好的例子,说明像 EF Core 这样的 ORM 如何帮助你比自己编写 SQL 更有效率。当然,您可以在没有EF Core的情况下自己使用上述技术,但实际上,很少有用户深入研究优化调查;使用EF Core,您甚至不需要意识到它。

让我们将其与 EF Core 7.0 输出进行比较:

info: 2022-07-10 18:46:56.530 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='Foo0' (Size = 4000), @p1='Foo1' (Size = 4000), @p2='Foo2' (Size = 4000), @p3='Foo3' (Size = 4000)], CommandType='Text', CommandTimeout
='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

事务已消失,如上所述 – MERGE 也是受隐式事务保护的单个语句。请注意,如果我们使用4个INSERT语句,我们将无法省略显式事务(及其额外的往返);因此,这是使用MERGE的另一个优势,再加上它在这里提供的基本更好的性能。

但其他事情也发生了变化:临时表消失了,OUTPUT子句现在将生成的ID直接发送回客户端。让我们对这两种变体的性能进行基准测试:

方法EF 版本服务器意味 着错误标准开发
Insert_four_rows6.0远程12.93 毫秒0.258 毫秒0.651 毫秒
Insert_four_rows7.0远程4.985 毫秒0.0981 毫秒0.1981 毫秒
Insert_four_rows6.0当地1.679 毫秒0.0331 毫秒0.0368 毫秒
Insert_four_rows7.0当地435.8 微秒7.85 微秒6.96 微秒

远程方案的运行速度提高了近 8 毫秒,提高了 61%。局部情况更令人印象深刻:1.243毫秒的改进相当于74%的改进;该操作在 EF Core 7.0 上运行速度是 4 倍!

请注意,这些结果包括两个单独的优化:删除上面讨论的事务,以及优化 MERGE 以不使用临时表。

插曲:SQL Server 和 OUTPUT 子句

此时,你可能想知道为什么 EF Core 到目前为止没有使用简单的 OUTPUT 子句(没有临时表)。毕竟,新的SQL既简单又快捷。

遗憾的是,SQL Server 有一些限制,在某些情况下不允许 OUTPUT 子句。最重要的是,在定义了触发器的表上使用 OUTPUT 子句不受支持,并引发错误;支持带 INTO 的输出(如上文 EF Core 6.0 的 MERGE 一起使用)。现在,当我们第一次设计EF Core时,我们的目标是让所有场景都能正常工作,以使用户体验尽可能无缝。我们也不知道临时表实际增加了多少开销。在 EF Core 7.0 中重新访问此内容时,我们具有以下选项:

  1. 默认情况下保留当前的慢速行为,并允许用户选择使用更新、更有效的技术。
  2. 切换到更有效的技术,并为使用触发器切换到较慢行为的人提供选择退出。

这不是一个容易做出的决定 - 如果我们能帮助它,我们努力永远不会破坏用户。但是,考虑到极端的性能差异以及用户甚至不知道这种情况的事实,我们最终选择了选项2。具有触发器的用户升级到 EF Core 7.0 将获得一个信息性异常,将他们指向选择退出,而其他所有人无需了解任何内容即可获得显著提高的性能。

甚至更少的往返:委托人和家属

让我们再看一个场景。在这个中,我们将插入一个主体(博客)和一个依赖项(帖子):

ctx.Blogs.Add(new Blog
{
    Name = "MyBlog",
    Posts = new()
    {
        new Post { Title = "My first post" }
    }
});
await ctx.SaveChangesAsync();

这将生成以下内容:

dbug: 2022-07-10 19:39:32.826 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:39:32.890 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (22ms) [Parameters=[@p0='MyBlog' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 2022-07-10 19:39:32.929 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (3ms) [Parameters=[@p1='1', @p2='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Post] ([BlogId], [Title])
      VALUES (@p1, @p2);
      SELECT [Id]
      FROM [Post]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 2022-07-10 19:39:32.932 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

我们有四个往返:两个用于事务管理,一个用于博客插入,一个用于 Post 插入(请注意,每个 DbCommand 执行表示一个往返)。现在,EF Core 通常会在 SaveChanges 中执行批处理,这意味着在单个命令中发送多个更改,以提高效率。但是,在这种情况下,这是不可能的:由于博客的键是数据库生成的IDENTITY列,因此我们必须先获取生成的值,然后才能发送必须包含它的Post插入。这是一种正常的情况,我们对此无能为力。

让我们将博客和帖子更改为使用 GUID 键而不是整数。默认情况下,EF Core 对 GUID 密钥执行客户端生成,这意味着它自己生成新的 GUID,而不是像 IDENTITY 列那样让数据库执行此操作。借助 EF Core 6.0,我们可以获得以下信息:

dbug: 2022-07-10 19:47:51.176 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:47:51.273 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (36ms) [Parameters=[@p0='7c63f6ac-a69a-4365-d1c5-08da629c4f43', @p1='MyBlog' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
info: 2022-07-10 19:47:51.284 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p2='d0e30140-0f33-4435-e165-08da629c4f4d', @p3='0', @p4='7c63f6ac-a69a-4365-d1c5-08da629c4f43' (Nullable = true), @p5='My first post' (Size
 = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Post] ([Id], [BlogId], [BlogId1], [Title])
      VALUES (@p2, @p3, @p4, @p5);
dbug: 2022-07-10 19:47:51.296 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

不幸的是,博客和帖子仍然通过不同的命令插入。EF Core 7.0 取消了这一点,并执行以下操作:

dbug: 2022-07-10 19:40:30.259 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:40:30.293 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (26ms) [Parameters=[@p0='ce67f663-221a-4a86-3d5b-08da629b4875', @p1='MyBlog' (Size = 4000), @p2='127329d1-5c31-4001-c6a6-08da629b487b', @p3='0', @p4='ce67f663-
221a-4a86-3d5b-08da629b4875' (Nullable = true), @p5='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Post] ([Id], [BlogId], [BlogId1], [Title])
      VALUES (@p2, @p3, @p4, @p5);
dbug: 2022-07-10 19:40:30.302 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

由于 Blog 的密钥是客户端生成的,因此不再需要等待任何数据库生成的值,并且两个 INSERT 组合成一个命令,从而减少了往返。

我知道你在想什么 - 你现在正在考虑从自动递增整数ID切换到GUID,以利用这种优化。在运行并执行此操作之前,你应该知道 EF Core 还具有一个名为 HiLo 的功能,该功能使用整数键提供类似的结果。配置 HiLo 后,EF 会设置一个数据库序列,并从中获取一系列值(默认为 10);这些预提取的值由 EF Core 在内部缓存,并在需要插入新行时使用。效果类似于上面的 GUID 方案:只要我们有序列中的剩余值,就不再需要在插入时获取数据库生成的 ID。一旦 EF 用尽了这些值,它将执行一次往返来获取下一个值范围,依此类推。

可以基于属性启用 HiLo,如下所示:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>().Property(b => b.Id).UseHiLo();
}

完成此操作后,我们的 SaveChanges 输出将非常高效,并且类似于 GUID 方案:

dbug: 2022-07-10 19:54:25.862 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:54:25.890 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (20ms) [Parameters=[@p0='1', @p1='MyBlog' (Size = 4000), @p2='1', @p3='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Post] ([BlogId], [Title])
      OUTPUT INSERTED.[Id]
      VALUES (@p2, @p3);
dbug: 2022-07-10 19:54:25.909 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

请注意,此往返删除优化还加快了其他一些方案的速度,包括按类型表 (TPT) 继承映射策略、在单个 SaveChanges 调用中删除行并将其插入到同一表中的情况,以及其他一些方案。

结束语

在此博客文章中,我们介绍了 EF Core 7.0 更新管道中的三项优化:

  1. 当只有一条语句通过 SaveChanges 执行时,省略事务(减少两次往返)。
  2. 优化 SQL Server 的多行插入技术以停止使用临时变量。
  3. 删除与在同一 SaveChanges 调用中插入主体和从属关系相关的不需要的往返行程,以及一些其他方案。

我们相信这些都是有影响力的改进,并希望它们能使您的应用程序受益。请分享您的经验,无论好坏!

先决条件

  • EF7 当前面向 .NET 6。
  • EF7 不会在 .NET Framework 上运行。

EF7 是 EF Core 6.0 的后继产品,不要与 EF6 混淆。如果您正在考虑从 EF6 升级,请阅读我们的从 EF6 端口到 EF Core 的指南。

如何获取 EF7 预览

EF7 作为一组 NuGet 包以独占方式分发。例如,若要将 SQL Server 提供程序添加到项目中,可以通过 dotnet 工具使用以下命令:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.0-preview.6.22329.4

下表链接到 EF Core 包的预览版 6 版本,并介绍了它们的用途。

目的
Microsoft.EntityFrameworkCore独立于特定数据库提供程序的主 EF Core 包
Microsoft.EntityFrameworkCore.SqlServerMicrosoft SQL Server 和 SQL Azure 的 Database provider
Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite对空间类型的 SQL Server 支持
Microsoft.EntityFrameworkCore.SqliteSQLite 的数据库提供程序,包括数据库引擎的本机二进制文件
Microsoft.EntityFrameworkCore.Sqlite.Core带打包本机二进制文件的 SQLite 数据库提供程序
Microsoft.EntityFrameworkCore.Sqlite.NetTopologySuiteSQLite 对空间类型的支持
Microsoft.EntityFrameworkCore.CosmosAzure Cosmos DB 的 Database provider
Microsoft.EntityFrameworkCore.InMemory内存中数据库提供程序
Microsoft.EntityFrameworkCore.ToolsVisual Studio Package Manager Console 的 EF Core PowerShell 命令;使用它来将脚手架和迁移等工具与Visual Studio集成
Microsoft.EntityFrameworkCore.Design适用于 EF Core 工具的共享设计时组件
Microsoft.EntityFrameworkCore.Proxyies延迟加载和更改跟踪代理
Microsoft.EntityFrameworkCore.Abstractions解耦的 EF 核心抽象;将其用于 EF Core 定义的扩展数据批注等功能
Microsoft.EntityFrameworkCore.Relational关系数据库提供程序的共享 EF 核心组件
Microsoft.EntityFrameworkCore.Analyzers适用于 EF 核心的 C# 分析器

我们还发布了适用于 ADO.NET 的 Microsoft.Data.Sqlite.Core 提供程序的 7.0 预览版 6 版本。

安装 EF7 命令行界面 (CLI)

在执行 EF7 Core 迁移或基架命令之前,必须将 CLI 包作为全局或本地工具安装。

要全局安装预览工具,请使用以下命令进行安装:

dotnet tool install --global dotnet-ef --version 7.0.0-preview.6.22329.4 

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

dotnet tool update --global dotnet-ef --version 7.0.0-preview.6.22329.4 

可以将此新版本的 EF7 CLI 用于使用较旧版本的 EF Core 运行时的项目。

给TA打赏
共{{data.count}}人
人已打赏
.NET.Net Core

.NET Framework, .NET Core 和.NET Standard的区别和联系

2022-7-22 19:05:03

.Net Core

如何在 ASP.NET Core 中使用 Quartz.NET 执行任务调度

2022-7-29 18:59:42

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
今日签到
有新私信 私信列表
搜索