如何在ASP.NET Core中用Entity Framework Core实现审计日志
在现代 web 应用中,出于监控、合规和调试等原因,可能需要追踪数据变更。这个过程被称为创建审计日志(审计轨迹),允许开发人员看到是谁进行了更改、更改时间以及更改内容。审计日志(审计轨迹)提供了数据更改的历史记录。
在这篇博客文章中,我将展示如何在ASP.NET Core应用程序中使用Entity Framework Core (EF Core) 实现审计日志。
应用审计:
我们将要审计的应用
今天我们将为“图书管理”应用程序实施审计追踪,该应用程序具有以下实体:
- 书籍
- 作者
- 用户
我发现,在所有需要审计的实体中包含以下这些属性很有用。
public interface IAuditableEntity
{
// 创建时间(UTC时间)
DateTime CreatedAtUtc { get; set; }
// 更新时间(UTC时间,可空)
DateTime? UpdatedAtUtc { get; set; }
// 创建人
string CreatedBy { get; set; }
// 更新人(可空)
string? UpdatedBy { get; set; }
}
我们应当让所有可审计的实体继承这个接口,例如,User 和 Book。
public class User : IAuditableEntity // 用户类,实现可审计实体接口
{
public Guid Id { get; set; } // 唯一标识符
public required string Email { get; set; } // 电子邮件
public DateTime CreatedAtUtc { get; set; } // 创建时间(UTC)
public DateTime? UpdatedAtUtc { get; set; } // 更新时间(UTC)
public string CreatedBy { get; set; } = null!; // 创建人
public string? UpdatedBy { get; set; } // 更新人
}
public class Book : IAuditableEntity // 书籍类,实现可审计实体接口
{
public required Guid Id { get; set; } // 唯一标识符
public required string Title { get; set; } // 标题
public required int Year { get; set; } // 年份
public Guid AuthorId { get; set; } // 作者唯一标识符
public Author Author { get; set; } = null!; // 书籍关联的作者
public DateTime CreatedAtUtc { get; set; } // 创建时间(UTC)
public DateTime? UpdatedAtUtc { get; set; } // 更新时间(UTC)
public string CreatedBy { get; set; } = null!; // 创建人
public string? UpdatedBy { get; set; } // 更新人
}
我们现在有几种选择,可以为每个实体手动实现审计轨迹,或者实现一个自动应用于所有实体的解决方案。在这篇文章里,我会向您展示第二种方案,因为它更稳定且更易于维护。
配置(Configuring)EF Core 中的审计日志实体实施审计追踪的第一步是创建一个用于存储审计日志的实体,该实体将审计日志存储在单独的数据库表中。该实体应捕获例如实体类型、主键、已更改属性及其旧值和新值,以及更改的时间戳信息。
public class 审计跟踪
{
public required Guid Id { get; set; }
public Guid? UserId { get; set; }
public User? User { get; set; }
public TrailType 跟踪类型 { get; set; }
public DateTime Utc日期 { get; set; }
public required string 实体名称 { get; set; }
public string? 主键 { get; set; }
public Dictionary<string, object?> 旧值 { get; set; } = new Dictionary<string, object?>();
public Dictionary<string, object?> 新值 { get; set; } = new Dictionary<string, object?>();
public List<string> 更改列 { get; set; } = new List<string>();
}
在这里提到了一个User
实体(即用户)的引用。根据你的应用需求,你可能需要也可能不需要这个引用。
审计追踪可以有以下几种类型:
- 实体已创建完成
- 实体已更新完毕
- 实体已成功删除
public 枚举 TrailType : byte //定义轨迹类型枚举
{
无 = 0, //表示无轨迹类型
创建 = 1, //表示创建操作
更新 = 2, //表示更新操作
删除 = 3 //表示删除操作
}
我们来看看如何在EF Core中配置审计日志实体,
public class 审计跟踪配置 : IEntityType配置<AuditTrail>
{
public void 配置(EntityTypeBuilder<审计跟踪项> 建筑师)
{
建筑师.ToTable("audit_trails");
建筑师.HasKey(e => e.Id);
建筑师.HasIndex(e => e.EntityName);
建筑师.Property(e => e.Id);
建筑师.Property(e => e.UserId);
建筑师.Property(e => e.EntityName).HasMaxLength(100).IsRequired();
建筑师.Property(e => e.DateUtc).IsRequired();
建筑师.Property(e => e.PrimaryKey).HasMaxLength(100);
建筑师.Property(e => e.TrailType).HasConversion<string>().转换为字符串();
建筑师.Property(e => e.ChangedColumns).HasColumnType("jsonb");
建筑师.Property(e => e.OldValues).HasColumnType("jsonb");
建筑师.Property(e => e.NewValues).HasColumnType("jsonb");
建筑师.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
}
}
我喜欢使用JSON 字段来表示 ChangedColumns
、OldValues
和 NewValues
。在这篇博客里,我的代码示例中使用的是Postgres数据库。
如果你使用的是 SQLite 或其他数据库不支持 json 列,你可以将实体中的类型改为字符串类型,并创建一个 EF Core 转换,将对象序列化为字符串,然后保存到数据库中。当你从数据库中检索数据时,这个转换会将 JSON 字符串反序列化为相应的 .NET 类型。
在使用 NET 8 和 EF 8 时,你需开启 EnableDynamicJson
选项,以便在 'jsonb' 列中存储动态 JSON。
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); // 创建一个新的Npgsql数据源构建器
dataSourceBuilder.EnableDynamicJson(); // 启用动态JSON支持
builder.Services.AddDbContext<ApplicationDbContext>((provider, options) =>
{
var interceptor = provider.GetRequiredService<AuditableInterceptor>(); // 获取AuditableInterceptor服务
options.EnableSensitiveDataLogging() // 启用敏感数据日志记录
.UseNpgsql(dataSourceBuilder.Build(), npgsqlOptions =>
{
npgsqlOptions.MigrationsHistoryTable("__MyMigrationsHistory", "devtips_audit_trails"); // 设置迁移历史表
})
.AddInterceptors(interceptor) // 添加拦截器
.UseSnakeCaseNamingConvention(); // 使用蛇形命名约定
});
实施所有可审计实体的:审计轨迹
我们可以在EF Core DbContext中实现一个审计功能,该功能会自动应用于所有继承自 IAuditableEntity
的实体。但首先,我们需要找到一个正在执行创建、更新或删除操作的用户。
我们来定义一个 CurrentSessionProvider
,接下来从当前的 HttpRequest
中的 ClaimsPrinciple
获取当前用户的标识符:
public interface ICurrentSessionProvider
{
Guid? 获取用户ID();
}
public class CurrentSessionProvider : ICurrentSessionProvider
{
private readonly Guid? _用户Id;
public CurrentSessionProvider(IHttpContextAccessor 访问对象)
{
var 用户标识符 = 访问对象.HttpContext?.User.FindFirstValue("userid");
if (用户标识符 is null)
{
return;
}
_用户Id = Guid.TryParse(用户标识符, out Guid guid) ? guid : null;
}
public Guid? 获取用户ID() => _用户Id;
}
你需要做的是在依赖注入容器中注册服务提供者和 IHttpContextAccessor
接口。
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentSessionProvider, CurrentSessionProvider>();
为了生成审计日志,我们可以使用EF Core Change Tracker的功能来跟踪被创建、更新或删除的实体。
我们需要将 ICurrentSessionProvider
注入到 DbContext
中,并重载 SaveChangesAsync
方法来创建审计日志。
public class ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
ICurrentSessionProvider currentSessionProvider)
: DbContext(options)
{
public ICurrentSessionProvider CurrentSessionProvider => currentSessionProvider;
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
{
var userId = CurrentSessionProvider.GetUserId();
SetAuditableProperties(userId);
var auditEntries = HandleAuditingBeforeSaveChanges(userId).ToList();
if (auditEntries.Count > 0)
{
await AuditTrails.AddRangeAsync(auditEntries, cancellationToken);
}
return await base.SaveChangesAsync(cancellationToken);
}
}
这里要注意,我们在调用 base.SaveChangesAsync
之前,会创建审计轨迹(AuditTrails),以确保所有的更改会在同一个事务中保存。
在上面的代码里,我们进行了两个操作:
- 为创建、更新或删除的记录设置可审计属性,
- 创建审计跟踪记录
所有继承了 IAuditableEntity
的实体,我们会为这些实体设置 Created
和 Updated
字段。在某些情况下,这些更改不是由用户而是由系统代码触发的。在这种情况下,我们会标记为“系统”进行了更改。
比如说,这可以是一个后台作业,或者数据库种子数据填充等.
private void SetAuditableProperties(Guid? userId)
{
const string systemSource = "系统"; // 系统来源
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>()) // ChangeTracker 跟踪实体的变化,IAuditableEntity 是可审计的实体接口
{
switch (entry.State) // 实体状态
{
case EntityState.Added:
entry.Entity.CreatedAtUtc = DateTime.UtcNow;
entry.Entity.CreatedBy = userId?.ToString() ?? systemSource; // 如果 userId 为 null,则使用 "系统" 作为创建者
break;
case EntityState.Modified:
entry.Entity.UpdatedAtUtc = DateTime.UtcNow;
entry.Entity.UpdatedBy = userId?.ToString() ?? systemSource; // 如果 userId 为 null,则使用 "系统" 作为更新者
break;
}
}
}
我们现在来看看如何创建审计轨迹记录。下面我们再遍历一遍 IAuditableEntity
实体,并选择那些被创建、更新或删除的。
private List<AuditTrail> 处理保存更改前的审计(Guid? userId)
{
var 可审计条目 = ChangeTracker.Entries<IAuditableEntity>()
.Where(x => x.State is EntityState.Added or EntityState.Deleted or EntityState.Modified)
.Select(x => 创建跟踪条目(userId, x))
.ToList();
return 可审计条目;
}
private static AuditTrail 创建跟踪条目(Guid? userId, EntityEntry<IAuditableEntity> entry)
{
var 跟踪条目 = new AuditTrail
{
Id = Guid.NewGuid(),
EntityName = entry.Entity.GetType().Name,
UserId = userId,
DateUtc = DateTime.UtcNow
};
设置审计跟踪属性值(entry, 跟踪条目);
设置审计跟踪导航值(entry, 跟踪条目);
设置审计跟踪引用值(entry, 跟踪条目);
return 跟踪条目;
}
审计记录可以包含以下类型的属性,包括:
- 普通属性(例如书的标题或出版年份),
- 关联属性(例如书的作者),
- 导航属性(例如作者的书籍),
我们来看看如何将属性添加到审计日志中
private static void SetAuditTrailPropertyValues(EntityEntry entry, AuditTrail trailEntry)
{
// 这些字段将由EF Core引擎自动赋值,例如在插入实体时
foreach (var property in entry.Properties.Where(x => !x.IsTemporary))
{
if (property.Metadata.IsPrimaryKey())
{
trailEntry.PrimaryKey = property.CurrentValue?.ToString();
continue;
}
// 过滤不需要出现在审核列表中的属性
if (property.Metadata.Name.Equals("PasswordHash"))
{
continue;
}
SetAuditTrailPropertyValue(entry, trailEntry, property);
}
}
private static void SetAuditTrailPropertyValue(EntityEntry entry, AuditTrail trailEntry, PropertyEntry property)
{
var propertyName = property.Metadata.Name;
switch (entry.State)
{
case EntityState.Added:
trailEntry.TrailType = TrailType.Create;
trailEntry.NewValues[propertyName] = property.CurrentValue;
break;
case EntityState.Deleted:
trailEntry.TrailType = TrailType.Delete;
trailEntry.OldValues[propertyName] = property.OriginalValue;
break;
case EntityState.Modified:
if (property.IsModified && (property.OriginalValue is null || !property.OriginalValue.Equals(property.CurrentValue)))
{
trailEntry.ChangedColumns.Add(propertyName);
trailEntry.TrailType = TrailType.Update;
trailEntry.OldValues[propertyName] = property.OriginalValue;
trailEntry.NewValues[propertyName] = property.CurrentValue;
}
break;
}
if (trailEntry.ChangedColumns.Count > 0)
{
trailEntry.TrailType = TrailType.Update;
}
}
如果你想排除任何敏感字段,可以在这里进行操作。例如,我们正在排除 PasswordHash
属性。
现在我们来看看如何将引用和导航字段添加到审计记录里:
// 设置审核跟踪引用值
private static void SetAuditTrailReferenceValues(EntityEntry entry, AuditTrail 审核跟踪)
{
foreach (var reference in entry.References.Where(x => x.IsModified))
{
var referenceName = reference.EntityEntry.Entity.GetType().Name;
审核跟踪.ChangedColumns.Add(referenceName);
}
}
// 设置审核跟踪导航值
private static void SetAuditTrailNavigationValues(EntityEntry entry, AuditTrail 审核跟踪)
{
foreach (var navigation in entry.Navigations.Where(x => x.Metadata.IsCollection && x.IsModified))
{
if (navigation.CurrentValue is not IEnumerable<object> enumerable)
{
continue;
}
var collection = enumerable.ToList();
if (collection.Count == 0)
{
continue;
}
var navigationName = collection.First().GetType().Name;
审核跟踪.ChangedColumns.Add(navigationName);
}
}
最后,我们可以运行一下应用程序,看看审计是怎么工作的。
这是系统和用户在 authors
表中设置属性的检查示例:
这里是 audit_trails
表的样子如下所示:
希望这篇博客对你有帮助,祝你编程愉快!
_原文发布于 https://antondevtips.com 于2024年8月16日.
共同学习,写下你的评论
评论加载中...
作者其他优质文章