培训
模块
使用 EF Core 持久保存和检索关系数据 - Training
本模块指导你完成创建数据访问项目的步骤。 你将使用 Entity Framework Core (EF Core) 连接到关系数据库并构造创建、读取、更新和删除 (CRUD) 查询。
Entity Framework Core (EF Core) 侦听器支持 EF Core 操作的侦听、修改和/或抑制。 这包括低级数据库操作(例如执行命令)以及高级别操作(例如对 SaveChanges 的调用)。
侦听器与日志记录和诊断的不同之处在于,它们允许修改或抑制所侦听的操作。 简单日志记录或 Microsoft.Extensions.Logging 是日志记录的更好选择。
配置上下文时,将按 DbContext 实例注册侦听器。 使用诊断侦听器获取相同的信息,但要获取进程中所有 DbContext 实例的信息。
拦截器是在配置 DbContext 实例时使用 AddInterceptors 注册的。 这通常通过替代 DbContext.OnConfiguring 来完成。 例如:
public class ExampleContext : BlogsContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(new TaggedQueryCommandInterceptor());
}
或者,可将 AddInterceptors
作为 AddDbContext 的一部分来调用,或者在创建 DbContextOptions 实例以传递给 DbContext 构造函数时进行调用。
提示
当使用 AddDbContext 或将 DbContextOptions 实例传递给 DbContext 构造函数时,仍会调用 OnConfiguring。 这使得它成为应用上下文配置的理想位置,而无需考虑如何构造 DbContext。
侦听器通常是无状态的,这意味着单个侦听器实例可用于所有 DbContext 实例。 例如:
public class TaggedQueryCommandInterceptorContext : BlogsContext
{
private static readonly TaggedQueryCommandInterceptor _interceptor
= new TaggedQueryCommandInterceptor();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(_interceptor);
}
每个拦截器实例都必须实现一个或多个从 IInterceptor 派生的接口。 每个实例只能注册一次,即使它实现了多个侦听接口;EF Core 将根据需要为每个接口路由事件。
备注
数据库拦截仅适用于关系数据库提供程序。
低层数据库侦听分为下表中显示的三个接口。
侦听器 | 已侦听的数据库操作 |
---|---|
IDbCommandInterceptor | 创建命令 执行命令 命令失败 释放命令的 DbDataReader |
IDbConnectionInterceptor | 打开和关闭连接 连接失败 |
IDbTransactionInterceptor | 创建事务 使用现有事务 提交事务 回滚事务 创建和使用保存点 事务失败 |
基类 DbCommandInterceptor、DbConnectionInterceptor 和 DbTransactionInterceptor 包含相应接口中每个方法的无操作实现。 使用基类,可无需实现未使用的侦听方法。
每种侦听器类型上的方法都成对出现,第一种方法在启动数据库操作之前调用,第二种方法在完成操作后调用。 例如,在执行查询之前调用 DbCommandInterceptor.ReaderExecuting,在查询发送到数据库后调用 DbCommandInterceptor.ReaderExecuted。
每对方法都有同步和异步变体。 这允许异步 I/O(如请求访问令牌)作为侦听异步数据库操作的一部分进行。
提示
可以从 GitHub 下载命令侦听器示例。
IDbCommandInterceptor 可用于在将 SQL 发送到数据库之前对其进行修改。 此示例演示如何修改 SQL 以包含查询提示。
通常,侦听过程中最棘手的部分是确定命令何时对应于需要修改的查询。 分析 SQL 是一种选项,但这种做法往往不可靠。 另一种选项是使用 EF Core 查询标记来标记每个应修改的查询。 例如:
var blogs1 = context.Blogs.TagWith("Use hint: robust plan").ToList();
然后,可在侦听器中检测到此标记,因为它将始终作为注释包含在命令文本的第一行中。 检测到标记后,会修改查询 SQL 以添加适当的提示:
public class TaggedQueryCommandInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
ManipulateCommand(command);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ManipulateCommand(command);
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
private static void ManipulateCommand(DbCommand command)
{
if (command.CommandText.StartsWith("-- Use hint: robust plan", StringComparison.Ordinal))
{
command.CommandText += " OPTION (ROBUST PLAN)";
}
}
}
注意:
Executing
方法,这些方法由 EF Core 通过生成的 SQL(发送到数据库之前)进行调用。 将此方法与 Executed
方法进行比较,后者在已返回数据库调用之后进行调用。标记查询后,运行此示例中的代码会生成以下内容:
-- Use hint: robust plan
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b] OPTION (ROBUST PLAN)
另一方面,如果未标记查询,则会将其发送到未修改的数据库:
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
提示
可以从 GitHub 下载连接侦听器示例。
IDbConnectionInterceptor 可用于在将 DbConnection 用于连接到数据库之前对其进行操作。 这可用于获取 Azure Active Directory (AAD) 访问令牌。 例如:
public class AadAuthenticationInterceptor : DbConnectionInterceptor
{
public override InterceptionResult ConnectionOpening(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result)
=> throw new InvalidOperationException("Open connections asynchronously when using AAD authentication.");
public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
DbConnection connection,
ConnectionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
var sqlConnection = (SqlConnection)connection;
var provider = new AzureServiceTokenProvider();
// Note: in some situations the access token may not be cached automatically the Azure Token Provider.
// Depending on the kind of token requested, you may need to implement your own caching here.
sqlConnection.AccessToken = await provider.GetAccessTokenAsync("https://database.windows.net/", null, cancellationToken);
return result;
}
}
提示
Microsoft.Data.SqlClient 现在支持通过连接字符串进行 AAD 身份验证。 有关详细信息,请参阅SqlAuthenticationMethod。
警告
请注意,侦听器引发是否进行同步调用,以打开连接。 这是因为没有可用于获取访问令牌的非同步方法,并且没有一种通用且简单的方法可从非同步上下文中调用异步方法而不存在死锁风险。
警告
在某些情况下,可能不会将访问令牌自动缓存到 Azure 令牌提供程序中。 根据所请求的令牌类型,你可能需要在此处实现自己的缓存。
提示
可以从 GitHub 下载高级命令侦听器示例。
EF Core 侦听器可以:
此示例显示了一个侦听器,该侦听器使用这些功能来执行类似于原始二级缓存的行为。 为特定查询返回缓存查询结果,避免数据库往返。
警告
以这种方式更改 EF Core 默认行为时要小心谨慎。 如果出现 EF Core 无法正确处理的异常结果,它可能会以意外的方式运行。 此外,此示例还展示侦听器概念;它不作为用于强大的二级缓存实现的模板。
在此示例中,应用程序经常执行查询以获取最新的“每日消息”:
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
已标记此查询,因此可在侦听器中轻松检测到此查询。 此想法是每天只查询一次数据库的新消息。 在其他时候,应用程序将使用缓存的结果。 (示例使用其中的 10 秒延迟来模拟新的一天。)
此侦听器是有状态的:它存储查询最近查询的每日消息的 ID 和消息文本,以及执行该查询的时间。 由于此状态,我们还需要一个 lock,因为缓存要求相同的侦听器必须由多个上下文实例使用。
private readonly object _lock = new object();
private int _id;
private string _message;
private DateTime _queriedAt;
在 Executing
方法中(即执行数据库调用之前),侦听器会检测标记的查询,然后检查是否有缓存的结果。 如果找到此类结果,则会禁止查询并改为使用缓存的结果。
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal))
{
lock (_lock)
{
if (_message != null
&& DateTime.UtcNow < _queriedAt + new TimeSpan(0, 0, 10))
{
command.CommandText = "-- Get_Daily_Message: Skipping DB call; using cache.";
result = InterceptionResult<DbDataReader>.SuppressWithResult(new CachedDailyMessageDataReader(_id, _message));
}
}
}
return new ValueTask<InterceptionResult<DbDataReader>>(result);
}
请注意代码如何调用 InterceptionResult<TResult>.SuppressWithResult 并传递包含缓存数据的替换项 DbDataReader。 然后,返回此 InterceptionResult,从而抑制查询执行。 EF Core 改用替换读取器作为查询结果。
此侦听器还会操作命令文本。 此操作不是必需的,但可提高日志消息的清晰度。 命令文本不需要是有效的 SQL,因为现在不会执行查询。
如果没有可用的缓存消息,或者如果消息已过期,则上面的代码不会禁止显示结果。 因此,EF Core 将正常执行查询。 然后,它将在执行后返回到侦听器的 Executed
方法。 此时,如果结果不是缓存的读取器,则会从实际读取器中提取新消息 ID 和字符串并进行缓存,以供下次使用此查询。
public override async ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.StartsWith("-- Get_Daily_Message", StringComparison.Ordinal)
&& !(result is CachedDailyMessageDataReader))
{
try
{
await result.ReadAsync(cancellationToken);
lock (_lock)
{
_id = result.GetInt32(0);
_message = result.GetString(1);
_queriedAt = DateTime.UtcNow;
return new CachedDailyMessageDataReader(_id, _message);
}
}
finally
{
await result.DisposeAsync();
}
}
return result;
}
缓存侦听器示例包含一个简单的控制台应用程序,该应用程序查询每日消息以测试缓存:
// 1. Initialize the database with some daily messages.
using (var context = new DailyMessageContext())
{
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
context.AddRange(
new DailyMessage { Message = "Remember: All builds are GA; no builds are RTM." },
new DailyMessage { Message = "Keep calm and drink tea" });
await context.SaveChangesAsync();
}
// 2. Query for the most recent daily message. It will be cached for 10 seconds.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 3. Insert a new daily message.
using (var context = new DailyMessageContext())
{
context.Add(new DailyMessage { Message = "Free beer for unicorns" });
await context.SaveChangesAsync();
}
// 4. Cached message is used until cache expires.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
// 5. Pretend it's the next day.
Thread.Sleep(10000);
// 6. Cache is expired, so the last message will not be queried again.
using (var context = new DailyMessageContext())
{
Console.WriteLine(await GetDailyMessage(context));
}
async Task<string> GetDailyMessage(DailyMessageContext context)
=> (await context.DailyMessages.TagWith("Get_Daily_Message").OrderBy(e => e.Id).LastAsync()).Message;
这会导致生成以下输出:
info: 10/15/2020 12:32:11.801 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Keep calm and drink tea
info: 10/15/2020 12:32:11.821 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[@p0='Free beer for unicorns' (Size = 22)], CommandType='Text', CommandTimeout='30']
INSERT INTO "DailyMessages" ("Message")
VALUES (@p0);
SELECT "Id"
FROM "DailyMessages"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();
info: 10/15/2020 12:32:11.826 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message: Skipping DB call; using cache.
Keep calm and drink tea
info: 10/15/2020 12:32:21.833 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
-- Get_Daily_Message
SELECT "d"."Id", "d"."Message"
FROM "DailyMessages" AS "d"
ORDER BY "d"."Id" DESC
LIMIT 1
Free beer for unicorns
从日志输出中可以看出,应用程序继续使用缓存的消息,直到超时到期,此时将再次查询数据库以获取任何新消息。
提示
可以从 GitHub 下载 SaveChanges 侦听器示例。
SaveChanges 和 SaveChangesAsync 拦截点由 ISaveChangesInterceptor 接口定义。 对于其他拦截器,为方便起见,提供了具有无操作方法的 SaveChangesInterceptor 基类。
提示
侦听器功能强大。 但是,在许多情况下,重写 SaveChanges 方法或使用 DbContext 上公开的适用于 SaveChanges 的 .NET 事件更为简单。
可以侦听 SaveChanges,以创建所做的更改的独立审核记录。
备注
这并不是一种可靠的审核解决方案。 这只是一个简单的示例,用于演示侦听功能。
审核示例使用包含博客和帖子的简单 DbContext。
public class BlogsContext : DbContext
{
private readonly AuditingInterceptor _auditingInterceptor = new AuditingInterceptor("DataSource=audit.db");
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.AddInterceptors(_auditingInterceptor)
.UseSqlite("DataSource=blogs.db");
public DbSet<Blog> Blogs { get; set; }
}
public class Blog
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Post> Posts { get; } = new List<Post>();
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public Blog Blog { get; set; }
}
请注意,已为每个 DbContext 实例注册了侦听器的新实例。 这是因为审核侦听器包含链接到当前上下文实例的状态。
该示例还包含用于审核数据库的第二个 DbContext 和模型。
public class AuditContext : DbContext
{
private readonly string _connectionString;
public AuditContext(string connectionString)
{
_connectionString = connectionString;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlite(_connectionString);
public DbSet<SaveChangesAudit> SaveChangesAudits { get; set; }
}
public class SaveChangesAudit
{
public int Id { get; set; }
public Guid AuditId { get; set; }
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public bool Succeeded { get; set; }
public string ErrorMessage { get; set; }
public ICollection<EntityAudit> Entities { get; } = new List<EntityAudit>();
}
public class EntityAudit
{
public int Id { get; set; }
public EntityState State { get; set; }
public string AuditMessage { get; set; }
public SaveChangesAudit SaveChangesAudit { get; set; }
}
使用侦听器进行审核的总体思路是:
在使用 ISaveChangesInterceptor.SavingChanges 和 ISaveChangesInterceptor.SavingChangesAsync 的替代项将任何更改发送到数据库之前处理第一个阶段。
public async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
await auditContext.SaveChangesAsync();
return result;
}
public InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
_audit = CreateAudit(eventData.Context);
using var auditContext = new AuditContext(_connectionString);
auditContext.Add(_audit);
auditContext.SaveChanges();
return result;
}
同时重写同步和异步方法可确保在无论是否调用 SaveChanges
或 SaveChangesAsync
的情况下进行审核。 另请注意,异步重载本身能够对审核数据库执行非阻止性异步 I/O。 你可能希望从同步 SavingChanges
方法引发,以确保所有数据库 I/O 都是异步的。 然后,这要求应用程序始终调用 SaveChangesAsync
且不调用 SaveChanges
。
每种侦听器方法都有 eventData
一个参数,提供有关侦听的事件的上下文信息。 在这种情况下,当前应用程序 DbContext 包含在事件数据中,然后用于创建审核消息。
private static SaveChangesAudit CreateAudit(DbContext context)
{
context.ChangeTracker.DetectChanges();
var audit = new SaveChangesAudit { AuditId = Guid.NewGuid(), StartTime = DateTime.UtcNow };
foreach (var entry in context.ChangeTracker.Entries())
{
var auditMessage = entry.State switch
{
EntityState.Deleted => CreateDeletedMessage(entry),
EntityState.Modified => CreateModifiedMessage(entry),
EntityState.Added => CreateAddedMessage(entry),
_ => null
};
if (auditMessage != null)
{
audit.Entities.Add(new EntityAudit { State = entry.State, AuditMessage = auditMessage });
}
}
return audit;
string CreateAddedMessage(EntityEntry entry)
=> entry.Properties.Aggregate(
$"Inserting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateModifiedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.IsModified || property.Metadata.IsPrimaryKey()).Aggregate(
$"Updating {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
string CreateDeletedMessage(EntityEntry entry)
=> entry.Properties.Where(property => property.Metadata.IsPrimaryKey()).Aggregate(
$"Deleting {entry.Metadata.DisplayName()} with ",
(auditString, property) => auditString + $"{property.Metadata.Name}: '{property.CurrentValue}' ");
}
结果是一个 SaveChangesAudit
实体,其中包含 EntityAudit
实体集合,每个实体都用于一个插入、更新或删除操作。 然后,侦听器将这些实体插入到审核数据库中。
提示
在每个 EF Core 事件数据类中替代 ToString,以生成事件的等效日志消息。 例如,调用 ContextInitializedEventData.ToString
会生成“Entity Framework Core 5.0.0 使用提供程序“Microsoft.EntityFrameworkCore.Sqlite”初始化的“BlogsContext”,其选项为“无””。
审核实体存储在侦听器上,以便SaveChanges 成功或失败后,可立即再次访问它。 为了操作成功,会调用 ISaveChangesInterceptor.SavedChanges 或 ISaveChangesInterceptor.SavedChangesAsync。
public int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
auditContext.SaveChanges();
return result;
}
public async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = true;
_audit.EndTime = DateTime.UtcNow;
await auditContext.SaveChangesAsync(cancellationToken);
return result;
}
审核实体已附加到审核上下文,因为它已存在于数据库中,并且需要更新。 然后我们需要设置 Succeeded
和 EndTime
,将这些属性标记为已修改,以便 SaveChanges 将更新发送到审核数据库。
处理失败的方式与成功的方式大致相同,但采用的是 ISaveChangesInterceptor.SaveChangesFailed 或 ISaveChangesInterceptor.SaveChangesFailedAsync 方法。 事件数据包含引发的异常。
public void SaveChangesFailed(DbContextErrorEventData eventData)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.Message;
auditContext.SaveChanges();
}
public async Task SaveChangesFailedAsync(
DbContextErrorEventData eventData,
CancellationToken cancellationToken = default)
{
using var auditContext = new AuditContext(_connectionString);
auditContext.Attach(_audit);
_audit.Succeeded = false;
_audit.EndTime = DateTime.UtcNow;
_audit.ErrorMessage = eventData.Exception.InnerException?.Message;
await auditContext.SaveChangesAsync(cancellationToken);
}
审核示例包含一个简单的控制台应用程序,该应用程序对博客数据库进行更改并显示创建的审核。
// Insert, update, and delete some entities
using (var context = new BlogsContext())
{
context.Add(
new Blog { Name = "EF Blog", Posts = { new Post { Title = "EF Core 3.1!" }, new Post { Title = "EF Core 5.0!" } } });
await context.SaveChangesAsync();
}
using (var context = new BlogsContext())
{
var blog = context.Blogs.Include(e => e.Posts).Single();
blog.Name = "EF Core Blog";
context.Remove(blog.Posts.First());
blog.Posts.Add(new Post { Title = "EF Core 6.0!" });
context.SaveChanges();
}
// Do an insert that will fail
using (var context = new BlogsContext())
{
try
{
context.Add(new Post { Id = 3, Title = "EF Core 3.1!" });
await context.SaveChangesAsync();
}
catch (DbUpdateException)
{
}
}
// Look at the audit trail
using (var context = new AuditContext("DataSource=audit.db"))
{
foreach (var audit in context.SaveChangesAudits.Include(e => e.Entities).ToList())
{
Console.WriteLine(
$"Audit {audit.AuditId} from {audit.StartTime} to {audit.EndTime} was{(audit.Succeeded ? "" : " not")} successful.");
foreach (var entity in audit.Entities)
{
Console.WriteLine($" {entity.AuditMessage}");
}
if (!audit.Succeeded)
{
Console.WriteLine($" Error: {audit.ErrorMessage}");
}
}
}
结果显示审核数据库的内容:
Audit 52e94327-1767-4046-a3ca-4c6b1eecbca6 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Blog with Id: '-2147482647' Name: 'EF Blog'
Inserting Post with Id: '-2147482647' BlogId: '-2147482647' Title: 'EF Core 3.1!'
Inserting Post with Id: '-2147482646' BlogId: '-2147482647' Title: 'EF Core 5.0!'
Audit 8450f57a-5030-4211-a534-eb66b8da7040 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was successful.
Inserting Post with Id: '-2147482645' BlogId: '1' Title: 'EF Core 6.0!'
Updating Blog with Id: '1' Name: 'EF Core Blog'
Deleting Post with Id: '1'
Audit 201fef4d-66a7-43ad-b9b6-b57e9d3f37b3 from 10/14/2020 9:10:17 PM to 10/14/2020 9:10:17 PM was not successful.
Inserting Post with Id: '3' BlogId: '' Title: 'EF Core 3.1!'
Error: SQLite Error 19: 'UNIQUE constraint failed: Post.Id'.
培训
模块
使用 EF Core 持久保存和检索关系数据 - Training
本模块指导你完成创建数据访问项目的步骤。 你将使用 Entity Framework Core (EF Core) 连接到关系数据库并构造创建、读取、更新和删除 (CRUD) 查询。