Skip to content

阴影属性(Shadow Properties) ​

阴影属性是 EF Core 中一种特殊的属性类型,它不存在于 .NET 实体类中,但存在于数据库表中。这种机制允许你将基础设施相关的字段与领域模型完全分离,保持实体类的纯净性。

目录 ​


1. 阴影属性基础 ​

1.1 什么是阴影属性? ​

核心概念:

csharp
// 普通实体类 - 没有审计字段
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    
    // ❌ 不想在这里添加基础设施字段:
    // public DateTime CreatedAt { get; set; }
    // public string CreatedBy { get; set; }
    // public DateTime? UpdatedAt { get; set; }
    // public string UpdatedBy { get; set; }
}

// 数据库表却包含这些字段:
/*
CREATE TABLE Products (
    Id INT PRIMARY KEY IDENTITY(1,1),
    Name NVARCHAR(200) NOT NULL,
    Price DECIMAL(18,2) NOT NULL,
    CreatedAt DATETIME2 NOT NULL,      -- 阴影属性
    CreatedBy NVARCHAR(100) NOT NULL,  -- 阴影属性
    UpdatedAt DATETIME2 NULL,          -- 阴影属性
    UpdatedBy NVARCHAR(100) NULL       -- 阴影属性
)
*/

工作原理:

csharp
// EF Core 在 ChangeTracker 中维护阴影属性
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
        .Property<DateTime>("CreatedAt");     // 字符串名称定义阴影属性
    
    modelBuilder.Entity<Product>()
        .Property<string>("CreatedBy")
        .HasMaxLength(100);
}

// 访问阴影属性使用 EF.Property API
var product = context.Products.First();
var createdAt = EF.Property<DateTime>(product, "CreatedAt");

1.2 阴影属性的应用场景 ​

典型用途:

  1. 审计字段 - CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
  2. 软删除 - IsDeleted, DeletedAt
  3. 多租户 - TenantId
  4. 行版本 - RowVersion (用于并发控制)
  5. 数据库生成的值 - 计算列、触发器生成的值
  6. 外键字段 - 不想在实体中暴露的外键

优势对比:

csharp
// ❌ 方式1: 在实体中添加审计字段
public class Product : IAuditable
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    
    // 污染领域模型
    public DateTime CreatedAt { get; set; }
    public string CreatedBy { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public string UpdatedBy { get; set; }
}

// ✅ 方式2: 使用阴影属性
public class Product  // 纯净的领域模型
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
}

// 审计字段作为阴影配置
modelBuilder.Entity<Product>()
    .Property<DateTime>("CreatedAt");
modelBuilder.Entity<Product>()
    .Property<string>("CreatedBy");

2. 配置阴影属性 ​

2.1 基本配置 ​

csharp
public class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 配置阴影属性
        modelBuilder.Entity<Product>()
            .Property<DateTime>("CreatedAt")
            .HasDefaultValueSql("GETUTCDATE()");  // 数据库默认值
        
        modelBuilder.Entity<Product>()
            .Property<string>("CreatedBy")
            .HasMaxLength(100);
        
        modelBuilder.Entity<Product>()
            .Property<DateTime?>("UpdatedAt");
        
        modelBuilder.Entity<Product>()
            .Property<string>("UpdatedBy")
            .HasMaxLength(100);
        
        // 创建索引提高查询性能
        modelBuilder.Entity<Product>()
            .HasIndex("CreatedAt");
        
        modelBuilder.Entity<Product>()
            .HasIndex("CreatedBy");
    }
}

2.2 批量配置阴影属性 ​

为所有实体配置审计字段:

csharp
public class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // 为所有实体添加审计阴影属性
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            // 跳过某些不需要审计的实体
            if (entityType.ClrType.GetCustomAttribute<NoAuditAttribute>() != null)
                continue;
            
            // 添加阴影属性
            entityType.AddProperty("CreatedAt", typeof(DateTime));
            entityType.AddProperty("CreatedBy", typeof(string));
            entityType.AddProperty("UpdatedAt", typeof(DateTime?));
            entityType.AddProperty("UpdatedBy", typeof(string));
            
            // 配置默认值
            modelBuilder.Entity(entityType.ClrType)
                .Property<DateTime>("CreatedAt")
                .HasDefaultValueSql("GETUTCDATE()");
            
            modelBuilder.Entity(entityType.ClrType)
                .Property<string>("CreatedBy")
                .HasMaxLength(100);
            
            modelBuilder.Entity(entityType.ClrType)
                .Property<DateTime?>("UpdatedAt");
            
            modelBuilder.Entity(entityType.ClrType)
                .Property<string>("UpdatedBy")
                .HasMaxLength(100);
        }
    }
}

// 标记特性:排除审计
[AttributeUsage(AttributeTargets.Class)]
public class NoAuditAttribute : Attribute
{
}

// 使用示例
[NoAudit]  // 这个实体不会有审计字段
public class AuditLog
{
    public int Id { get; set; }
    public string Action { get; set; }
}

2.3 使用拦截器自动设置阴影属性 ​

csharp
public class AuditShadowInterceptor : SaveChangesInterceptor
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    
    public AuditShadowInterceptor(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }
    
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context == null) return result;
        
        var currentUser = GetCurrentUserName();
        var now = DateTime.UtcNow;
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            // 检查实体是否有阴影属性
            var properties = entry.Properties;
            
            switch (entry.State)
            {
                case EntityState.Added:
                    // 设置创建信息
                    SetShadowProperty(entry, "CreatedAt", now);
                    SetShadowProperty(entry, "CreatedBy", currentUser);
                    
                    // 同时设置更新信息(初始时相同)
                    SetShadowProperty(entry, "UpdatedAt", now);
                    SetShadowProperty(entry, "UpdatedBy", currentUser);
                    break;
                    
                case EntityState.Modified:
                    // 只更新 UpdatedAt 和 UpdatedBy
                    SetShadowProperty(entry, "UpdatedAt", now);
                    SetShadowProperty(entry, "UpdatedBy", currentUser);
                    break;
            }
        }
        
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }
    
    private void SetShadowProperty(EntityEntry entry, string propertyName, object value)
    {
        var property = entry.Metadata.FindProperty(propertyName);
        if (property != null)
        {
            entry.Property(propertyName).CurrentValue = value;
        }
    }
    
    private string GetCurrentUserName()
    {
        return _httpContextAccessor.HttpContext?.User?.Identity?.Name 
               ?? "System";
    }
}

// 注册拦截器
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString);
    var interceptor = sp.GetRequiredService<AuditShadowInterceptor>();
    options.AddInterceptors(interceptor);
});

3. 审计追踪应用 ​

3.1 完整的审计追踪系统 ​

结合阴影属性和审计日志:

csharp
public class AuditTrailInterceptor : SaveChangesInterceptor
{
    private readonly List<AuditEntry> _auditEntries = new();
    
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context == null) return result;
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is AuditLog) continue;  // 避免递归
            
            var auditEntry = new AuditEntry
            {
                EntityName = entry.Entity.GetType().Name,
                EntityId = GetEntityId(entry),
                Action = entry.State.ToString(),
                Timestamp = DateTime.UtcNow,
                UserId = GetCurrentUserId()
            };
            
            // 记录变更的属性(包括阴影属性)
            if (entry.State == EntityState.Modified)
            {
                foreach (var property in entry.Properties)
                {
                    if (property.IsModified)
                    {
                        auditEntry.Changes.Add(new PropertyChange
                        {
                            PropertyName = property.Metadata.Name,
                            OldValue = property.OriginalValue,
                            NewValue = property.CurrentValue
                        });
                    }
                }
            }
            else if (entry.State == EntityState.Added)
            {
                foreach (var property in entry.Properties)
                {
                    auditEntry.NewValues[property.Metadata.Name] = property.CurrentValue;
                }
            }
            else if (entry.State == EntityState.Deleted)
            {
                foreach (var property in entry.Properties)
                {
                    auditEntry.OldValues[property.Metadata.Name] = property.OriginalValue;
                }
            }
            
            _auditEntries.Add(auditEntry);
        }
        
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }
    
    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData,
        int result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        
        if (context != null && _auditEntries.Any())
        {
            // 保存审计日志
            context.Set<AuditLog>().AddRange(_auditEntries.Select(ae => ae.ToAuditLog()));
            await context.SaveChangesAsync(cancellationToken);
            
            _auditEntries.Clear();
        }
        
        return await base.SavedChangesAsync(eventData, result, cancellationToken);
    }
}

// 审计日志实体
public class AuditLog
{
    public long Id { get; set; }
    public string EntityName { get; set; }
    public string EntityId { get; set; }
    public string Action { get; set; }
    public string Changes { get; set; }  // JSON
    public string UserId { get; set; }
    public DateTime Timestamp { get; set; }
}

// 查询审计历史
var auditHistory = await context.AuditLogs
    .Where(a => a.EntityName == "Product" && a.EntityId == "123")
    .OrderByDescending(a => a.Timestamp)
    .ToListAsync();

foreach (var audit in auditHistory)
{
    Console.WriteLine($"{audit.Action} by {audit.UserId} at {audit.Timestamp}");
    Console.WriteLine($"Changes: {audit.Changes}");
}

3.2 基于阴影属性的轻量级审计 ​

如果只需要简单的时间追踪,可以仅使用阴影属性:

csharp
// 查询最近创建的 Product
var recentProducts = await context.Products
    .OrderByDescending(p => EF.Property<DateTime>(p, "CreatedAt"))
    .Take(10)
    .ToListAsync();

// 按创建者分组统计
var stats = await context.Products
    .GroupBy(p => EF.Property<string>(p, "CreatedBy"))
    .Select(g => new
    {
        User = g.Key,
        Count = g.Count(),
        LatestProduct = g.Max(p => EF.Property<DateTime>(p, "CreatedAt"))
    })
    .ToListAsync();

4. 多租户隔离 ​

4.1 使用阴影属性实现租户隔离 ​

配置 TenantId 阴影属性:

csharp
public class MultiTenantDbContext : DbContext
{
    private readonly string _tenantId;
    
    public MultiTenantDbContext(DbContextOptions options, string tenantId)
        : base(options)
    {
        _tenantId = tenantId;
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // 为所有实体添加 TenantId 阴影属性
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            entityType.AddProperty("TenantId", typeof(string));
            
            // 配置全局查询过滤器
            var parameter = Expression.Parameter(entityType.ClrType, "e");
            var property = Expression.Property(parameter, "TenantId");
            var tenantId = Expression.Constant(_tenantId);
            var equalExpression = Expression.Equal(property, tenantId);
            var lambda = Expression.Lambda(equalExpression, parameter);
            
            modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
        }
    }
}

// 拦截器自动设置 TenantId
public class TenantIsolationInterceptor : SaveChangesInterceptor
{
    private readonly string _tenantId;
    
    public TenantIsolationInterceptor(string tenantId)
    {
        _tenantId = tenantId;
    }
    
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        var context = eventData.Context;
        if (context == null) return result;
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Property("TenantId").CurrentValue = _tenantId;
            }
        }
        
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

使用效果:

csharp
// 自动过滤租户数据
var products = await context.Products.ToListAsync();
// SQL: SELECT * FROM Products WHERE TenantId = 'tenant1'

// 无法访问其他租户的数据
var otherTenantProducts = await context.Products
    .IgnoreQueryFilters()  // 需要显式忽略过滤器
    .Where(p => EF.Property<string>(p, "TenantId") == "tenant2")
    .ToListAsync();

5. 乐观并发控制 ​

5.1 使用 RowVersion 阴影属性 ​

csharp
public class ConcurrencyControlDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            // 添加 RowVersion 阴影属性
            entityType.AddProperty("RowVersion", typeof(byte[]));
            
            modelBuilder.Entity(entityType.ClrType)
                .Property<byte[]>("RowVersion")
                .IsRowVersion();  // 配置为行版本
        }
    }
}

// 处理并发冲突
public async Task UpdateProductWithConcurrency(int productId, Action<Product> updateAction)
{
    using var context = new ConcurrencyControlDbContext(options);
    
    var product = await context.Products.FindAsync(productId);
    if (product == null) throw new NotFoundException();
    
    updateAction(product);
    
    try
    {
        await context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        // 获取当前数据库中的值
        var databaseEntry = ex.Entries.Single();
        var databaseValues = databaseEntry.GetDatabaseValues();
        var currentRowVersion = databaseValues["RowVersion"];
        
        // 决定解决策略:
        // 1. 客户端胜出
        databaseEntry.OriginalValues["RowVersion"] = currentRowVersion;
        await context.SaveChangesAsync();
        
        // 2. 数据库胜出(重新加载)
        // await databaseEntry.ReloadAsync();
        
        // 3. 合并冲突
        // MergeChanges(databaseEntry);
    }
}

6. 查询和修改阴影属性 ​

6.1 查询阴影属性 ​

csharp
// 方法1: 使用 EF.Property API
var products = await context.Products
    .Where(p => EF.Property<DateTime>(p, "CreatedAt") > DateTime.UtcNow.AddDays(-7))
    .ToListAsync();

// 方法2: 在排序中使用
var sortedProducts = await context.Products
    .OrderByDescending(p => EF.Property<DateTime>(p, "CreatedAt"))
    .ToListAsync();

// 方法3: 投影到 DTO
var productDtos = await context.Products
    .Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price,
        CreatedAt = EF.Property<DateTime>(p, "CreatedAt"),
        CreatedBy = EF.Property<string>(p, "CreatedBy")
    })
    .ToListAsync();

// 方法4: 在分组中使用
var stats = await context.Products
    .GroupBy(p => EF.Property<string>(p, "CreatedBy"))
    .Select(g => new
    {
        User = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p => p.Price)
    })
    .ToListAsync();

6.2 修改阴影属性 ​

csharp
// 方法1: 通过拦截器(推荐)
public class ShadowPropertyInterceptor : SaveChangesInterceptor
{
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(...)
    {
        foreach (var entry in eventData.Context.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Modified)
            {
                entry.Property("UpdatedAt").CurrentValue = DateTime.UtcNow;
            }
        }
        return await base.SavingChangesAsync(...);
    }
}

// 方法2: 直接设置
var product = await context.Products.FindAsync(1);
context.Entry(product).Property("UpdatedAt").CurrentValue = DateTime.UtcNow;
await context.SaveChangesAsync();

// 方法3: 在迁移中设置默认值
migrationBuilder.AddColumn<DateTime>(
    name: "CreatedAt",
    table: "Products",
    type: "datetime2",
    nullable: false,
    defaultValueSql: "GETUTCDATE()");

6.3 在原始 SQL 中使用阴影属性 ​

csharp
// 查询包含阴影属性
var products = await context.Products
    .FromSqlRaw(@"
        SELECT Id, Name, Price, CreatedAt, CreatedBy
        FROM Products
        WHERE CreatedAt >= {0}
    ", DateTime.UtcNow.AddDays(-30))
    .ToListAsync();

// 更新阴影属性
await context.Database.ExecuteSqlRawAsync(
    "UPDATE Products SET UpdatedAt = GETUTCDATE() WHERE Id = {0}", productId);

7. 阴影属性 vs 普通属性 ​

7.1 对比分析 ​

特性阴影属性普通属性
实体类可见性❌ 不可见✅ 可见
领域模型纯净度✅ 高❌ 低
访问方式EF.Property<T>()entity.Property
编译时检查❌ 无✅ 有
重构友好性❌ 较差✅ 好
适用场景基础设施字段业务字段

7.2 何时使用阴影属性? ​

✅ 推荐使用阴影属性:

  • 审计字段(CreatedAt, UpdatedAt, CreatedBy, UpdatedBy)
  • 租户ID(TenantId)
  • 行版本(RowVersion)
  • 数据库生成的值(计算列)
  • 不想在实体中暴露的外键

❌ 不推荐使用阴影属性:

  • 业务逻辑需要的字段
  • 需要在验证中使用的字段
  • 需要频繁访问的字段
  • 需要在领域事件中使用

7.3 混合方案 ​

csharp
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    
    // 业务相关的时间戳 - 作为普通属性
    public DateTime? PublishedAt { get; set; }
    public DateTime? DiscontinuedAt { get; set; }
}

// 基础设施相关的审计字段 - 作为阴影属性
modelBuilder.Entity<Product>()
    .Property<DateTime>("CreatedAt");
modelBuilder.Entity<Product>()
    .Property<string>("CreatedBy");

8. 最佳实践与常见陷阱 ​

8.1 最佳实践 ​

✅ 应该做的:

  1. 统一命名规范

    csharp
    // 使用一致的命名
    .Property<DateTime>("CreatedAt")
    .Property<DateTime>("UpdatedAt")
    .Property<string>("CreatedBy")
    .Property<string>("UpdatedBy")
  2. 添加索引优化查询

    csharp
    modelBuilder.Entity<Product>()
        .HasIndex("CreatedAt");
    
    modelBuilder.Entity<Product>()
        .HasIndex("TenantId");
  3. 使用拦截器自动管理

    csharp
    // 不要手动设置,使用拦截器自动化
    public class AuditInterceptor : SaveChangesInterceptor
    {
        // 自动设置阴影属性
    }
  4. 提供默认值

    csharp
    .Property<DateTime>("CreatedAt")
        .HasDefaultValueSql("GETUTCDATE()");
  5. 文档化阴影属性

    csharp
    /// <summary>
    /// 阴影属性: CreatedAt, CreatedBy, UpdatedAt, UpdatedBy
    /// 用于审计追踪,不在实体类中定义
    /// </summary>
    public class Product
    {
        // ...
    }

8.2 常见陷阱 ​

❌ 不应该做的:

  1. 不要忘记配置阴影属性

    csharp
    // ❌ 错误: 使用了未配置的阴影属性
    var date = EF.Property<DateTime>(product, "CreatedAt");  // 运行时异常!
    
    // ✅ 正确: 先在 OnModelCreating 中配置
    modelBuilder.Entity<Product>().Property<DateTime>("CreatedAt");
  2. 不要硬编码属性名

    csharp
    // ❌ 坏做法
    var date = EF.Property<DateTime>(product, "CreatedAt");
    
    // ✅ 好做法: 使用常量
    public static class ShadowProperties
    {
        public const string CreatedAt = "CreatedAt";
        public const string CreatedBy = "CreatedBy";
    }
    
    var date = EF.Property<DateTime>(product, ShadowProperties.CreatedAt);
  3. 不要在阴影属性上执行复杂逻辑

    csharp
    // ❌ 不推荐: 复杂查询难以维护
    var products = await context.Products
        .Where(p => EF.Property<string>(p, "CreatedBy")
            .Substring(0, 3) == "ABC")
        .ToListAsync();
    
    // ✅ 推荐: 将业务字段改为普通属性
    public class Product
    {
        public string DepartmentCode { get; set; }  // 普通属性
    }
  4. 不要忘记测试阴影属性

    csharp
    [Fact]
    public async Task Should_Set_CreatedAt_Shadow_Property()
    {
        // Arrange
        using var context = CreateContext();
        
        // Act
        context.Products.Add(new Product { Name = "Test" });
        await context.SaveChangesAsync();
        
        // Assert
        var product = await context.Products.FirstAsync();
        var createdAt = EF.Property<DateTime>(product, "CreatedAt");
        Assert.NotEqual(default, createdAt);
    }

8.3 调试技巧 ​

查看阴影属性的值:

csharp
// 方法1: 使用 DebugView
var debugView = context.ChangeTracker.DebugView.LongView;
Console.WriteLine(debugView);

// 方法2: 遍历所有属性
var product = await context.Products.FirstAsync();
var entry = context.Entry(product);

foreach (var prop in entry.Properties)
{
    Console.WriteLine($"{prop.Metadata.Name}: {prop.CurrentValue}");
    // 输出包括阴影属性:
    // Id: 1
    // Name: Product A
    // Price: 99.99
    // CreatedAt: 2024-01-01 12:00:00
    // CreatedBy: admin
}

// 方法3: 启用详细日志
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Debug);

生成迁移时检查阴影属性:

bash
# 查看迁移脚本,确认阴影属性被包含
dotnet ef migrations script PreviousMigration CurrentMigration

总结 ​

阴影属性是 EF Core 提供的强大机制,用于分离基础设施关注点和领域模型:

核心价值 ​

✅ 保持领域模型纯净 - 实体类只包含业务字段
✅ 自动管理审计追踪 - 结合拦截器实现自动化
✅ 透明的多租户隔离 - TenantId 作为阴影属性
✅ 简化并发控制 - RowVersion 阴影属性

典型应用场景 ​

  1. 审计字段 - CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
  2. 软删除 - IsDeleted, DeletedAt
  3. 多租户 - TenantId
  4. 并发控制 - RowVersion
  5. 隐藏外键 - CategoryId(不想在实体中暴露)

注意事项 ​

⚠️ 必须在 OnModelCreating 中配置
⚠️ 使用字符串访问,缺少编译时检查
⚠️ 做好文档化,避免团队成员困惑
⚠️ 添加索引优化查询性能

合理使用阴影属性,你可以构建出更加清晰和可维护的企业级应用!

基于 MIT 许可发布