一個強大且靈活的 Entity Framework Core 實體變更追蹤函式庫,支援多種格式化模板、事件處理和靈活的過濾條件設定。
- 自動變更追蹤:透過 EF Core 攔截器自動追蹤實體變更
- MetadataType 支援:使用
[TrackChanges]屬性標記需要追蹤的屬性 - 靈活過濾:支援白名單/黑名單模式,精確控制追蹤範圍
- 事件驅動架構:提供變更前後的完整事件生命週期
- 多種內建模板:default、compact、verbose、json、bbcode
- 自定義模板:輕鬆建立符合需求的格式化模板
- 變數替換引擎:支援靈活的模板變數系統
- 動態切換:執行時動態註冊和切換模板
- 優先級排序:可設定屬性的追蹤優先級
- 條件邏輯:支援基於上下文的條件格式化
- 國際化支援:輕鬆建立多語言格式化模板
- 效能優化:內建快取機制,降低效能開銷
Install-Package EntityTrackerdotnet add package EntityTracker<PackageReference Include="EntityTracker" Version="1.0.0" />在 Program.cs 或 Startup.cs 中註冊服務:
using EntityTracker.Extensions;
// 註冊變更格式化服務
services.AddChangeFormatting();
// 註冊 DbContext 並添加攔截器
services.AddDbContext<YourDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(connectionString);
options.AddInterceptors(
new ChangeTrackingInterceptor(
serviceProvider.GetRequiredService<ILogger<ChangeTrackingInterceptor>>(),
serviceProvider
)
);
});使用 [TrackChanges] 屬性標記需要追蹤的屬性:
using EntityTracker.Attributes;
using System.ComponentModel.DataAnnotations;
[MetadataType(typeof(UserMetadata))]
public partial class User
{
internal class UserMetadata
{
[TrackChanges("使用者名稱", Priority = 1)]
[Display(Name = "使用者名稱")]
public string? Name { get; set; }
[TrackChanges("電子郵件", Priority = 2)]
[Display(Name = "電子郵件")]
public string? Email { get; set; }
[TrackChanges("電話號碼", Priority = 3)]
[Display(Name = "電話號碼")]
public string? Phone { get; set; }
// 不追蹤的屬性
[Display(Name = "備註")]
public string? Memo { get; set; }
// 明確禁用追蹤
[TrackChanges(Enabled = false)]
[Display(Name = "內部 ID")]
public string? InternalId { get; set; }
}
}當實體被修改並保存時,變更會自動被記錄:
var user = await dbContext.Users.FindAsync(userId);
user.Name = "新名稱";
user.Email = "new@example.com";
await dbContext.SaveChangesAsync();
// 輸出:使用者名稱: 舊名稱 -> 新名稱 | 電子郵件: old@example.com -> new@example.com系統提供多種內建模板:
services.AddDbContext<YourDbContext>((serviceProvider, options) =>
{
var interceptor = new ChangeTrackingInterceptor(logger, serviceProvider)
{
FormatTemplateName = "verbose" // 使用詳細格式
};
options.AddInterceptors(interceptor);
});可用的內建模板:
| 模板名稱 | 說明 | 範例輸出 |
|---|---|---|
default |
標準格式 | 名稱: 舊值 -> 新值 |
compact |
簡潔格式 | 名稱:舊值->新值 |
verbose |
詳細格式 | [EntityType] 名稱 從 '舊值' 變更為 '新值' |
json |
JSON 格式 | {"property":"Name","from":"舊值","to":"新值"} |
bbcode |
BBCode 格式 | [color=#ff6875][User][/color] 舊值 -> 新值 |
using EntityTracker.Formatting;
public class MyCustomTemplate : DefaultChangeFormatTemplate
{
public MyCustomTemplate()
{
PropertyChangeTemplate = "?? {{DisplayName}}: {{OriginalValue}} ?? {{CurrentValue}}";
SummaryTemplate = "?? {{EntityType}} 發生 {{ChangeCount}} 項變更:{{Changes}}";
ChangesSeparator = " | ";
}
}
// 註冊模板
services.AddChangeFormatting(registry =>
{
registry.RegisterTemplate("custom", new MyCustomTemplate());
registry.SetDefaultTemplate("custom");
});public class AuditLogTemplate : IChangeFormatTemplate
{
public string FormatPropertyChange(PropertyChangeContext context)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
return $"[{timestamp}] {context.EntityType.Name}.{context.PropertyName}: " +
$"\"{context.OriginalValue}\" → \"{context.CurrentValue}\"";
}
public string FormatChangesSummary(IEnumerable<PropertyChangeContext> contexts)
{
var contextList = contexts.ToList();
if (contextList.Count == 0) return string.Empty;
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
var changes = contextList.Select(FormatPropertyChange);
return $"[{timestamp}] AUDIT: {contextList.First().EntityType.Name} " +
$"modified ({contextList.Count} changes)\n{string.Join("\n", changes)}";
}
}在模板字串中可使用以下變數(格式為 {{變數名}}):
{{EntityType}}- 實體類型名稱{{PropertyName}}- 屬性名稱{{DisplayName}}- 屬性顯示名稱{{OriginalValue}}- 原始值{{CurrentValue}}- 當前值{{EntityState}}- 實體狀態{{ChangeCount}}- 變更數量(摘要模板){{Changes}}- 格式化的變更列表(摘要模板)
using EntityTracker.ChangeTracking;
services.AddSingleton<IChangeTrackingFilter>(provider =>
{
var options = new ChangeTrackingFilterOptions()
.UseWhitelistMode()
.TrackEntity<User>()
.TrackEntity<Order>()
.TrackEntity<Product>();
return new ChangeTrackingFilter(options);
});services.AddSingleton<IChangeTrackingFilter>(provider =>
{
var options = new ChangeTrackingFilterOptions()
.UseBlacklistMode()
.ExcludeEntity<AuditLog>()
.ExcludeEntity<SystemLog>()
.ExcludeEntity<TemporaryData>();
return new ChangeTrackingFilter(options);
});using EntityTracker.ChangeTracking;
using Microsoft.EntityFrameworkCore.ChangeTracking;
public class UserChangeHandler : EntitySpecificChangeTrackedEventHandler<User>
{
private readonly ILogger<UserChangeHandler> _logger;
private readonly IEmailService _emailService;
public UserChangeHandler(
ILogger<UserChangeHandler> logger,
IEmailService emailService)
{
_logger = logger;
_emailService = emailService;
}
public override async Task ChangedAsync(User entity, EntityEntry entry, List<string> changes)
{
_logger.LogInformation("User {UserId} was modified: {Changes}",
entity.Id, string.Join(", ", changes));
// 發送通知郵件
if (changes.Any(c => c.Contains("Email")))
{
await _emailService.SendEmailChangeNotificationAsync(entity);
}
}
public override async Task AddedAsync(User entity, EntityEntry entry)
{
_logger.LogInformation("New user {UserId} was created", entity.Id);
await _emailService.SendWelcomeEmailAsync(entity);
}
public override async Task DeletedAsync(User entity, EntityEntry entry)
{
_logger.LogWarning("User {UserId} was deleted", entity.Id);
await _emailService.SendAccountClosureEmailAsync(entity);
}
}
// 註冊事件處理器
services.AddScoped<IEntitySpecificChangeTrackedEventHandler, UserChangeHandler>();
services.AddScoped<ChangeTrackedEventDispatcher>();根據不同屬性類型使用不同格式:
public class ConditionalTemplate : DefaultChangeFormatTemplate
{
public override string FormatPropertyChange(PropertyChangeContext context)
{
// 密碼欄位特殊處理
if (context.PropertyName.Contains("Password", StringComparison.OrdinalIgnoreCase))
{
return $"{context.DisplayName}: [密碼已變更]";
}
// 空值處理
if (context.OriginalValue == null)
{
return $"{context.DisplayName}: 設定為 {context.CurrentValue}";
}
if (context.CurrentValue == null)
{
return $"{context.DisplayName}: 已清除 (原值: {context.OriginalValue})";
}
// 數字差異顯示
if (decimal.TryParse(context.OriginalValue?.ToString(), out var original) &&
decimal.TryParse(context.CurrentValue?.ToString(), out var current))
{
var difference = current - original;
var sign = difference >= 0 ? "+" : "";
return $"{context.DisplayName}: {context.OriginalValue} → {context.CurrentValue} ({sign}{difference})";
}
return base.FormatPropertyChange(context);
}
}public class LocalizedTemplate : IChangeFormatTemplate
{
private readonly string _culture;
public LocalizedTemplate(string culture = "zh-TW") => _culture = culture;
public string FormatPropertyChange(PropertyChangeContext context)
{
return _culture switch
{
"en" => $"{context.DisplayName}: {context.OriginalValue} -> {context.CurrentValue}",
"zh-TW" => $"{context.DisplayName}:{context.OriginalValue} → {context.CurrentValue}",
"ja" => $"{context.DisplayName}: {context.OriginalValue} ?? {context.CurrentValue} ?",
_ => $"{context.DisplayName}: {context.OriginalValue} -> {context.CurrentValue}"
};
}
// ... FormatChangesSummary 實作
}
// 根據使用者語言註冊
services.AddChangeFormatting(registry =>
{
registry.RegisterTemplate("en", new LocalizedTemplate("en"));
registry.RegisterTemplate("zh-TW", new LocalizedTemplate("zh-TW"));
registry.RegisterTemplate("ja", new LocalizedTemplate("ja"));
});public class JsonAuditTemplate : IChangeFormatTemplate
{
public string FormatPropertyChange(PropertyChangeContext context)
{
return JsonSerializer.Serialize(new
{
timestamp = DateTime.UtcNow,
entityType = context.EntityType.Name,
property = context.PropertyName,
displayName = context.DisplayName,
originalValue = context.OriginalValue?.ToString(),
currentValue = context.CurrentValue?.ToString()
});
}
public string FormatChangesSummary(IEnumerable<PropertyChangeContext> contexts)
{
var contextList = contexts.ToList();
return JsonSerializer.Serialize(new
{
timestamp = DateTime.UtcNow,
entityType = contextList.First().EntityType.Name,
changeCount = contextList.Count,
changes = contextList.Select(c => new
{
property = c.PropertyName,
displayName = c.DisplayName,
originalValue = c.OriginalValue?.ToString(),
currentValue = c.CurrentValue?.ToString()
})
}, new JsonSerializerOptions { WriteIndented = true });
}
}標記需要追蹤的屬性。
屬性:
DisplayName(string?): 變更記錄的顯示名稱Enabled(bool): 是否啟用追蹤,預設為 truePriority(int): 追蹤優先級,數字越小優先級越高
明確標記不追蹤的屬性(用於類別層級追蹤時排除特定屬性)。
public interface IChangeFormatTemplate
{
string FormatPropertyChange(PropertyChangeContext context);
string FormatChangesSummary(IEnumerable<PropertyChangeContext> contexts);
}public interface IChangeTrackingFilter
{
bool ShouldTrackEntity(Type entityType);
}public interface IEntitySpecificChangeTrackedEventHandler
{
Type EntityType { get; }
bool SupportsEntity(Type entityType);
Task ChangingAsync(EntityEntry entry);
Task ChangedAsync(EntityEntry entry, List<string> changes);
Task AddingAsync(EntityEntry entry);
Task AddedAsync(EntityEntry entry);
Task DeletingAsync(EntityEntry entry);
Task DeletedAsync(EntityEntry entry);
}public class ChangeTrackingFilterOptions
{
public HashSet<Type> TrackedEntityTypes { get; }
public HashSet<Type> ExcludedEntityTypes { get; }
public bool WhitelistMode { get; set; }
// Fluent API
public ChangeTrackingFilterOptions TrackEntity<TEntity>();
public ChangeTrackingFilterOptions ExcludeEntity<TEntity>();
public ChangeTrackingFilterOptions UseWhitelistMode();
public ChangeTrackingFilterOptions UseBlacklistMode();
}- 快取機制:MetadataAttributeHelper 使用快取避免重複反射
- 延遲評估:只在需要時才執行格式化
- 過濾器:使用過濾器減少不必要的追蹤
- 批次處理:SaveChanges 時批次處理所有變更
- 僅追蹤必要的屬性
- 使用白名單模式限制追蹤範圍
- 對於大量資料操作,考慮暫時禁用追蹤
- 選擇適合的格式化模板(簡單模板效能更好)
歡迎提交 Pull Request 或回報問題!
- Fork 此專案
- 建立您的特性分支 (
git checkout -b feature/AmazingFeature) - 提交您的更改 (
git commit -m 'Add some AmazingFeature') - 推送到分支 (
git push origin feature/AmazingFeature) - 開啟 Pull Request
本專案採用 MIT 授權 - 詳見 LICENSE 檔案
- ? 初始版本發布
- ?? 支援 .NET 6/7/8/9
- ?? 多種內建格式化模板
- ?? 完整的事件處理系統
- ?? NuGet 套件發布
A: 可以在 DbContext 層級設定過濾器,或使用 IgnoreTrackingAttribute:
[IgnoreTracking]
public string? TemporaryData { get; set; }A: 目前主要支援純量屬性的追蹤。導航屬性的變更需要透過外鍵屬性來追蹤。
A: 可以在建立攔截器時設定 FormatTemplateName 屬性:
var interceptor = new ChangeTrackingInterceptor(logger, serviceProvider)
{
FormatTemplateName = userPreferences.TemplatePreference
};A: 透過快取和過濾器優化,對一般應用程式的效能影響很小。建議在效能敏感的操作中使用白名單模式。
Made with ?? by Antfire70007