如何在EF Core中使用C# 9记录?

36

我正在使用ASP.NET Core 5 Web API,并尝试将新的C#记录用作我的模型类。但是,每当我使用with表达式更新修改后的模型时,我都会遇到EF Core跟踪问题的错误:

System.InvalidOperationException: The instance of entity type 'Product' cannot be
tracked because another instance with the key value '{ID: 2}' is already being
tracked. When attaching existing entities, ensure that only one entity instance
with a given key value is attached.

我认为这是由于“变异”记录会创建一个新的对象实例,而EF Core的跟踪系统不喜欢它,但我不确定最佳修复方式。有人有什么建议吗?还是我应该回到使用常规类而不是记录吗?

以下是重现问题的片段:

// Models/Product.cs
public record Product(int ID, string Name);


// Controllers/ProductController.cs
[HttpGet("test/{id}")]
public async Task<Product> ExampleControllerAction(int id, CancellationToken cancellationToken)
{
    string newName = "test new name!!";

    Product product = await db.Products.FindAsync(new object[] { id }, cancellationToken);
    product = product with { Name = newName }; // Modify the model.

    db.Update(product); // InvalidOperationException happens here.
    await db.SaveChangesAsync(cancellationToken);

    return product;
}

2
你使用记录类型而不是类的目的是什么?有什么好处吗?我在这里看不到任何好处。你似乎只是想尝试一些新东西,但甚至不确定何时应该使用这个新东西。如果你希望数据是不可变的并且随着时间调整为新实例,则应该使用记录类型。虽然你可以定义它来支持可变数据,但它就像类一样,并没有比类更多的好处。 - King King
8
默认情况下是不可变的,并且代码更加简洁。但似乎 EF Core 跟踪并不真正支持不可变类,所以我想我必须切换回常规类。 - Phil K
正确,EF Core更改跟踪器与记录不兼容。 - ErikEJ
1
@KingKing,感谢您对 with 的评论,我完全忽略了那个逻辑。 - Silvermind
我相信问题的关键在于 with 创建了一个带有更改的记录副本。当您将产品分配给新记录时,它会失去 EF Core 依赖于更改跟踪的对象引用。 - Sam
5个回答

53

来源于官方文档

Entity Framework Core 依赖于引用相等性来确保它在概念上为一个实体使用一个实体类型的一个实例。因此,记录类型不适合用作 Entity Framework Core 中的实体类型。

这可能会让一些人感到困惑。请仔细阅读文档。例如,record 并不适用于实体,但是对于所拥有的类型(例如在 DDD 术语中的值对象)来说却完全可以,因为此类值没有概念上的唯一标识。

例如,如果 Address 实体被建模为类,拥有 City 值对象,则按以下方式将 City 映射为 Address 中的 City_NameCity_Code 等 (即使用记录名称与属性名称连接的下划线)。

请注意,City 没有 Id,因为我们在这里并没有跟踪唯一的城市,只是跟踪名称、代码以及您可以添加到 City 中的其他信息。

无论您做什么,都不要将ID添加到记录中并尝试手动进行映射,因为对于EF而言,具有相同ID的两条记录不一定意味着是相同的概念实体。这可能是因为EF通过引用比较对象实例(记录是引用类型),而不使用标准的相等比较器,而对于记录而言,它与标准对象的相等比较器不同(需要确认)。

我自己对EF内部工作原理并不是很了解,所以无法太过肯定地发表讨论,但我相信文档,并且您应该也应该信任它们,除非您想阅读源代码。


19
如果关闭EF的更改跟踪,则应按照编写的方式运行。尝试在原始的DB调用中添加AsNoTracking()

1
Mike,你可以在元社区发布一个错误/功能请求/担忧等。 - Soleil
没有问题,这个案例加一。 - Soner from The Ottoman Empire

8

当前文档说明...

一些数据模型需要引用相等性。例如,Entity Framework Core 依赖于引用相等性来确保它仅使用一个实体类型的实例,作为概念上的一个实体。因此,在 Entity Framework Core 中,记录和记录结构不适合用作实体类型[重点是]。

- Records (C# reference)

...然而,这并不排除Mike Weiss'AsNoTracking()替代方案。


5
微软的官方声明没有意义...虽然record类型确实会override Equals,但EF和.NET一直能够通过Object.ReferenceEquals绕过自定义的Equals覆盖,并使用ConditionalWeakTable等更高级的方法进行比较——所以我不知道为什么这对他们来说显然是个问题...(在Linq表达式中使用==也无所谓:EF有Expression<>树对象,可以将==绑定到Object.ReferenceEquals而不总是使用static operator== - Dai

0
我认为使用记录类型+EF Core的一个很好的用例是编译器生成的关于相等性/比较的“帮助”。
考虑一个类似于这样的EF Core查询:
var result = 
    (from pt in context.ParentTable
    from c in pt.Children
    select new {
        pt.Id,
        pt.Name,
        c.Kinship,
        ChildName = c.Name
    }).Distinct().ToArray();

这是在EF Core中从表中检索数据的一种非常高效的方式,您不需要更新涉及的任何表。您只选择所需的列,EF Core不会跟踪输出,并且您避免了使用"select *",这是非常重要的。
在这种情况下,"Distinct"将在数据库端运行,并且性能良好。对于演示,如果您将"Distinct().ToArray()"反转为".ToArray().Distinct()",那么您将从数据库中获取所有匹配的记录,并在客户端执行Distinct。虽然不完全理想,但这样做是有效的,因为我们使用的匿名类型在内部充当记录类型。匿名类型非常好用,可以使用它们。
现在,考虑一下如果您不能使用匿名类型来重构这个东西。也许您想将此查询的结果传递给其他方法,并且不想使用动态类型。您唯一的选择就是定义一个类来包含所有数据。
public class ParentChild {
    public string Name { get;set; }
    public string Kinship { get;set; }
    public string ChildName { get;set; }
}

var result = 
    (from pt in context.ParentTable
    from c in pt.Children
    select new ParentChild() {
        Name = pt.Name,
        Kinship = c.Kinship,
        ChildName = c.Name
    }).AsEnumerable().Distinct().ToArray(); //Emphasis on running the distinct operation Client-Side, not db-side

表面上看,这似乎没问题。查询是一样的,我们只是将匿名类型更改为具体类型。但是,你将不再获得你想要的不重复记录列表!这是因为我们没有提供GetHashCode的实现,也没有重写.Equals,也没有提供IEqualityComparer给Distinct。事实上,这段代码将运行得就像没有Distinct()一样。
如果你将中间类型更改为记录类型,那么客户端的.Distinct()将会按照你的预期工作。
public record class ParentChild {
    public string Name { get;set; }
    public string Kinship { get;set; }
    public string ChildName { get;set; }
}
//Adding record automatically adds comparison stuff, suitable for sorting and distinction

根据文档所要表达的意思,我认为重要的是要注意不要将这些中间优化的查询类型之一作为实体使用(尝试将其附加到EF上下文或更改此对象,然后调用SaveChanges())。文档并不是说记录类型在其他与EF相关的用途上是禁止的,比如DTO、类型映射或ViewModels。
只是在这里添加一些额外的信息;如果你正在使用一些较早版本的C#,而record类型不可用,VS IDE有一个提取类的辅助函数,它将生成一个自定义的record类型。
public class ParentChild
{
    public ParentChild(string name, string kinship, string childName)
    {
        Name = name;
        Kinship = kinship;
        ChildName = childName;
    }

    public string Name { get; }
    public string Kinship { get; }
    public string ChildName { get; }

    public override bool Equals(object obj)
    {
        return obj is ParentChild other &&
                Name == other.Name &&
                Kinship == other.Kinship &&
                ChildName == other.ChildName;
    }

    public override int GetHashCode()
    {
        //Smaller classes will use a HashCode.Combine() overload, rather than hash.Add like below
        var hash = new HashCode();
        hash.Add(Name);
        hash.Add(Kinship);
        hash.Add(ChildName);
        return hash.ToHashCode();
    }
}

0
除了其他答案之外,我尝试使用EF Core 7中的记录来保存投影(.Select())的结果,作为使用匿名类型的替代方案。
这些记录在大部分情况下都能正常工作,但是如果你使用记录的构造函数,EF将无法再跟踪传递给构造函数的值,一旦它们作为属性返回。
例如,如果你在执行查询之前使用记录进行最后的投影,它将正常工作。
await blah.Select(o => new SomeRecord(o.Thing, o.Doodlydo))
.ToListAsync()

然而,如果你试图进一步浏览该属性,它将无法正常工作。
blah.Select(o => new SomeRecord(o.Thing, o.Doodlydo))
.Select(sr => sr.Thing.InnerThing)

EF会抛出典型的“无法翻译查询”异常,因为它不再理解记录属性是传递给记录构造函数的投影。
然而,如果你在构造函数中传入"default"来构造记录,然后设置其属性,它确实可以工作。
blah.Select(o => new SomeRecord(default, default) {
    Thing = o.Thing,
    Doodlydo = o.Doodlydo
})
.Select(sr => sr.Thing.InnerThing)

因为EF再次知道属性是什么,因为它看到了它们被设置的过程。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接