EF Core 中的标识解析

DbContext 只能跟踪一个具有任何给定主键值的实体实例。 这意味着,必须将具有相同键值的实体的多个实例解析为单个实例。 这一操作称为“标识解析”。 标识解析可确保 Entity Framework Core (EF Core) 跟踪一致的图,且实体的关系或属性值没有多义性。

提示

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

提示

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

介绍

以下代码查询了一个实体,然后尝试附加具有相同主键值的不同实例:

using var context = new BlogsContext();

var blogA = context.Blogs.Single(e => e.Id == 1);
var blogB = new Blog { Id = 1, Name = ".NET Blog (All new!)" };

try
{
    context.Update(blogB); // This will throw
}
catch (Exception e)
{
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

运行此代码将导致以下异常:

System.InvalidOperationException:无法跟踪实体类型“Blog”的实例,因为已跟踪了另一个具有键值“{Id: 1}”的实例。 附加现有实体时,请确保仅附加一个具有给定键值的实体实例。

EF Core 需要单个实例,因为:

  • 多个实例之间的属性值可能不同。 更新数据库时,EF Core 需要知道要使用哪些属性值。
  • 多个实例之间与其他实体的关系可能不同。 例如,“blogA”可能跟与“blogB”不同的文章集合相关。

在以下这些情况下通常会遇到上述异常:

  • 尝试更新实体时
  • 尝试跟踪实体的序列化图形时
  • 设置非自动生成的键值失败时
  • 为多个工作单元重复使用 DbContext 实例时

以下各部分将讨论其中每种情况。

更新实体

有多种不同的方法可使用新值更新实体,如 EF Core 中的更改跟踪显式跟踪实体所述。 下面在标识解析的上下文中概述了这些方法。 需要注意的重要一点是,每种方法都使用查询或调用 UpdateAttach 中的一个,但绝不会同时使用两者

调用更新

通常,要更新的实体不是来自我们要用于 SaveChanges 的 DbContext 上的查询。 例如,在 Web 应用程序中,可以根据 POST 请求中的信息创建实体实例。 处理此方法的最简单方法是使用 DbContext.UpdateDbSet<TEntity>.Update。 例如:

public static void UpdateFromHttpPost1(Blog blog)
{
    using var context = new BlogsContext();

    context.Update(blog);

    context.SaveChanges();
}

在这种情况下:

  • 仅创建实体的单个实例。
  • 在进行更新的过程中,不会从数据库中查询实体实例
  • 无论是否实际进行更改,所有属性值都将在数据库中更新。
  • 进行一次数据库往返。

查询,然后应用更改

通常,根据 POST 请求或类似请求中的信息创建实体时,我们实际上不知道哪些属性值已更改。 通常只更新数据库中的所有值即可,正如我们在前面的示例中的操作。 但是,如果应用程序正在处理多个实体,并且只有少量这些实体具有实际更改,则可以限制发送的更新。 可执行查询来跟踪当前存在于数据库中的实体,然后将更改应用于这些被跟踪的实体,从而实现此目的。 例如:

public static void UpdateFromHttpPost2(Blog blog)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(blog.Id);

    trackedBlog.Name = blog.Name;
    trackedBlog.Summary = blog.Summary;

    context.SaveChanges();
}

在这种情况下:

  • 仅跟踪实体的单个实例,即 Find 查询从数据库返回的实例。
  • 不使用 UpdateAttach
  • 数据库中仅更新实际已更改的属性值。
  • 数据库往返会进行两次。

EF Core 有一些帮助程序可以像这样传输属性值。 例如,PropertyValues.SetValues 将从给定的对象复制所有值,并在跟踪的对象上设置这些值:

public static void UpdateFromHttpPost3(Blog blog)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(blog.Id);

    context.Entry(trackedBlog).CurrentValues.SetValues(blog);

    context.SaveChanges();
}

SetValues 接受各种对象类型,包括数据传输对象 (DTO),其属性名称与实体类型的属性相匹配。 例如:

public static void UpdateFromHttpPost4(BlogDto dto)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(dto.Id);

    context.Entry(trackedBlog).CurrentValues.SetValues(dto);

    context.SaveChanges();
}

或包括含属性值名称/值项的字典:

public static void UpdateFromHttpPost5(Dictionary<string, object> propertyValues)
{
    using var context = new BlogsContext();

    var trackedBlog = context.Blogs.Find(propertyValues["Id"]);

    context.Entry(trackedBlog).CurrentValues.SetValues(propertyValues);

    context.SaveChanges();
}

有关使用此类属性值的详细信息,请参阅访问跟踪的实体

使用原始值

到目前为止,每种方法要么在进行更新之前执行查询,要么无论是否更改,都更新所有属性值。 若要在更新过程中仅更新已更改的值,而不执行查询,需要知道哪些属性值已更改的特定信息。 获取此信息的常用方法是在 HTTP Post 或类似内容中发回当前值和原始值。 例如:

public static void UpdateFromHttpPost6(Blog blog, Dictionary<string, object> originalValues)
{
    using var context = new BlogsContext();

    context.Attach(blog);
    context.Entry(blog).OriginalValues.SetValues(originalValues);

    context.SaveChanges();
}

在此代码中,首先附加带有修改值的实体。 这会导致 EF Core 跟踪 Unchanged 状态中的实体;即没有标记为已修改的属性值。 然后,将原始值的字典应用到此跟踪实体。 这会将不同的当前值和原始值标记为已修改的属性。 具有相同当前值和原始值的属性将不会标记为已修改。

在这种情况下:

  • 使用 Attach 仅跟踪实体的单个实例。
  • 在进行更新的过程中,不会从数据库中查询实体实例
  • 应用原始值可确保在数据库中仅更新实际更改的属性值。
  • 进行一次数据库往返。

与上一部分中的示例一样,原始值不必作为字典传递;实体实例或 DTO 也将起作用。

提示

虽然这种方法有吸引力,但它确实需要将实体的原始值发送到 Web 客户端或从 Web 客户端发送。 仔细考虑这种额外的复杂性是否值得。对于许多应用程序而言,一种更简单的方法会更实用。

附加序列化图形

EF Core 使用通过外键和导航属性连接的实体图形,如更改外键和导航中所述。 如果这些图形在 EF Core 之外创建(例如通过 JSON 文件),则可以具有同一实体的多个实例。 在跟踪图形之前,需要将这些重复项解析为单个实例。

不包含重复项的图形

在进一步操作之前,请务必确认:

  • 序列化程序通常可以选择处理图形中的循环和重复实例。
  • 选择用作图形根的对象通常有助于减少或删除重复项。

如果可能,请使用序列化选项,并选择不会导致重复的根。 例如,下面的代码使用 Json.NET 来序列化一个博客列表,其中每个博客都包含其关联的帖子:

using var context = new BlogsContext();

var blogs = context.Blogs.Include(e => e.Posts).ToList();

var serialized = JsonConvert.SerializeObject(
    blogs,
    new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, Formatting = Formatting.Indented });

Console.WriteLine(serialized);

此代码生成的 JSON 是:

[
  {
    "Id": 1,
    "Name": ".NET Blog",
    "Summary": "Posts about .NET",
    "Posts": [
      {
        "Id": 1,
        "Title": "Announcing the Release of EF Core 5.0",
        "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
        "BlogId": 1
      },
      {
        "Id": 2,
        "Title": "Announcing F# 5",
        "Content": "F# 5 is the latest version of F#, the functional programming language...",
        "BlogId": 1
      }
    ]
  },
  {
    "Id": 2,
    "Name": "Visual Studio Blog",
    "Summary": "Posts about Visual Studio",
    "Posts": [
      {
        "Id": 3,
        "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...",
        "BlogId": 2
      },
      {
        "Id": 4,
        "Title": "Database Profiling with Visual Studio",
        "Content": "Examine when database queries were executed and measure how long the take using...",
        "BlogId": 2
      }
    ]
  }
]

请注意,JSON 中没有重复的博客或文章。 这意味着,对 Update 的简单调用将可用于更新数据库中的这些实体:

public static void UpdateBlogsFromJson(string json)
{
    using var context = new BlogsContext();

    var blogs = JsonConvert.DeserializeObject<List<Blog>>(json);

    foreach (var blog in blogs)
    {
        context.Update(blog);
    }

    context.SaveChanges();
}

处理重复项

上一示例中的代码将每个博客及其关联的文章序列化。 如果将其更改为将每篇文章与其关联的博客进行序列化,则会将重复项引入序列化的 JSON。 例如:

using var context = new BlogsContext();

var posts = context.Posts.Include(e => e.Blog).ToList();

var serialized = JsonConvert.SerializeObject(
    posts,
    new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore, Formatting = Formatting.Indented });

Console.WriteLine(serialized);

现在,序列化的 JSON 如下所示:

[
  {
    "Id": 1,
    "Title": "Announcing the Release of EF Core 5.0",
    "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
    "BlogId": 1,
    "Blog": {
      "Id": 1,
      "Name": ".NET Blog",
      "Summary": "Posts about .NET",
      "Posts": [
        {
          "Id": 2,
          "Title": "Announcing F# 5",
          "Content": "F# 5 is the latest version of F#, the functional programming language...",
          "BlogId": 1
        }
      ]
    }
  },
  {
    "Id": 2,
    "Title": "Announcing F# 5",
    "Content": "F# 5 is the latest version of F#, the functional programming language...",
    "BlogId": 1,
    "Blog": {
      "Id": 1,
      "Name": ".NET Blog",
      "Summary": "Posts about .NET",
      "Posts": [
        {
          "Id": 1,
          "Title": "Announcing the Release of EF Core 5.0",
          "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
          "BlogId": 1
        }
      ]
    }
  },
  {
    "Id": 3,
    "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...",
    "BlogId": 2,
    "Blog": {
      "Id": 2,
      "Name": "Visual Studio Blog",
      "Summary": "Posts about Visual Studio",
      "Posts": [
        {
          "Id": 4,
          "Title": "Database Profiling with Visual Studio",
          "Content": "Examine when database queries were executed and measure how long the take using...",
          "BlogId": 2
        }
      ]
    }
  },
  {
    "Id": 4,
    "Title": "Database Profiling with Visual Studio",
    "Content": "Examine when database queries were executed and measure how long the take using...",
    "BlogId": 2,
    "Blog": {
      "Id": 2,
      "Name": "Visual Studio Blog",
      "Summary": "Posts about Visual Studio",
      "Posts": [
        {
          "Id": 3,
          "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...",
          "BlogId": 2
        }
      ]
    }
  }
]

请注意,图形现在包含多个具有相同键值的 Blog 实例,还包括多个具有相同键值的 Post 实例。 尝试跟踪此图形(如前面示例中的操作)将引发:

System.InvalidOperationException:无法跟踪实体类型“Post”的实例,因为已跟踪了另一个具有键值“{Id: 2}”的实例。 附加现有实体时,请确保仅附加一个具有给定键值的实体实例。

可以通过两种方式解决此问题:

  • 使用保留引用的 JSON 序列化选项
  • 跟踪图形时执行标识解析

保留引用

Json.NET 提供了 PreserveReferencesHandling 选项来处理此问题。 例如:

var serialized = JsonConvert.SerializeObject(
    posts,
    new JsonSerializerSettings
    {
        PreserveReferencesHandling = PreserveReferencesHandling.All, Formatting = Formatting.Indented
    });

生成的 JSON 现在如下所示:

{
  "$id": "1",
  "$values": [
    {
      "$id": "2",
      "Id": 1,
      "Title": "Announcing the Release of EF Core 5.0",
      "Content": "Announcing the release of EF Core 5.0, a full featured cross-platform...",
      "BlogId": 1,
      "Blog": {
        "$id": "3",
        "Id": 1,
        "Name": ".NET Blog",
        "Summary": "Posts about .NET",
        "Posts": [
          {
            "$ref": "2"
          },
          {
            "$id": "4",
            "Id": 2,
            "Title": "Announcing F# 5",
            "Content": "F# 5 is the latest version of F#, the functional programming language...",
            "BlogId": 1,
            "Blog": {
              "$ref": "3"
            }
          }
        ]
      }
    },
    {
      "$ref": "4"
    },
    {
      "$id": "5",
      "Id": 3,
      "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...",
      "BlogId": 2,
      "Blog": {
        "$id": "6",
        "Id": 2,
        "Name": "Visual Studio Blog",
        "Summary": "Posts about Visual Studio",
        "Posts": [
          {
            "$ref": "5"
          },
          {
            "$id": "7",
            "Id": 4,
            "Title": "Database Profiling with Visual Studio",
            "Content": "Examine when database queries were executed and measure how long the take using...",
            "BlogId": 2,
            "Blog": {
              "$ref": "6"
            }
          }
        ]
      }
    },
    {
      "$ref": "7"
    }
  ]
}

请注意,此 JSON 已将重复项替换为 "$ref": "5" 之类的引用,它们会引用图形中已存在的实例。 可以使用简单的 Update 调用再次跟踪该图形,如上所示。

.NET 基类库 (BCL) 中的 System.Text.Json 支持具有类似的选项,可产生相同的结果。 例如:

var serialized = JsonSerializer.Serialize(
    posts, new JsonSerializerOptions { ReferenceHandler = ReferenceHandler.Preserve, WriteIndented = true });

解析重复项

如果无法在序列化过程中消除重复项,则 ChangeTracker.TrackGraph 会提供一种方法来处理这种情况。 TrackGraph 的工作方式与 AddAttachUpdate 类似,不同之处在于它会在跟踪前为每个实体实例生成回叫。 此回叫可用于跟踪实体或忽略它。 例如:

public static void UpdatePostsFromJsonWithIdentityResolution(string json)
{
    using var context = new BlogsContext();

    var posts = JsonConvert.DeserializeObject<List<Post>>(json);

    foreach (var post in posts)
    {
        context.ChangeTracker.TrackGraph(
            post, node =>
            {
                var keyValue = node.Entry.Property("Id").CurrentValue;
                var entityType = node.Entry.Metadata;

                var existingEntity = node.Entry.Context.ChangeTracker.Entries()
                    .FirstOrDefault(
                        e => Equals(e.Metadata, entityType)
                             && Equals(e.Property("Id").CurrentValue, keyValue));

                if (existingEntity == null)
                {
                    Console.WriteLine($"Tracking {entityType.DisplayName()} entity with key value {keyValue}");

                    node.Entry.State = EntityState.Modified;
                }
                else
                {
                    Console.WriteLine($"Discarding duplicate {entityType.DisplayName()} entity with key value {keyValue}");
                }
            });
    }

    context.SaveChanges();
}

对于图形中的每个实体,此代码将:

  • 查找实体的实体类型和键值
  • 在更改跟踪器中查找具有此键的实体
    • 如果找到该实体,则不会采取进一步行动,因为该实体是一个重复项
    • 如果找不到该实体,则通过将状态设置为 Modified 来进行跟踪

运行此代码得到的输出如下:

Tracking EntityType: Post entity with key value 1
Tracking EntityType: Blog entity with key value 1
Tracking EntityType: Post entity with key value 2
Discarding duplicate EntityType: Post entity with key value 2
Tracking EntityType: Post entity with key value 3
Tracking EntityType: Blog entity with key value 2
Tracking EntityType: Post entity with key value 4
Discarding duplicate EntityType: Post entity with key value 4

重要

此代码假定所有重复项都相同。 这样一来,就可以随意选择其中一个重复项来跟踪,而放弃其他重复项。 如果重复项可能不同,则代码将需要决定如何确定使用哪一个,以及如何将属性和导航值组合在一起。

注意

为简单起见,此代码假定每个实体都有一个称为 Id 的主键属性。 可能会将该属性编码为抽象基类或接口。 或者,可以从 IEntityType 元数据获取一个或多个主键属性,使此代码适用于任何类型的实体。

未能设置键值

实体类型通常配置为使用自动生成的键值。 这是非复合键的整数和 GUID 属性的默认值。 但是,如果未将实体类型配置为使用自动生成的键值,则在跟踪该实体之前必须设置显式键值。 例如,使用以下实体类型:

public class Pet
{
    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public int Id { get; set; }

    public string Name { get; set; }
}

考虑在不设置键值的情况下尝试跟踪两个新实体实例的代码:

using var context = new BlogsContext();

context.Add(new Pet { Name = "Smokey" });

try
{
    context.Add(new Pet { Name = "Clippy" }); // This will throw
}
catch (Exception e)
{
    Console.WriteLine($"{e.GetType().FullName}: {e.Message}");
}

此代码将引发:

System.InvalidOperationException:无法跟踪实体类型“Pet”的实例,因为已跟踪了另一个具有键值“{Id: 0}”的实例。 附加现有实体时,请确保仅附加一个具有给定键值的实体实例。

解决此问题的方法是显式设置键值,或将键属性配置为使用生成的键值。 有关详细信息,请参阅生成的值

过度使用单个 DbContext 实例

DbContext 旨在表示短期工作单元,如 DbContext 初始化和配置中所述,以及 EF Core 中的更改跟踪中的详细阐述。 不遵循此指南很容易遇到尝试跟踪同一实体的多个实例的情况。 常见示例包括:

  • 使用同一 DbContext 实例来设置测试状态,然后执行测试。 通常,这会导致 DbContext 仍然跟踪测试设置中的一个实体实例,然后尝试在测试中附加一个新实例。 请改用不同的 DbContext 实例来设置测试状态和测试代码。
  • 在存储库或类似的代码中使用共享的 DbContext 实例。 相反,请确保存储库对每个工作单元都使用单个 DbContext 实例。

标识解析和查询

当从查询跟踪实体时,将自动进行标识解析。 这意味着,如果已跟踪具有给定键值的实体实例,则会使用这个现有跟踪实例,而不是创建新的实例。 这样做的一个重要后果是:如果数据库中的数据发生了更改,则不会在查询结果中反映出来。 这是为每个工作单元使用新 DbContext 实例的一个充分理由,如 DbContext 初始化和配置中所述,以及 EF Core 中的更改跟踪中的详细阐述。

重要

请务必了解 EF Core 始终针对数据库在 DbSet 上执行 LINQ 查询,并且仅根据数据库中的内容返回结果。 但是,对于跟踪查询,如果返回的实体已被跟踪,则使用被跟踪的实例(而不是根据数据库中的数据)创建实例。

当需要使用数据库中的最新数据刷新跟踪的实体时,可以使用 Reload()GetDatabaseValues()。 有关详细信息,请参阅访问跟踪的实体

与跟踪查询不同,无跟踪查询不会执行标识解析。 这意味着无跟踪查询可以返回重复项,如同前面所述的 JSON 序列化案例。 如果查询结果将进行序列化并发送到客户端,这通常不是问题。

提示

请勿经常执行无跟踪查询,然后将返回的实体附加到同一个上下文。 这会比使用跟踪查询更慢且更难正确处理。

无跟踪查询不会执行标识解析,因为这样做会影响从查询对大量实体进行流式处理的性能。 这是因为标识解析需要跟踪返回的每个实例,以便可以使用它而不是稍后创建重复项。

可以使用 AsNoTrackingWithIdentityResolution<TEntity>(IQueryable<TEntity>) 使 no-tracking 查询强制执行标识解析。 查询随后将跟踪返回的实例(无需按正常方式跟踪它们),并确保在查询结果中没有创建重复项。

替代对象相等性

EF Core 在比较实体实例时会使用引用相等性。 即使实体类型替代 Object.Equals(Object) 或更改对象相等性,情况也是如此。 但有一种情况,替代相等性会影响 EF Core 行为:如果集合导航使用替代的相等性而不是引用相等性,这时会将多个实例报告为相同。

因此,建议避免替代实体相等性。 如果使用了替代,请确保创建可强制实施引用相等性的集合导航。 例如,创建使用引用相等性的相等比较器:

public sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
    private ReferenceEqualityComparer()
    {
    }

    public static ReferenceEqualityComparer Instance { get; } = new ReferenceEqualityComparer();

    bool IEqualityComparer<object>.Equals(object x, object y) => x == y;

    int IEqualityComparer<object>.GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}

(从 .NET 5 开始,这将作为 ReferenceEqualityComparer 包含在 BCL 中。)

然后可以在创建集合导航时使用此比较器。 例如:

public ICollection<Order> Orders { get; set; }
    = new HashSet<Order>(ReferenceEqualityComparer.Instance);

比较键属性

除了比较相等性,还需要对键值进行排序。 这对于在对 SaveChanges 的单个调用中更新多个实体时避免死锁非常重要。 用于主键、备用键或外键属性的所有类型,以及用于唯一索引的类型,都必须实现 IComparable<T>IEquatable<T>。 通常用作键(int、Guid、string 等)的类型已经支持这些接口。 自定义键类型可能会添加这些接口。