Skip to content

影子属性与索引器属性 ​

概述 ​

影子属性(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.5

3. 动态索引器属性 ​

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);
}

最佳实践 ​

✅ 推荐做法 ​

  1. 技术字段用影子属性: 如审计字段、版本号
  2. 业务字段用实体属性: 保持领域模型清晰
  3. 合理使用索引器: 适合动态扩展属性
  4. 配合全局过滤器: 实现数据隔离和软删除

❌ 避免的陷阱 ​

  1. 不要过度使用: 太多影子属性会降低代码可读性
  2. 不要忘记映射: 影子属性必须在 OnModelCreating 中配置
  3. 注意性能: 索引器属性的查询可能较慢

总结 ​

特性优点缺点适用场景
影子属性不污染模型,自动管理编译时不检查审计、软删除、租户ID
索引器属性灵活,支持动态属性性能较低,类型不安全产品属性、自定义字段

建议: 优先使用影子属性管理技术性字段,谨慎使用索引器属性!

基于 MIT 许可发布