EF Core并行集成测试中ValueObject静态实例引发键属性修改异常
并行运行集成测试时,偶尔会遇到实体保存失败,EF Core 抛出异常:
System.InvalidOperationException : The property 'Order.Price#Money.OrderId' is part of a key and so cannot be modified or marked as modified. To change the principal of an existing entity with an identifying foreign key, first delete the dependent and invoke 'SaveChanges', and then associate the dependent with the new principal.
单独运行should_get_order测试完全正常,但批量运行所有测试时偶尔触发。将Order构造函数中Price = Money.Zero改为Price = new Money(0)后,异常不再出现,怀疑问题出在Money.Zero这个静态只读字段上。
相关实体实现代码
public class Order : AggregateRoot { // some other props public Money Price { get; private set; } public Order() { Price = Money.Zero; // 抛出异常,改为new Money(0)则正常 } public static Order Create() { return new Order(); } } public abstract class AggregateRoot { public AggregateId Id { get; protected set; } } public class AggregateId<T> : IEquatable<AggregateId<T>> { public T Value { get; } public AggregateId(T value) { Value = value; } public bool Equals(AggregateId<T>? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return EqualityComparer<T>.Default.Equals(Value, other.Value); } public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != this.GetType()) return false; return Equals((AggregateId<T>)obj); } public override int GetHashCode() { return EqualityComparer<T>.Default.GetHashCode(Value); } } public class AggregateId : AggregateId<Guid> { public AggregateId() : this(Guid.NewGuid()) { } public AggregateId(Guid value) : base(value) { } public static implicit operator Guid(AggregateId id) => id.Value; public static implicit operator AggregateId(Guid id) => new(id); } public class Money : IEquatable<Money> { public static readonly Money Zero = new Money(0); public decimal Value { get; } public Money(decimal value) { if (value < 0) { throw new InvalidOperationException($"Money '{value}' cannot be negative"); } Value = value; } public Money ChangeValue(decimal value) { return new Money(value); } public bool Equals(Money? other) { if (other is null) return false; if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; return Value == other.Value; } public override bool Equals(object? obj) { if (obj as Money is null) return false; if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; if (obj.GetType() != GetType()) return false; return Equals((Money)obj); } public override int GetHashCode() { return EqualityComparer<decimal>.Default.GetHashCode(Value); } public override string ToString() { return Value.ToString("0.00", CultureInfo.CreateSpecificCulture("en-US")); } }
EF Core 配置代码
internal class OrderConfiguration : IEntityTypeConfiguration<Order> { public void Configure(EntityTypeBuilder<Order> builder) { builder.HasKey(o => o.Id); builder .Property(o => o.Id) .HasConversion(id => id.Value, id => new AggregateId(id)); builder.OwnsOne(i => i.Price, navigation => { navigation.Property(m => m.Value).HasColumnName("Cost").IsRequired().HasPrecision(14, 4); }); } }
集成测试代码
public class OrdersTests : IClassFixture<TestApplicationFactory<Program>>, IClassFixture<TestDbContext> { [Fact] public async Task should_get_order() { var order = Order.Create(); await _dbContext.AddAsync(order); // 此处偶尔抛出异常 await _dbContext.SaveChangesAsync(); var response = await _client.Request($"{Path}/{order.Id.Value}").GetAsync(); var order = await response.GetJsonAsync<Order>(); order.ShouldNotBeNull(); } // some other tests } public class TestApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class { protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("test"); } } public class TestDbContext : IDisposable { internal OrderDbContext DbContext { get; } private const string AppSettings = "appsettings.test.json"; private static readonly IConfiguration Configuration = new ConfigurationBuilder().AddJsonFile(AppSettings) .AddEnvironmentVariables() .Build(); public TestDbContext() { DbContext = new OrderDbContext(new DbContextOptionsBuilder<OrderDbContext>().UseNpgsql(Configuration["database:connectionString"]) .EnableSensitiveDataLogging() .Options); } public void Dispose() { DbContext?.Database.EnsureDeleted(); DbContext?.Dispose(); } }
异常堆栈跟踪
InternalEntityEntry.SetPropertyModified(IProperty property, Boolean changeState, Boolean isModified, Boolean isConceptualNull, Boolean acceptChanges) ChangeDetector.PropertyChanged(InternalEntityEntry entry, IPropertyBase propertyBase, Boolean setModified) InternalEntityEntryNotifier.PropertyChanged(InternalEntityEntry entry, IPropertyBase property, Boolean setModified) InternalEntityEntry.SetProperty(IPropertyBase propertyBase, Object value, Boolean isMaterialization, Boolean setModified, Boolean isCascadeDelete, CurrentValueType valueType) InternalEntityEntry.SetProperty(IPropertyBase propertyBase, Object value, Boolean isMaterialization, Boolean setModified, Boolean isCascadeDelete) InternalEntityEntry.PropagateValue(InternalEntityEntry principalEntry, IProperty principalProperty, IProperty dependentProperty, Boolean isMaterialization, Boolean setModified) NavigationFixer.SetForeignKeyProperties(InternalEntityEntry dependentEntry, InternalEntityEntry principalEntry, IForeignKey foreignKey, Boolean setModified, Boolean fromQuery) NavigationFixer.InitialFixup(InternalEntityEntry entry, Boolean fromQuery) NavigationFixer.StateChanged(InternalEntityEntry entry, EntityState oldState, Boolean fromQuery) InternalEntityEntryNotifier.StateChanged(InternalEntityEntry entry, EntityState oldState, Boolean fromQuery) InternalEntityEntry.SetEntityState(EntityState oldState, EntityState newState, Boolean acceptChanges, Boolean modifyProperties) InternalEntityEntry.SetEntityState(EntityState entityState, Boolean acceptChanges, Boolean modifyProperties, Nullable`1 forceStateWhenUnknownKey) EntityGraphAttacher.PaintAction(EntityEntryGraphNode`1 node) EntityEntryGraphIterator.TraverseGraph[TState](EntityEntryGraphNode`1 node, Func`2 handleNode) EntityGraphAttacher.AttachGraph(InternalEntityEntry rootEntry, EntityState targetState, EntityState storeGeneratedWithKeySetTargetState, Boolean forceStateWhenUnknownKey) DbContext.SetEntityState(InternalEntityEntry entry, EntityState entityState) DbContext.SetEntityState[TEntity](TEntity entity, EntityState entityState) DbContext.Add[TEntity](TEntity entity) OrdersControllerTests.given_valid_order_item_should_add_to_order() line 24 --- End of stack trace from previous location ---
问题分析与解决方案
根本原因
问题确实出在Money.Zero这个静态只读单例实例上。EF Core对拥有实体(Owned Entity,这里的Money是Order的拥有实体)的跟踪机制是:每个拥有实体实例会关联到唯一的主实体(Order)。
当并行测试时,多个Order实例会共享同一个Money.Zero实例。EF Core在跟踪第一个Order时,会给这个Money.Zero实例关联上对应的OrderId外键值。当第二个Order尝试使用同一个Money.Zero实例时,EF Core会发现这个拥有实体已经被关联到另一个主实体,此时尝试修改它的OrderId(作为拥有实体的主键一部分)就会触发异常——因为拥有实体的主键包含主实体的外键,无法直接修改关联的主实体。
而使用new Money(0)时,每个Order都拥有独立的Money实例,不存在共享实例被多个主实体关联的问题,因此不会触发异常。
解决方案
- 避免共享拥有实体实例:将
Order构造函数中的Price = Money.Zero改为Price = new Money(0),确保每个Order都有独立的Money实例。 - 优化
Money静态字段的使用方式:如果希望复用Zero语义,可以修改Money.Zero的获取逻辑,每次返回新实例(值对象只要值相等即可,实例是否共享不重要):public static Money Zero => new Money(0); // 改为属性,每次返回新实例 - 强化测试隔离性:虽然当前测试已经通过
TestDbContext的Dispose删除数据库,但并行测试时仍需确保每个测试的DbContext和实体实例完全隔离,避免跨测试的实例共享。
内容的提问来源于stack exchange,提问作者siteCoreDeveloper2022

