Appearance
影子属性与索引器属性
概述
影子属性(Shadow Properties)是 EF Core 中一种特殊的属性,它们在实体类中不存在,但在数据库表中存在对应的列。索引器属性(Indexer Properties)允许实体通过字典方式访问动态属性。这两种特性为数据模型提供了更大的灵活性。
使用场景
- 审计字段: CreatedAt, ModifiedBy 等不想污染领域模型的字段
- 外键字段: 数据库中需要但领域层不需要的外键
- 多租户: TenantId 等技术性字段
- 动态属性: 运行时才能确定的属性
影子属性(Shadow Properties)
1. 配置影子属性
csharp
// Domain/Entities/Product.cs (没有 CreatedAt 属性)
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
// Infrastructure/Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
// 配置影子属性
entity.Property<DateTime>("CreatedAt")
.HasDefaultValueSql("GETUTCDATE()");
entity.Property<DateTime?>("UpdatedAt");
entity.Property<string>("CreatedBy")
.HasMaxLength(100);
});
}2. 读写影子属性
csharp
// 读取影子属性
var products = await _context.Products.ToListAsync();
foreach (var product in products)
{
var createdAt = _context.Entry(product).Property<DateTime>("CreatedAt").CurrentValue;
Console.WriteLine($"Created at: {createdAt}");
}
// 设置影子属性
var newProduct = new Product { Name = "Laptop", Price = 999.99m };
_context.Products.Add(newProduct);
_context.Entry(newProduct).Property<DateTime>("CreatedAt").CurrentValue = DateTime.UtcNow;
_context.Entry(newProduct).Property<string>("CreatedBy").CurrentValue = "admin";
await _context.SaveChangesAsync();3. 自动管理审计字段
csharp
public class AppDbContext : DbContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
public AppDbContext(
DbContextOptions<AppDbContext> options,
IHttpContextAccessor httpContextAccessor)
: base(options)
{
_httpContextAccessor = httpContextAccessor;
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
var currentUser = _httpContextAccessor.HttpContext?.User?.Identity?.Name ?? "System";
var now = DateTime.UtcNow;
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
// 设置创建时间
if (entry.Metadata.FindProperty("CreatedAt") != null)
{
entry.Property("CreatedAt").CurrentValue = now;
}
// 设置创建人
if (entry.Metadata.FindProperty("CreatedBy") != null)
{
entry.Property("CreatedBy").CurrentValue = currentUser;
}
}
// 设置更新时间
if (entry.Metadata.FindProperty("UpdatedAt") != null)
{
entry.Property("UpdatedAt").CurrentValue = now;
}
}
return base.SaveChangesAsync(cancellationToken);
}
}4. 查询影子属性
csharp
// 按影子属性过滤
var recentProducts = await _context.Products
.Where(p => EF.Property<DateTime>(p, "CreatedAt") > DateTime.UtcNow.AddDays(-7))
.ToListAsync();
// 按影子属性排序
var products = await _context.Products
.OrderByDescending(p => EF.Property<DateTime>(p, "CreatedAt"))
.ToListAsync();
// 投影包含影子属性
var productDtos = await _context.Products
.Select(p => new ProductDto(
p.Id,
p.Name,
p.Price,
EF.Property<DateTime>(p, "CreatedAt"),
EF.Property<string>(p, "CreatedBy")))
.ToListAsync();索引器属性(Indexer Properties)
1. 配置索引器属性
csharp
// Domain/Entities/ProductWithAttributes.cs
public class ProductWithAttributes
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
// 索引器属性
private Dictionary<string, string> _attributes = new();
public string this[string key]
{
get => _attributes.ContainsKey(key) ? _attributes[key] : null;
set => _attributes[key] = value;
}
}
// Infrastructure/Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProductWithAttributes>(entity =>
{
// 配置索引器属性
entity.IndexerProperty<string>("Color")
.HasMaxLength(50);
entity.IndexerProperty<decimal>("Weight")
.HasColumnType("decimal(10,2)");
entity.IndexerProperty<int>("Stock")
.HasDefaultValue(0);
});
}2. 使用索引器属性
csharp
// 创建实体并设置索引器属性
var product = new ProductWithAttributes { Name = "T-Shirt" };
product["Color"] = "Red";
product["Weight"] = "0.5";
product["Stock"] = "100";
_context.Products.Add(product);
await _context.SaveChangesAsync();
// 读取索引器属性
var loadedProduct = await _context.Products.FindAsync(product.Id);
Console.WriteLine($"Color: {loadedProduct["Color"]}"); // Red
Console.WriteLine($"Weight: {loadedProduct["Weight"]}"); // 0.53. 动态索引器属性
csharp
// 支持任意键值对
modelBuilder.Entity<ProductWithAttributes>(entity =>
{
// 使用 JSON 列存储动态属性
entity.Property<Dictionary<string, string>>("DynamicProperties")
.HasColumnType("nvarchar(max)")
.HasConversion(
v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
v => JsonSerializer.Deserialize<Dictionary<string, string>>(v, (JsonSerializerOptions)null));
});
// 使用
var product = new ProductWithAttributes { Name = "Laptop" };
product["CPU"] = "Intel i7";
product["RAM"] = "16GB";
product["Storage"] = "512GB SSD";实际应用场景
场景 1: 软删除
csharp
// 配置影子属性实现软删除
modelBuilder.Entity<Product>(entity =>
{
entity.Property<bool>("IsDeleted")
.HasDefaultValue(false);
entity.Property<DateTime?>("DeletedAt");
});
// 全局查询过滤器(自动过滤已删除的记录)
modelBuilder.Entity<Product>()
.HasQueryFilter(p => !EF.Property<bool>(p, "IsDeleted"));
// 软删除方法
public async Task SoftDeleteAsync(int productId)
{
var product = await _context.Products.FindAsync(productId);
if (product != null)
{
_context.Entry(product).Property<bool>("IsDeleted").CurrentValue = true;
_context.Entry(product).Property<DateTime?>("DeletedAt").CurrentValue = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}场景 2: 行版本控制
csharp
// 配置影子属性作为行版本
modelBuilder.Entity<Product>(entity =>
{
entity.Property<byte[]>("RowVersion")
.IsRowVersion(); // 并发令牌
});
// 使用时 EF Core 会自动处理并发检查
var product = await _context.Products.FindAsync(1);
product.Price = 120m;
await _context.SaveChangesAsync(); // 自动检查 RowVersion场景 3: 多租户隔离
csharp
// 配置租户 ID 影子属性
modelBuilder.Entity<Product>(entity =>
{
entity.Property<string>("TenantId")
.IsRequired()
.HasMaxLength(50);
// 全局过滤器
entity.HasQueryFilter(p =>
EF.Property<string>(p, "TenantId") == _currentTenantId);
});
// 自动设置租户 ID
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added &&
e.Metadata.FindProperty("TenantId") != null);
foreach (var entry in entries)
{
entry.Property("TenantId").CurrentValue = _currentTenantId;
}
return base.SaveChangesAsync(cancellationToken);
}最佳实践
✅ 推荐做法
- 技术字段用影子属性: 如审计字段、版本号
- 业务字段用实体属性: 保持领域模型清晰
- 合理使用索引器: 适合动态扩展属性
- 配合全局过滤器: 实现数据隔离和软删除
❌ 避免的陷阱
- 不要过度使用: 太多影子属性会降低代码可读性
- 不要忘记映射: 影子属性必须在
OnModelCreating中配置 - 注意性能: 索引器属性的查询可能较慢
总结
| 特性 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 影子属性 | 不污染模型,自动管理 | 编译时不检查 | 审计、软删除、租户ID |
| 索引器属性 | 灵活,支持动态属性 | 性能较低,类型不安全 | 产品属性、自定义字段 |
建议: 优先使用影子属性管理技术性字段,谨慎使用索引器属性!