Entity Framework Core 7 紀錄整個系統的資料變更 詳細示範

有些系統會要求將所有資料變更細節記錄下來,以防萬一資料有問題時找不到是誰修改的。在 Entity Framework Core 中可以在 ApplicationDbContext (或是其他自訂的名字中) 中透過覆寫 SaveChanges 和 SaveChangesAsync 事件,在儲存前一刻將變更的資料攔截下來,在 Entity Framework Core 5 或以上還可以寫在自訂的攔截器中(需要繼承 SaveChangesInterceptor)。只是經過許多研究,到目前為止筆者還是有一個大問題沒有辦法解決,就是如果 Id 不是在程式中產生,而是透過資料庫產生,那就沒有辦法記錄到 Id 資訊。如果不介意這個問題尚未被解決的化再繼續往下閱讀。如果有高手能夠解決這個問題,也很希望能夠留言指點。

安裝

筆者這裡是安裝 Microsoft.EntityFrameworkCore.SqlServer 和 Microsoft.EntityFrameworkCore.Design ,請依據自己的資料庫類型安裝。

資料庫欄位

這裡列出的是儲存「資料變更紀錄」的資料表,內容十分簡單:
    
-- 變更紀錄
create table ActivityLog
(
    Id         bigint identity
        constraint ActivityLog_pk
            primary key,
    TableName  nvarchar(256), -- 資料表名稱
    PrimaryKey nvarchar(450), -- 主鍵
    OldValue   nvarchar(max), -- 舊資料(json)
    NewValue   nvarchar(max), -- 新資料(json)
    UserId     nvarchar(450), -- 變更使用者代號
    Username   nvarchar(450), -- 變更使用者名稱
    CreatedAt  datetime default getdate() not null
)
    

這裡也附上實體的 C# 程式碼
    
/// <summary>
/// 變更紀錄
/// </summary>
[Table("ActivityLog")]
public partial class ActivityLog
{
    [Key]
    public long Id { get; set; }

    /// <summary>
    /// 資料表名稱
    /// </summary>
    [StringLength(256)]
    public string? TableName { get; set; }

    /// <summary>
    /// 主鍵
    /// </summary>
    [StringLength(450)]
    public string? PrimaryKey { get; set; }

    /// <summary>
    /// 舊資料(json)
    /// </summary>
    public string? OldValue { get; set; }

    /// <summary>
    /// 新資料(json)
    /// </summary>
    public string? NewValue { get; set; }

    /// <summary>
    /// 變更使用者代號
    /// </summary>
    [StringLength(450)]
    public string? UserId { get; set; }

    /// <summary>
    /// 變更使用者名稱
    /// </summary>
    [StringLength(450)]
    public string? Username { get; set; }

    /// <summary>
    /// 建立時間
    /// </summary>
    [Column(TypeName = "datetime")]
    public DateTime CreatedAt { get; set; }
}
    

取得使用者資料

這部分請直接參考筆者之前寫的一篇: ASP.NET Core 6 MVC 在 Service 層中取得 UserName,本文中使用的 IUserResolveService 就是從這篇來的。
不過這部分還是會依照不同的驗證方式需要做不同的處理。

紀錄變更資訊

在 ApplicationDbContext 中透過覆寫 SaveChanges 和 SaveChangesAsync 方法,除了我們用來記錄變更紀錄的資料表 ActivityLog 以外,其他只要有變更就紀錄下來。我們會把舊資料和新資料分別序列化為 Json ,儲存在資料表中的 OldValue 和 NewValue 欄位中,並且使用之前的 IUserResolveService 將登入的使用者資訊紀錄下來。
    
public partial class ApplicationDbContext : DbContext
{
    private readonly ILogger<ApplicationDbContext> _logger;
    private readonly IUserResolveService _userResolveService;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ILogger<ApplicationDbContext> logger,
        IUserResolveService userResolveService
    )
        : base(options)
    {
        _logger = logger;
        _userResolveService = userResolveService;
    }

    public override int SaveChanges()
    {
        AddLog();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
    {
        AddLog();
        return base.SaveChangesAsync(cancellationToken);
    }

    private static readonly JsonSerializerOptions JsonSerializerOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        // Converters = { new IgnoreInversePropertyConverter() },
        WriteIndented = false,
    };

    /// <summary>
    /// 儲存變更紀錄
    /// </summary>
    private void AddLog()
    {
        var list = ChangeTracker.Entries()
            .Where(entity => entity.Entity.GetType() != typeof(ActivityLog) && entity.State != EntityState.Unchanged)
            .Select(entity => new
            {
                TableName = entity.Entity.GetType().GetTableName(),
                OldValue = entity.State == EntityState.Added ? null : entity.OriginalValues,
                NewValue = entity.State == EntityState.Deleted ? null : entity.Entity,
            })
            .ToList();

        foreach (var data in list)
        {
            var activityLog = new ActivityLog
            {
                TableName = data.TableName,
                PrimaryKey = data.NewValue?.GetType().GetProperty("Id")?.GetValue(data.NewValue)?.ToString(),
                UserId = _userResolveService.GetCurrentSessionUserId(),
                Username = _userResolveService.GetCurrentSessionUserName(),
                OldValue = data.OldValue == null
                    ? null
                    : JsonSerializer.Serialize(data.OldValue?.ToObject(), JsonSerializerOptions),
                NewValue = JsonSerializer.Serialize(data.NewValue, JsonSerializerOptions),
                CreatedAt = DateTime.Now,
            };

            this.ActivityLogs.Add(activityLog);
        }
    }

}
    

測試看看,阿,出現錯誤了:
    
Unhandled exception. System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path:
    

這個問題是因為只要有關聯,在 Entity Framework Core 的實體就會有 virtual 的屬性,例如 User 資料表 和 UserRole 資料表有關聯,所以有 UserRoles 屬性
    
    [InverseProperty("User")]
    public virtual ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();
    

因為 UserRole 和 User 有關聯,所以 UserRole 中也有 User:
    
    [ForeignKey("UserId")]
    [InverseProperty("UserRoles")]
    public virtual User User { get; set; } = null!
    

在 Json 序列化時就會緊密的糾纏,循環參照後 Json 資料層數超過 64,就會出現上面的錯誤。

如果是使用 Newtonsoft.Json 比較容易解決,但是我們是使用內建的 System.Text.Json ,比較麻煩。最容易的方式就是一一加上 JsonIgnoreAttribute
    
    [JsonIgnore]
    [ForeignKey("UserId")]
    [InverseProperty("UserRoles")]
    public virtual User User { get; set; } = null!
    

這很蠢,而且每次資料同步時都需要再處理一次(或和上一個版本比對快速復原),但是非常有效...。

不過因為所有我們不想要的屬性上面都有 InversePropertyAttribute 這個 Attribute,所以要解決的目標也很明確,就是有就忽略序列化就對了。 說來簡單,不過經過許多嘗試,筆者最後還是終於生出來了這個至少能用的 System.Text.Json 的 JsonConverter。這個 JsonConverter 就是專門用在這一個地方,只會序列化,並沒有想要讓他做反序列化,所以直接忽略:
    
public class IgnoreInversePropertyConverter : JsonConverter<object>
{
    public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
    {
        var typeOfValue = value.GetType();
        writer.WriteStartObject();

        foreach (PropertyInfo prop in typeOfValue.GetProperties())
        {
            // 忽略 InversePropertyAttribute 屬性
            if (prop.GetCustomAttribute(typeof(InversePropertyAttribute)) == null)
            {
                object? propValue = prop.GetValue(value);
                writer.WritePropertyName(prop.Name);
                JsonSerializer.Serialize(writer, propValue, prop.PropertyType, options);
            }
        }

        writer.WriteEndObject();
    }
}
    

阿這個要怎麼使用?眼尖的朋友應該有發現上面的程式碼中有一行被註解了,把他打開(取消註解)就可以了。
    
    private static readonly JsonSerializerOptions JsonSerializerOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        Converters = { new IgnoreInversePropertyConverter() },
        WriteIndented = false,
    };
    



參考資料:
Microsoft.Learn - DbContext.SaveChanges Method
Microsoft.Learn - SaveChangesInterceptor Class JetBrains Blog - How to Implement a Soft Delete Strategy with Entity Framework Core SlackOverflow - System.Text.Json add JsonIgnore attribute at runtime

留言