Appearance
阴影属性(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 阴影属性的应用场景
典型用途:
- 审计字段 - CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
- 软删除 - IsDeleted, DeletedAt
- 多租户 - TenantId
- 行版本 - RowVersion (用于并发控制)
- 数据库生成的值 - 计算列、触发器生成的值
- 外键字段 - 不想在实体中暴露的外键
优势对比:
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 最佳实践
✅ 应该做的:
统一命名规范
csharp// 使用一致的命名 .Property<DateTime>("CreatedAt") .Property<DateTime>("UpdatedAt") .Property<string>("CreatedBy") .Property<string>("UpdatedBy")添加索引优化查询
csharpmodelBuilder.Entity<Product>() .HasIndex("CreatedAt"); modelBuilder.Entity<Product>() .HasIndex("TenantId");使用拦截器自动管理
csharp// 不要手动设置,使用拦截器自动化 public class AuditInterceptor : SaveChangesInterceptor { // 自动设置阴影属性 }提供默认值
csharp.Property<DateTime>("CreatedAt") .HasDefaultValueSql("GETUTCDATE()");文档化阴影属性
csharp/// <summary> /// 阴影属性: CreatedAt, CreatedBy, UpdatedAt, UpdatedBy /// 用于审计追踪,不在实体类中定义 /// </summary> public class Product { // ... }
8.2 常见陷阱
❌ 不应该做的:
不要忘记配置阴影属性
csharp// ❌ 错误: 使用了未配置的阴影属性 var date = EF.Property<DateTime>(product, "CreatedAt"); // 运行时异常! // ✅ 正确: 先在 OnModelCreating 中配置 modelBuilder.Entity<Product>().Property<DateTime>("CreatedAt");不要硬编码属性名
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);不要在阴影属性上执行复杂逻辑
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; } // 普通属性 }不要忘记测试阴影属性
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 阴影属性
典型应用场景
- 审计字段 - CreatedAt, UpdatedAt, CreatedBy, UpdatedBy
- 软删除 - IsDeleted, DeletedAt
- 多租户 - TenantId
- 并发控制 - RowVersion
- 隐藏外键 - CategoryId(不想在实体中暴露)
注意事项
⚠️ 必须在 OnModelCreating 中配置
⚠️ 使用字符串访问,缺少编译时检查
⚠️ 做好文档化,避免团队成员困惑
⚠️ 添加索引优化查询性能
合理使用阴影属性,你可以构建出更加清晰和可维护的企业级应用!