其他更改跟踪功能

本文档介绍涉及更改跟踪的其他功能和场景。

提示

本文档假设你已了解实体状态和 EF Core 更改跟踪的基础知识。 有关这些主题的详细信息,请参阅 EF Core 中的更改跟踪

提示

通过从 GitHub 下载示例代码,你可运行并调试到本文档中的所有代码。

AddAddAsync

每当使用该方法可能导致数据库交互时,Entity Framework Core (EF Core) 都提供异步方法。 在使用不支持高性能异步访问的数据库时,还提供了同步方法来避免开销。

DbContext.AddDbSet<TEntity>.Add 通常不会访问数据库,因为这些方法本质上只是开始跟踪实体。 但是,某些形式的值生成可能会访问数据库以生成键值。 执行此操作并附带 EF Core 的唯一值生成器是 HiLoValueGenerator<TValue>。 使用此生成器并不常见;默认情况下从不配置它。 这意味着,绝大多数应用程序都应使用 Add,而不是 AddAsync

其他类似方法,如 UpdateAttachRemove 没有异步重载,因为它们从不生成新键值,因此从不需要访问数据库。

AddRangeUpdateRangeAttachRangeRemoveRange

DbSet<TEntity>DbContext 提供可在单次调用中接受多个实例的 AddUpdateAttachRemove 备用版本。 这些方法分别为 AddRangeUpdateRangeAttachRangeRemoveRange

为方便起见,提供了这些方法。 使用“range”方法的功能与多次调用等效的非 range 方法相同。 这两种方法之间没有明显的性能差异。

注意

这不同于 EF6,其中 AddRangeAdd 都自动调用 DetectChanges,但多次调用 Add 会导致多次调用 DetectChanges,而不是一次。 这使得 AddRange 在 EF6 中的效率更高。 在 EF Core中,这两种方法都不会自动调用 DetectChanges

DbContext 与 DbSet 方法

许多方法(包括 AddUpdateAttachRemove)在 DbSet<TEntity>DbContext 上都有实现。 对于普通实体类型,这些方法具有完全相同的行为。 这是因为实体的 CLR 类型映射到 EF Core 模型中的一个且只有一个实体类型。 因此,CLR 类型完全定义了实体在模型中的适用位置,因此可以隐式确定要使用的 DbSet。

此规则的例外情况是使用共享类型实体类型,这些类型主要用于多对多联接实体。 使用共享类型实体类型时,必须先为所使用的 EF Core 模型类型创建 DbSet。 然后,可以在 DbSet 上使用 AddUpdateAttachRemove 等方法,而不会对所使用的 EF Core 模型类型产生任何多义情况。

默认情况下,共享类型实体类型用于多对多关系中的联接实体。 还可以显式配置共享类型实体类型,以用于多对多关系。 例如,下面的代码将 Dictionary<string, int> 配置为联接实体类型:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .SharedTypeEntity<Dictionary<string, int>>(
            "PostTag",
            b =>
            {
                b.IndexerProperty<int>("TagId");
                b.IndexerProperty<int>("PostId");
            });

    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(p => p.Posts)
        .UsingEntity<Dictionary<string, int>>(
            "PostTag",
            j => j.HasOne<Tag>().WithMany(),
            j => j.HasOne<Post>().WithMany());
}

更改外键和导航演示了如何通过跟踪新的联接实体实例来关联两个实体。 下面的代码对用于联接实体的 Dictionary<string, int> 共享类型实体类型执行此操作:

using var context = new BlogsContext();

var post = context.Posts.Single(e => e.Id == 3);
var tag = context.Tags.Single(e => e.Id == 1);

var joinEntitySet = context.Set<Dictionary<string, int>>("PostTag");
var joinEntity = new Dictionary<string, int> { ["PostId"] = post.Id, ["TagId"] = tag.Id };
joinEntitySet.Add(joinEntity);

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

context.SaveChanges();

请注意,DbContext.Set<TEntity>(String) 用于为 PostTag 实体类型创建 DbSet。 然后,可以使用此 DbSet 来调用 Add 和新的联接实体实例。

重要

按照约定,用于联接实体类型的 CLR 类型可能会在未来版本中更改以提高性能。 不要依赖于任何特定的联接实体类型,除非已在上述代码中对 Dictionary<string, int> 所做的那样显式配置。

属性与字段访问

默认情况下,对实体属性的访问使用属性的支持字段。 这很有效,可避免调用属性 getter 和 setter 时引发副作用。 例如,这就是延迟加载能够避免触发无限循环的原因所在。 有关在模型中配置支持字段的详细信息,请参阅支持字段

有时,在修改属性值时,EF Core 产生副作用是可取的。 例如,当数据绑定到实体时,设置属性可能会向 U.I. 生成通知,这在直接设置字段时不会发生。 可以通过更改以下项的 PropertyAccessMode 来实现此目的:

属性访问模式 FieldPreferField 都将使 EF Core 通过其支持字段访问属性值。 同样,PropertyPreferProperty 都将将使 EF Core 通过其 getter 和 setter 访问属性值。

如果使用 FieldProperty,EF Core不能分别通过字段或属性 getter/setter 访问该值,则 EF Core 将引发异常。 这可确保 EF Core 始终使用字段/属性访问。

另一方面,如果无法使用首选访问,则 PreferFieldPreferProperty 模式将分别回退到分别使用属性或支持字段。 PreferField 是默认值。 这意味着 EF Core 将尽可能使用字段,但如果必须改为通过其 getter 或 setter 访问属性,则不会失败。

FieldDuringConstructionPreferFieldDuringConstruction 将 EF Core 配置为仅在创建实体实例时使用支持字段。 这允许在执行查询时不存在 getter 和 setter 副作用,而随后 EF Core 的属性更改将导致这些副作用。

下表汇总了不同的属性访问模式:

PropertyAccessMode 首选项 创建实体的首选项 回退 创建实体的回退
Field 字段 字段 引发 引发
Property properties properties 引发 引发
PreferField 字段 字段 properties properties
PreferProperty properties properties 字段 字段
FieldDuringConstruction properties 字段 字段 引发
PreferFieldDuringConstruction properties 字段 字段 properties

临时值

EF Core 在跟踪新实体时创建临时密钥值,当调用 SaveChanges 时,这些新实体将具有数据库生成的实际密钥值。 有关如何使用这些临时值的概述,请参阅 EF Core 中的更改跟踪

访问临时值

临时值存储在更改跟踪器中,而不是直接设置到实体实例。 但是,当使用用于访问跟踪实体的各种机制时,这些临时值会公开。 例如,以下代码使用 EntityEntry.CurrentValues 访问临时值:

using var context = new BlogsContext();

var blog = new Blog { Name = ".NET Blog" };

context.Add(blog);

Console.WriteLine($"Blog.Id set on entity is {blog.Id}");
Console.WriteLine($"Blog.Id tracked by EF is {context.Entry(blog).Property(e => e.Id).CurrentValue}");

此代码的输出为:

Blog.Id set on entity is 0
Blog.Id tracked by EF is -2147482643

PropertyEntry.IsTemporary 可用于检查临时值。

操作临时值

对于显式处理临时值,有时很有用。 例如,可以在 Web 客户端上创建一组新实体,然后将其序列化回服务器。 外键值是在这些实体之间建立关系的一种方法。 下面的代码使用此方法通过外键关联新实体的关系图,同时仍然允许在调用 SaveChanges 时生成实际键值。

var blogs = new List<Blog> { new Blog { Id = -1, Name = ".NET Blog" }, new Blog { Id = -2, Name = "Visual Studio Blog" } };

var posts = new List<Post>
{
    new Post
    {
        Id = -1,
        BlogId = -1,
        Title = "Announcing the Release of EF Core 5.0",
        Content = "Announcing the release of EF Core 5.0, a full featured cross-platform..."
    },
    new Post
    {
        Id = -2,
        BlogId = -2,
        Title = "Disassembly improvements for optimized managed debugging",
        Content = "If you are focused on squeezing out the last bits of performance for your .NET service or..."
    }
};

using var context = new BlogsContext();

foreach (var blog in blogs)
{
    context.Add(blog).Property(e => e.Id).IsTemporary = true;
}

foreach (var post in posts)
{
    context.Add(post).Property(e => e.Id).IsTemporary = true;
}

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

context.SaveChanges();

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

请注意:

  • 负数作为临时键值使用;这不是必需的,但却是防止密钥冲突的常见约定。
  • Post.BlogId FK 属性分配与关联博客的 PK 相同的负值。
  • 跟踪每个实体后,可通过设置 IsTemporary 将 PK 值标记为临时。 这是必需的,因为应用程序提供的任何密钥值都假定为一个实际键值。

在调用 SaveChanges 之前查看更改跟踪器调试视图表明 PK 值标记为临时,并且文章与正确的博客关联,其中包括导航的修正:

Blog {Id: -2} Added
  Id: -2 PK Temporary
  Name: 'Visual Studio Blog'
  Posts: [{Id: -2}]
Blog {Id: -1} Added
  Id: -1 PK Temporary
  Name: '.NET Blog'
  Posts: [{Id: -1}]
Post {Id: -2} Added
  Id: -2 PK Temporary
  BlogId: -2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: -2}
  Tags: []
Post {Id: -1} Added
  Id: -1 PK Temporary
  BlogId: -1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: -1}

调用 SaveChanges 之后,这些临时值已替换为由数据库生成的实际值:

Blog {Id: 1} Unchanged
  Id: 1 PK
  Name: '.NET Blog'
  Posts: [{Id: 1}]
Blog {Id: 2} Unchanged
  Id: 2 PK
  Name: 'Visual Studio Blog'
  Posts: [{Id: 2}]
Post {Id: 1} Unchanged
  Id: 1 PK
  BlogId: 1 FK
  Content: 'Announcing the release of EF Core 5.0, a full featured cross...'
  Title: 'Announcing the Release of EF Core 5.0'
  Blog: {Id: 1}
  Tags: []
Post {Id: 2} Unchanged
  Id: 2 PK
  BlogId: 2 FK
  Content: 'If you are focused on squeezing out the last bits of perform...'
  Title: 'Disassembly improvements for optimized managed debugging'
  Blog: {Id: 2}
  Tags: []

使用默认值

EF Core 允许属性在调用 SaveChanges 时从数据库获取其默认值。 与生成的键值一样,如果没有显式设置值,EF Core 将仅从数据库使用默认值。 例如,请考虑以下实体类型:

public class Token
{
    public int Id { get; set; }
    public string Name { get; set; }
    public DateTime ValidFrom { get; set; }
}

ValidFrom 属性配置为从数据库获取默认值:

modelBuilder
    .Entity<Token>()
    .Property(e => e.ValidFrom)
    .HasDefaultValueSql("CURRENT_TIMESTAMP");

插入此类型的实体时,EF Core 将使数据库生成值,除非已设置显式值。 例如:

using var context = new BlogsContext();

context.AddRange(
    new Token { Name = "A" },
    new Token { Name = "B", ValidFrom = new DateTime(1111, 11, 11, 11, 11, 11) });

context.SaveChanges();

Console.WriteLine(context.ChangeTracker.DebugView.LongView);

查看更改跟踪器调试视图会显示第一个令牌由数据库生成 ValidFrom,而第二个令牌使用显式设置的值:

Token {Id: 1} Unchanged
  Id: 1 PK
  Name: 'A'
  ValidFrom: '12/30/2020 6:36:06 PM'
Token {Id: 2} Unchanged
  Id: 2 PK
  Name: 'B'
  ValidFrom: '11/11/1111 11:11:11 AM'

注意

使用数据库默认值要求数据库列已配置默认值约束。 使用 HasDefaultValueSqlHasDefaultValue 时,EF Core 迁移会自动完成此操作。 在不使用 EF Core 迁移时,请确保以其他方式在列上创建默认约束。

使用“可为空”属性

EF Core 能够通过将属性值与该类型的 CLR 默认值进行比较来确定是否已设置了该属性。 这在大多数情况下非常有效,但意味着无法将 CLR 默认值显式插入数据库中。 例如,请考虑一个具有整数属性的实体:

public class Foo1
{
    public int Id { get; set; }
    public int Count { get; set; }
}

其中,该属性配置为具有数据库默认值 -1:

modelBuilder
    .Entity<Foo1>()
    .Property(e => e.Count)
    .HasDefaultValue(-1);

目的是在未设置显式值的情况下均使用默认值 -1。 但是,如果将值设置为 0(整数的 CLR 默认值)与 EF Core 无法区分,这意味着不能为此属性插入 0。 例如:

using var context = new BlogsContext();

var fooA = new Foo1 { Count = 10 };
var fooB = new Foo1 { Count = 0 };
var fooC = new Foo1();

context.AddRange(fooA, fooB, fooC);
context.SaveChanges();

Debug.Assert(fooA.Count == 10);
Debug.Assert(fooB.Count == -1); // Not what we want!
Debug.Assert(fooC.Count == -1);

请注意,Count 显式设置为 0 的实例仍从数据库中获取默认值,而这不是我们预期的值。 处理此操作的一种简单方法是让 Count 属性可为空:

public class Foo2
{
    public int Id { get; set; }
    public int? Count { get; set; }
}

这会使 CLR 默认为 NULL,而不是 0,这意味着在显式设置时,将插入 0:

using var context = new BlogsContext();

var fooA = new Foo2 { Count = 10 };
var fooB = new Foo2 { Count = 0 };
var fooC = new Foo2();

context.AddRange(fooA, fooB, fooC);
context.SaveChanges();

Debug.Assert(fooA.Count == 10);
Debug.Assert(fooB.Count == 0);
Debug.Assert(fooC.Count == -1);

使用可为空的支持字段

使属性可为空的问题是,属性在域模型中在概念上可能不可为空。 如果强制属性可为空,则会损害模型。

属性可以保留为不可为 null,并且只有支持字段可为空。 例如:

public class Foo3
{
    public int Id { get; set; }

    private int? _count;

    public int Count
    {
        get => _count ?? -1;
        set => _count = value;
    }
}

这允许在将属性显式设置为 0 的情况下插入 CLR 默认值 (0),而无需在域模型中将属性公开为可为空。 例如:

using var context = new BlogsContext();

var fooA = new Foo3 { Count = 10 };
var fooB = new Foo3 { Count = 0 };
var fooC = new Foo3();

context.AddRange(fooA, fooB, fooC);
context.SaveChanges();

Debug.Assert(fooA.Count == 10);
Debug.Assert(fooB.Count == 0);
Debug.Assert(fooC.Count == -1);

布尔属性的可为空支持字段

当使用具有存储生成的默认值的 bool 属性时,此模式特别有用。 由于 bool 的 CLR 默认值是“false”,这意味着不能使用正常模式显式插入“false”。 例如,考虑 User 实体类型:

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }

    private bool? _isAuthorized;

    public bool IsAuthorized
    {
        get => _isAuthorized ?? true;
        set => _isAuthorized = value;
    }
}

IsAuthorized 属性配置为数据库默认值“true”:

modelBuilder
    .Entity<User>()
    .Property(e => e.IsAuthorized)
    .HasDefaultValue(true);

IsAuthorized 属性可以在插入前显式设置为“true”或“false”,也可以不设置,不设置时便会使用数据库默认值:

using var context = new BlogsContext();

var userA = new User { Name = "Mac" };
var userB = new User { Name = "Alice", IsAuthorized = true };
var userC = new User { Name = "Baxter", IsAuthorized = false }; // Always deny Baxter access!

context.AddRange(userA, userB, userC);

context.SaveChanges();

使用 SQLite 时,SaveChanges 的输出显示数据库默认值用于 Mac,而显式值为 Alice 和 Baxter 设置:

-- Executed DbCommand (0ms) [Parameters=[@p0='Mac' (Size = 3)], CommandType='Text', CommandTimeout='30']
INSERT INTO "User" ("Name")
VALUES (@p0);
SELECT "Id", "IsAuthorized"
FROM "User"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

-- Executed DbCommand (0ms) [Parameters=[@p0='True' (DbType = String), @p1='Alice' (Size = 5)], CommandType='Text', CommandTimeout='30']
INSERT INTO "User" ("IsAuthorized", "Name")
VALUES (@p0, @p1);
SELECT "Id"
FROM "User"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

-- Executed DbCommand (0ms) [Parameters=[@p0='False' (DbType = String), @p1='Baxter' (Size = 6)], CommandType='Text', CommandTimeout='30']
INSERT INTO "User" ("IsAuthorized", "Name")
VALUES (@p0, @p1);
SELECT "Id"
FROM "User"
WHERE changes() = 1 AND "rowid" = last_insert_rowid();

仅限架构默认值

有时,通过 EF Core 迁移创建的数据库架构中的默认值非常有用,EF Core 不会使用这些值进行插入。 可以通过将属性配置为 PropertyBuilder.ValueGeneratedNever 来实现此目的,例如:

modelBuilder
    .Entity<Bar>()
    .Property(e => e.Count)
    .HasDefaultValue(-1)
    .ValueGeneratedNever();