在现代 Web 应用程序中,出于监控、合规性和调试原因,可能需要跟踪数据更改。此过程称为创建审计跟踪,允许开发人员查看谁进行了更改、何时进行了更改以及更改的内容。审计跟踪提供对数据所做更改的历史记录。
在这篇博文中,我将展示如何使用 Entity Framework Core (EF Core) 在 ASP.NET Core 应用程序中实现审计跟踪。
我们将审核的应用程序
今天,我们将为具有以下实体的 “Books” 应用程序实施审计跟踪:
我发现在所有需要审核的实体中包含以下属性非常有用:
public interface IAuditableEntity
{
DateTime CreatedAtUtc { get; set; }
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; }
public DateTime? UpdatedAtUtc { get; set; }
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; }
public DateTime? UpdatedAtUtc { get; set; }
public string CreatedBy { get; set; } = null!;
public string? UpdatedBy { get; set; }
}
现在我们有几个选项,我们可以为每个实体手动实施审计跟踪,或者使用一种自动应用于所有实体的实施。在这篇博文中,我将向您展示第二个选项,因为它更强大且更易于维护。
在 EF Core 中配置审核跟踪实体
实施审计跟踪的第一步是创建一个实体,该实体将审计日志存储在单独的数据库表中。此实体应捕获详细信息,例如实体类型、主键、已更改属性的列表、旧值、新值和更改的时间戳。
public class AuditTrail
{
public required Guid Id { get; set; }
public Guid? UserId { get; set; }
public User? User { get; set; }
public TrailType TrailType { get; set; }
public DateTime DateUtc { get; set; }
public required string EntityName { get; set; }
public string? PrimaryKey { get; set; }
public Dictionary<string, object?> OldValues { get; set; } = [];
public Dictionary<string, object?> NewValues { get; set; } = [];
public List<string> ChangedColumns { get; set; } = [];
}
这里我们有一个对实体的引用。根据您的应用程序需求,您可能有此引用,也可能没有此引用。User
每个审计跟踪可以属于以下类型:
已创建实体
实体已更新
实体已删除
public enum TrailType : byte
{
None = 0,
Create = 1,
Update = 2,
Delete = 3
}
让我们看看如何在 EF Core 中配置审核线索实体:
public class AuditTrailConfiguration : IEntityTypeConfiguration<AuditTrail>
{
public void Configure(EntityTypeBuilder<AuditTrail> builder)
{
builder.ToTable("audit_trails");
builder.HasKey(e => e.Id);
builder.HasIndex(e => e.EntityName);
builder.Property(e => e.Id);
builder.Property(e => e.UserId);
builder.Property(e => e.EntityName).HasMaxLength(100).IsRequired();
builder.Property(e => e.DateUtc).IsRequired();
builder.Property(e => e.PrimaryKey).HasMaxLength(100);
builder.Property(e => e.TrailType).HasConversion<string>();
builder.Property(e => e.ChangedColumns).HasColumnType("jsonb");
builder.Property(e => e.OldValues).HasColumnType("jsonb");
builder.Property(e => e.NewValues).HasColumnType("jsonb");
builder.HasOne(e => e.User)
.WithMany()
.HasForeignKey(e => e.UserId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
}
}
我喜欢使用 json 列来表示 、 和 。在这篇博文中,在我的代码示例中,我使用了 Postgres 数据库。ChangedColumnsOldValuesNewValues
如果您使用的是 SQLite 或其他不支持 json 列的数据库,则可以在实体中使用字符串类型,并创建一个 EF Core 转换,将对象序列化为字符串以将其保存在数据库中。从数据库中检索数据时,此 Conversion 会将 JSON 字符串反序列化为相应的 .NET 类型。
在 Postgres 数据库中,使用 NET 8 和 EF 8 时,您需要能够在 “jsonb” 列中拥有动态 json:EnableDynamicJson
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.EnableDynamicJson();
builder.Services.AddDbContext<ApplicationDbContext>((provider, options) =>
{
var interceptor = provider.GetRequiredService<AuditableInterceptor>();
options.EnableSensitiveDataLogging()
.UseNpgsql(dataSourceBuilder.Build(), npgsqlOptions =>
{
npgsqlOptions.MigrationsHistoryTable("__MyMigrationsHistory", "devtips_audit_trails");
})
.AddInterceptors(interceptor)
.UseSnakeCaseNamingConvention();
});
为所有可审计实体实施审计跟踪
我们可以在 EF Core DbContext 中实现审核,该审核将自动应用于继承自 的所有实体。但首先我们需要获取对这些实体执行创建、更新或删除操作的用户。IAuditableEntity
让我们定义一个 ,它将从当前 的 中检索当前用户标识符 :CurrentSessionProviderClaimsPrincipleHttpRequest
public interface ICurrentSessionProvider
{
Guid? GetUserId();
}
public class CurrentSessionProvider : ICurrentSessionProvider
{
private readonly Guid? _currentUserId;
public CurrentSessionProvider(IHttpContextAccessor accessor)
{
var userId = accessor.HttpContext?.User.FindFirstValue("userid");
if (userId is null)
{
return;
}
_currentUserId = Guid.TryParse(userId, out var guid) ? guid : null;
}
public Guid? GetUserId() => _currentUserId;
}
您需要注册提供商,并在 DI 中:IHttpContextAccessor
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped\<ICurrentSessionProvider, CurrentSessionProvider>();
要创建审计跟踪,我们可以使用 EF Core Changer Tracker 功能来获取已创建、更新或删除的实体。
我们需要注入到 DbContext 中并覆盖 method 来创建审计跟踪。ICurrentSessionProviderSaveChangesAsync
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);
}
}
请注意,我们在调用之前创建以确保我们将对数据库的所有更改保存在单个事务中。AuditTrailsbase.SaveChangesAsync
在上面的代码中,我们执行了两个操作:
为 created、updated 或 deleted 记录设置可审计属性
创建审计跟踪记录
对于从 we set 和 fields 继承的所有实体。在某些情况下,更改可能不是由用户触发的,而是由代码触发的。在这种情况下,我们设置 “system” 执行更改。IAuditableEntityCreatedUpdated
例如,这可以是后台作业、数据库种子设定等。
private void SetAuditableProperties(Guid? userId)
{
const string systemSource = "system";
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAtUtc = DateTime.UtcNow;
entry.Entity.CreatedBy = userId?.ToString() ?? systemSource;
break;
case EntityState.Modified:
entry.Entity.UpdatedAtUtc = DateTime.UtcNow;
entry.Entity.UpdatedBy = userId?.ToString() ?? systemSource;
break;
}
}
}
现在,我们来看看如何创建审计跟踪记录。同样,我们将遍历实体并选择已创建、更新或删除的实体:IAuditableEntity
private List<AuditTrail> HandleAuditingBeforeSaveChanges(Guid? userId)
{
var auditableEntries = ChangeTracker.Entries<IAuditableEntity>()
.Where(x => x.State is EntityState.Added or EntityState.Deleted or EntityState.Modified)
.Select(x => CreateTrailEntry(userId, x))
.ToList();
return auditableEntries;
}
private static AuditTrail CreateTrailEntry(Guid? userId, EntityEntry<IAuditableEntity> entry)
{
var trailEntry = new AuditTrail
{
Id = Guid.NewGuid(),
EntityName = entry.Entity.GetType().Name,
UserId = userId,
DateUtc = DateTime.UtcNow
};
SetAuditTrailPropertyValues(entry, trailEntry);
SetAuditTrailNavigationValues(entry, trailEntry);
SetAuditTrailReferenceValues(entry, trailEntry);
return trailEntry;
}
审计跟踪记录可以包含以下类型的属性:
plain 属性(如 Book's Title 或 Year of Publication)
reference 属性(如 Book's Author)
navigation 属性(如 Author's Books)
让我们看看如何将 plain properties 添加到审计跟踪中:
private static void SetAuditTrailPropertyValues(EntityEntry entry, AuditTrail trailEntry)
{
// Skip temp fields (that will be assigned automatically by ef core engine, for example: when inserting an entity
foreach (var property in entry.Properties.Where(x => !x.IsTemporary))
{
if (property.Metadata.IsPrimaryKey())
{
trailEntry.PrimaryKey = property.CurrentValue?.ToString();
continue;
}
// Filter properties that should not appear in the audit list
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;
}
}
如果您需要排除任何敏感字段,可以在此处执行此操作。例如,我们从审计跟踪中排除了 property。PasswordHash
现在,让我们探索如何将引用和导航属性添加到审计跟踪中:
private static void SetAuditTrailReferenceValues(EntityEntry entry, AuditTrail trailEntry)
{
foreach (var reference in entry.References.Where(x => x.IsModified))
{
var referenceName = reference.EntityEntry.Entity.GetType().Name;
trailEntry.ChangedColumns.Add(referenceName);
}
}
private static void SetAuditTrailNavigationValues(EntityEntry entry, AuditTrail trailEntry)
{
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;
trailEntry.ChangedColumns.Add(navigationName);
}
}
最后,我们可以运行我们的应用程序来查看审计的实际效果。
以下是表中系统和用户设置的审计属性示例:authors
表格如下所示:audit_trails