有些系統會要求將所有資料變更細節記錄下來,以防萬一資料有問題時找不到是誰修改的。在 Entity Framework Core 中可以在 ApplicationDbContext (或是其他自訂的名字中) 中透過覆寫 SaveChanges 和 SaveChangesAsync 事件,在儲存前一刻將變更的資料攔截下來,在 Entity Framework Core 5 或以上還可以寫在自訂的攔截器中(需要繼承 SaveChangesInterceptor)。只是經過許多研究,到目前為止筆者還是有一個大問題沒有辦法解決,就是如果 Id 不是在程式中產生,而是透過資料庫產生,那就沒有辦法記錄到 Id 資訊。如果不介意這個問題尚未被解決的化再繼續往下閱讀。如果有高手能夠解決這個問題,也很希望能夠留言指點。
這裡也附上實體的 C# 程式碼
不過這部分還是會依照不同的驗證方式需要做不同的處理。
測試看看,阿,出現錯誤了:
這個問題是因為只要有關聯,在 Entity Framework Core 的實體就會有 virtual 的屬性,例如 User 資料表 和 UserRole 資料表有關聯,所以有 UserRoles 屬性
因為 UserRole 和 User 有關聯,所以 UserRole 中也有 User:
在 Json 序列化時就會緊密的糾纏,循環參照後 Json 資料層數超過 64,就會出現上面的錯誤。
如果是使用 Newtonsoft.Json 比較容易解決,但是我們是使用內建的 System.Text.Json ,比較麻煩。最容易的方式就是一一加上 JsonIgnoreAttribute
這很蠢,而且每次資料同步時都需要再處理一次(或和上一個版本比對快速復原),但是非常有效...。
不過因為所有我們不想要的屬性上面都有 InversePropertyAttribute 這個 Attribute,所以要解決的目標也很明確,就是有就忽略序列化就對了。 說來簡單,不過經過許多嘗試,筆者最後還是終於生出來了這個至少能用的 System.Text.Json 的 JsonConverter。這個 JsonConverter 就是專門用在這一個地方,只會序列化,並沒有想要讓他做反序列化,所以直接忽略:
阿這個要怎麼使用?眼尖的朋友應該有發現上面的程式碼中有一行被註解了,把他打開(取消註解)就可以了。
參考資料:
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
安裝
筆者這裡是安裝 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
留言
張貼留言
如果有任何問題、建議、想說的話或文章題目推薦,都歡迎留言或來信: a@ruyut.com