实体类型的实例无法被跟踪,因为已经有相同主键的另一个实例正在被跟踪。

179

我有一个 Service Object 叫做 Update

public bool Update(object original, object modified)
{
    var originalClient = (Client)original;
    var modifiedClient = (Client)modified;
    _context.Clients.Update(originalClient); //<-- throws the error
    _context.SaveChanges();
    //Variance checking and logging of changes between the modified and original
}

这里是我从哪里调用此方法的地方:

public IActionResult Update(DetailViewModel vm)
{
    var originalClient = (Client)_service.GetAsNoTracking(vm.ClientId);
    var modifiedClient = (Client)_service.Fetch(vm.ClientId.ToString());
    // Changing the modifiedClient here
    _service.Update(originalClient, modifiedClient);
}

这里是GetAsNotTracking方法:

public Client GetAsNoTracking(long id)
{
    return GetClientQueryableObject(id).AsNoTracking().FirstOrDefault();
}

Fetch 方法:

public object Fetch(string id)
{
   long fetchId;
   long.TryParse(id, out fetchId);
   return GetClientQueryableObject(fetchId).FirstOrDefault();
}

GetClientQueryableObject:

private Microsoft.Data.Entity.Query.IIncludableQueryable<Client, ActivityType> GetClientQueryableObject(long searchId)
{
    return _context.Clients
        .Where(x => x.Id == searchId)
        .Include(x => x.Opportunities)
        .ThenInclude(x => x.BusinessUnit)
        .Include(x => x.Opportunities)
        .ThenInclude(x => x.Probability)
        .Include(x => x.Industry)
        .Include(x => x.Activities)
        .ThenInclude(x => x.User)
        .Include(x => x.Activities)
        .ThenInclude(x => x.ActivityType);
 }

有什么想法吗?

我查阅了以下文章/讨论,但都没有结果:ASP.NET GitHub 问题3839

更新:

这是对GetAsNoTracking的更改:

public Client GetAsNoTracking(long id)
{
    return GetClientQueryableObjectAsNoTracking(id).FirstOrDefault();
}

GetClientQueryableObjectAsNoTracking

private IQueryable<Client> GetClientQueryableObjectAsNoTracking(long searchId)
{
    return _context.Clients
        .Where(x => x.Id == searchId)
        .Include(x => x.Opportunities)
        .ThenInclude(x => x.BusinessUnit)
        .AsNoTracking()
        .Include(x => x.Opportunities)
        .ThenInclude(x => x.Probability)
        .AsNoTracking()
        .Include(x => x.Industry)
        .AsNoTracking()
        .Include(x => x.Activities)
        .ThenInclude(x => x.User)
        .AsNoTracking()
        .Include(x => x.Activities)
        .ThenInclude(x => x.ActivityType)
        .AsNoTracking();
}

5
我认为你放置 AsNoTracking() 的位置可能有些晚了,因为 _context.Clients 已经被跟踪了。尝试直接在 _context.Clients 调用上放置 AsNoTracking()。除非你计划跟踪它们,否则所有的 Include 应该都使用 AsNoTracking() - Robert Harvey
我可以问一下你为什么要获取原始数据和修改后的数据吗?你为什么不只获取原始数据,进行修改,然后调用更新函数呢?我不理解获取修改后的数据的意义。 - garethb
@garethb 我需要做一个审计日志。因此,我有一个差异检查器,用于检查两个对象中的更改并将其记录到数据库中。 - Rijnhardt
1
如果您对修改后和原始数据都调用GetAsNoTracking会发生什么? - garethb
3
记录一下:只需要一个AsNoTracking()调用,无论在哪里。 - Gert Arnold
显示剩余7条评论
24个回答

138

不需要覆盖EF跟踪系统,您也可以在保存之前分离“本地”条目并附加您的更新条目:

// 
var local = _context.Set<YourEntity>()
    .Local
    .FirstOrDefault(entry => entry.Id.Equals(entryId));

// check if local is not null 
if (local != null)
{
    // detach
    _context.Entry(local).State = EntityState.Detached;
}
// set Modified flag in your entry
_context.Entry(entryToUpdate).State = EntityState.Modified;

// save 
_context.SaveChanges();

更新: 为避免代码冗余,您可以使用扩展方法:

public static void DetachLocal<T>(this DbContext context, T t, string entryId) 
    where T : class, IIdentifier 
{
    var local = context.Set<T>()
        .Local
        .FirstOrDefault(entry => entry.Id.Equals(entryId));
    if (!local.IsNull())
    {
        context.Entry(local).State = EntityState.Detached;
    }
    context.Entry(t).State = EntityState.Modified;
}

我的IIdentifier接口只有一个Id字符串属性。

无论你的实体是什么,你都可以在上下文中使用这个方法:

_context.DetachLocal(tmodel, id);
_context.SaveChanges();

我正在尝试在通用存储库中完成此操作,但是我无法访问实体的主键(它可能具有任何名称和任何类型)。在这种情况下,是否有一种方法可以查找本地跟踪的实体?显然,EF可以在内部执行此操作,但我找不到公共API。 - Blisco
1
注意:此代码需要 local.IsNull() 的 LanguageExt 包支持... <PackageReference Include="LanguageExt.Core" Version="4.2.1" />。该函数未内置于 EF Core 中。 - psiodrake

52
public async Task<Product> GetValue(int id)
{
    Product Products = await _context.Products
        .AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
    return Products;
}

AsNoTracking()

重要提示:在许多情况下使用AsNoTracking()非常有效。但是,如果使用像Moq这样的框架进行模拟或存根策略,则单元测试将失败。MSDN的官方解释如下:

然而,正确地对DbSet查询功能进行模拟是不可能的,因为查询是通过LINQ运算符表达的,这些运算符是IQueryable上的静态扩展方法调用。因此,当有些人谈论“模拟DbSet”时,他们实际上是创建了一个由内存集合支持的DbSet,然后在内存中评估对该集合的查询运算符,就像简单的IEnumerable一样。这不是一个模拟,而是一种假设,在其中,内存集合替换了真实数据库。

避免在尝试使用模拟策略进行单元测试时产生未来问题。还有其他的单元测试方式,例如实现知名于Microsoft文档中提到的仓储模式。


对我有用...! - Chandan Y S
1
运行得非常顺利。 - Niklaus
使用AsNoTracking在加载具有许多相关实体的实体时可能会带来很多问题。 - valentasm

46
对我来说,这只是解决了问题。在任何更新之前添加这段代码。
_context.ChangeTracker.Clear()

Microsoft文档

停止跟踪所有当前跟踪的实体。

DbContext的设计是具有短生命周期的,每个工作单元都会创建一个新实例。这种方式意味着当上下文在每个工作单元结束时被释放时,所有跟踪的实体都会被丢弃。然而,在无法创建新的上下文实例的情况下,使用此方法清除所有跟踪的实体可能是有用的。

与分离每个跟踪的实体相比,始终应优先选择此方法。分离实体是一个缓慢的过程,可能会产生副作用。此方法在清除上下文中的所有跟踪实体方面更加高效。

请注意,此方法不会生成StateChanged事件,因为实体不是单独分离的。

更新

Microsoft解释简单的工作单元

更新

最好不要调用Update()方法。只需在查询需要修改的对象时使用跟踪功能,然后调用SaveChanges()方法,它将以优化的方式仅更新已编辑的字段。这就是EfCore跟踪的工作方式。如果使用得当,跟踪是一个很好的功能。

如果在您的项目中禁用了跟踪,或者由于其他原因,那么这个我创建的方法就是优化的解决方案,用于更新EFCore的对象。调用它而不是DbContextUpdate()方法,它将检查对象是否被跟踪。如果被跟踪,则不执行任何操作。如果对象未被跟踪,则手动执行跟踪工作。之后只需调用SaveChanges()方法,它将创建优化的SQL语句,仅更新已更改的字段。这也将帮助您如果您想要为您的应用程序创建审计功能,以便轻松跟踪每一行数据的更改。
public void UpdateIfNoTracking(TEntity entityToUpdate) where TEntity : class
{
     var keys = GetPrimaryKeys(context, entityToUpdate);

     bool tracked = context.Entry(entityToUpdate).State != EntityState.Detached;

     if (tracked)
         return;

     if (keys != null)
     {
            
         var oldValues = context.Set<TEntity>().Find(keys);

            context.Entry(oldValues).CurrentValues.SetValues(entityToUpdate);
     }
     else
     {
         context.Set<TEntity>().Attach(entityToUpdate);
         context.Entry(entityToUpdate).State = EntityState.Modified;
     }
 }

我在Github:Solid.DataAccess上发布了完整的解决方案库,它以良好的方式应用了工作单元和仓储模式。我在所有项目中都使用它,它的效果非常好。欢迎克隆并为其改进做出贡献。 GetPrimaryKeys()方法用于动态获取对象的所有主键值,因为我使用了通用对象。这在使用复合或多个主键的实体时非常有帮助。
 private static object[] GetPrimaryKeys<T>(DbContext context, T value)
 {
     var keyNames = context.Model.FindEntityType(typeof(T)).FindPrimaryKey().Properties
               .Select(x => x.Name).ToArray();
     var result = new object[keyNames.Length];
     for (int i = 0; i < keyNames.Length; i++)
     {
         result[i] = typeof(T).GetProperty(keyNames[i])?.GetValue(value);
     }
     return result;
 }

1
仅适用于 EF Core 5.0 及以上版本。 - Aesir
1
这正是我需要的,因为我正在使用内存数据库进行单元测试 - 因此当添加东西以后再删除时,我一直遇到异常。感谢您帮我节省了几个小时的时间。 - Poat
1
谢谢,伙计!!! 我在跟着一个使用EF InMemory的教程,但他们没有使用这个句子,所以只有UPDATE方法在我的RESTful环境中无法工作。 我花了一整天的时间试图弄清楚如何让它工作,直到我找到了你的帖子! 谢谢你! - X00D45
1
以上代码中缺少GetPrimaryKeys方法,请解释一下GetPrimaryKeys()函数的功能。 - Abhishek B.
@AbhishekB。我已经更新了答案,并附上了方法和解释。如果你想要完整的解决方案,请点击上面的链接Solid.DataAccess。 - sabsab
1
感谢@sabsab提供的更新和解释。 - Abhishek B.

20

在我的情况下,表的id列没有设置为标识列。


你救了我 :) 我的键设置错了(Id和Level而不是仅Id),因此我得到了这个错误异常。 - Muflix

11

看起来你只是想跟踪对模型所做的更改,而不是实际上将未跟踪的模型保留在内存中。我可以建议一种替代方法,这将完全消除问题。

EF会自动为您跟踪更改。如何利用那个内置的逻辑呢?

覆盖DbContext中的SaveChanges()方法。

    public override int SaveChanges()
    {
        foreach (var entry in ChangeTracker.Entries<Client>())
        {
            if (entry.State == EntityState.Modified)
            {
                // Get the changed values.
                var modifiedProps = ObjectStateManager.GetObjectStateEntry(entry.EntityKey).GetModifiedProperties();
                var currentValues = ObjectStateManager.GetObjectStateEntry(entry.EntityKey).CurrentValues;
                foreach (var propName in modifiedProps)
                {
                    var newValue = currentValues[propName];
                    //log changes
                }
            }
        }

        return base.SaveChanges();
    }

这里可以找到好的例子:

Entity Framework 6:审核/跟踪更改

使用MVC和实体框架实现审核日志/更改历史记录

编辑: Client 可以很容易地更改为接口。 比如说 ITrackableEntity。这样,您可以集中逻辑并自动记录所有实现特定接口的实体的所有更改。 接口本身没有任何特定属性。

    public override int SaveChanges()
    {
        foreach (var entry in ChangeTracker.Entries<ITrackableClient>())
        {
            if (entry.State == EntityState.Modified)
            {
                // Same code as example above.
            }
        }

        return base.SaveChanges();
    }

此外,请查看Eranga的一个很好的建议,即订阅而不是实际覆盖SaveChanges()。


我确实看过这些例子;我遇到的一些问题是需要在某些表上启用它。那里使用的用例是广泛实现的。感谢反馈,非常感激。 - Rijnhardt
这可能会有所帮助,感谢您的输入。只有一个问题,我如何获取已授权的用户?我需要它用于审计日志。我可以使用 User.GetUserId() 吗? - Rijnhardt
这取决于您的应用程序以及如何验证用户。是WPF、ASP、MVC等等。我通常将其抽象为一个可以检索当前用户的管理器或工具。User.GetUserId()听起来像是控制器中可用的内容,它是HttpContext.User的缩写。所以你需要HttpContext。这里有一些可以帮助你的东西:https://dev59.com/unI-5IYBdhLWcg3wTWj4,https://dev59.com/sW025IYBdhLWcg3wnnYw - smoksnes
HttpContext.Current.User.Identity.GetUserId(); - smoksnes

11

在设置xUnit测试时,我遇到了相同的问题(EF Core)。在测试中“解决”这个问题的方法是在设置种子数据后循环遍历更改跟踪器实体。

  • 在SeedAppDbContext()方法的底部。

我设置了一个测试模拟上下文:

/// <summary>
/// Get an In memory version of the app db context with some seeded data
/// </summary>
public static AppDbContext GetAppDbContext(string dbName)
{
    //set up the options to use for this dbcontext
    var options = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase(databaseName: dbName)
        //.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
        .Options;

    var dbContext = new AppDbContext(options);
    dbContext.SeedAppDbContext();
    return dbContext;
}

扩展方法以添加一些种子数据:

  • 并在方法底部的foreach循环中分离实体。
    public static void SeedAppDbContext(this AppDbContext appDbContext)
    {
       // add companies
       var c1 = new Company() { Id = 1, CompanyName = "Fake Company One", ContactPersonName = "Contact one", eMail = "one@caomp1.com", Phone = "0123456789", AdminUserId = "" };
       c1.Address = new Address() { Id = 1, AddressL1 = "Field Farm", AddressL2 = "Some Lane", City = "some city", PostalCode = "AB12 3CD" };
       appDbContext.CompanyRecords.Add(c1);
                        
       var nc1 = new Company() { Id = 2, CompanyName = "Test Company 2", ContactPersonName = "Contact two", eMail = "two@comp2.com", Phone = "0123456789", Address = new Address() { }, AdminUserId = "" };
       nc1.Address = new Address() { Id = 2, AddressL1 = "The Barn", AddressL2 = "Some Lane", City = "some city", PostalCode = "AB12 3CD" };
       appDbContext.CompanyRecords.Add(nc1);

       //....and so on....
            
       //last call to commit everything to the memory db
       appDbContext.SaveChanges();

       //and then to detach everything 
       foreach (var entity in appDbContext.ChangeTracker.Entries())
       {
           entity.State = EntityState.Detached;
       }
    }

控制器的put方法

.ConvertTo<>()方法是来自ServiceStack的扩展方法。

 [HttpPut]
public async Task<IActionResult> PutUpdateCompany(CompanyFullDto company)
{
    if (0 == company.Id)
        return BadRequest();
    try
    {
        Company editEntity = company.ConvertTo<Company>();
        
        //Prior to detaching an error thrown on line below (another instance with id)
        var trackedEntity = _appDbContext.CompanyRecords.Update(editEntity);
        

        await _appDbContext.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException dbError)
    {
        if (!CompanyExists(company.Id))
            return NotFound();
        else
            return BadRequest(dbError);
    }
    catch (Exception Error)
    {
        return BadRequest(Error);
    }
    return Ok();
}

而且测试:

    [Fact]
    public async Task PassWhenEditingCompany()
    {
        var _appDbContext = AppDbContextMocker.GetAppDbContext(nameof(CompaniesController));
        var _controller = new CompaniesController(null, _appDbContext);

        //Arrange
        const string companyName = "Fake Company One";
        const string contactPerson = "Contact one";

        const string newCompanyName = "New Fake Company One";
        const string newContactPersonName = "New Contact Person";

        //Act
        var getResult = _controller.GetCompanyById(1);
        var getEntity = (getResult.Result.Result as OkObjectResult).Value;
        var entityDto = getEntity as CompanyFullDto;


        //Assert
        Assert.Equal(companyName, entityDto.CompanyName);
        Assert.Equal(contactPerson, entityDto.ContactPersonName);
        Assert.Equal(1, entityDto.Id);

        //Arrange
        Company entity = entityDto.ConvertTo<Company>();
        entity.CompanyName = newCompanyName;
        entity.ContactPersonName = newContactPersonName;
        CompanyFullDto entityDtoUpd = entity.ConvertTo<CompanyFullDto>();

        //Act
        var result = await _controller.PutUpdateCompany(entityDtoUpd) as StatusCodeResult;

        //Assert           
        Assert.True(result.StatusCode == 200);

        //Act
        getResult = _controller.GetCompanyById(1);
        getEntity = (getResult.Result.Result as OkObjectResult).Value;
        
        entityDto = getEntity as CompanyFullDto;
        
        //Assert
        Assert.Equal(1, entityDto.Id); // didn't add a new record
        Assert.Equal(newCompanyName, entityDto.CompanyName); //updated the name
        Assert.Equal(newContactPersonName, entityDto.ContactPersonName); //updated the contact

//make sure to dispose of the _appDbContext otherwise running the full test will fail.
_appDbContext.Dispose();
    }

5

对于我来说,我在使用AutoMapper和.NET 6时遇到了这个问题。为了解决它,我将代码从以下内容更改:

DbItem? result = await _dbContext.DbItems.FirstOrDefaultAsync(t => t.Id == id);
if (result == null)
{
    return null;
}
DbItem mappedItem = _mapper.Map<DbItem>(dto);  //problematic line
var updatedItem = _dbContext.DbItems.Update(mappedItem);

致:

DbItem? result = await _dbContext.DbItems.FirstOrDefaultAsync(t => t.Id == id);
if (result == null)
{
    return null;
}
_mapper.Map(dto, result);  //the fix
var updatedItem = _dbContext.DbItems.Update(result);

有问题的代码行创建了一个具有相同键值的新DbItem,导致了问题。修复代码行将DTO中的字段映射到原始的DbItem。


太好了!那就是我的问题。 - ashilon
你如何处理嵌套实体?例如{name: "item 1", SubItems: [{name: "SubItem1"}]}。即使我使用_mapper.Map(dto, result);,子项数组也将被替换为具有不同引用的对象,这相当于_mapper.Map<DbSubItem>(x); - Artem Balianytsia

4
遇到过这个问题.. 在保存之前先取消跟踪旧实体,问题就解决了。
 public async Task<int> Update<T>(T entity) where T : BaseEntity
{
    entity.UpdatedAt = DateTime.UtcNow;

    // Untrack previous entity version
    var trackedEntity = this.context.Set<T>()
        .SingleOrDefaultAsync(e => e.Id == entity.Id);
    this.context.Entry<T>(await trackedEntity).State = EntityState.Detached;

    // Track new version
    this.context.Set<T>().Attach(entity);
    this.context.Entry<T>(entity).State = EntityState.Modified;

    await this.context.SaveChangesAsync();

    return entity.Id;
}

3
在EF Core中,确保不要同时设置外键和导航属性。当我同时设置键和属性时,就会出现这个错误。
例如:
        new VerificationAccount()
        {
            Account = konto_1630,
            VerificationRowType = VerificationRowType.Template,
            // REMOVED THE LINE BELOW AND THE ERROR WENT AWAY
            //VerificationAccount = verificationAccounts.First(x => x.Account == konto_1630),
            VerificationId = verificationId
        }

3

您可以在保存后将实体设置为分离状态,如下所示:

public async Task<T> Update(int id, T entity)
    {
        entity.Id = id;
        _ctx.Set<T>().Update(entity);
        await _ctx.SaveChangesAsync();
        _ctx.Entry(entity).State = EntityState.Detached; //detach saved entity
        return entity;
    }

对我有用。我在这个应用程序中使用的是 .Net Core 3.0。 我还不得不在我的存储库构造函数中设置以下内容才能使其工作:DbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - Felipe Diniz
在saveChanges方法附近我遇到了一个异常,也就是在我添加Detached之前的那一行代码,你有什么解决办法吗? - user2643191
什么类型的异常? - Francesco

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