前言
上次說了利用 AOP 思想實現了審計日誌功能,不過有同學反饋還是無法實現完全無侵入,於是我又重構了一版新的。
回顧一下:Asp-Net-Core開發筆記:實現動態審計日誌功能
現在已經可以實現對業務程式碼完全無侵入的審計日誌了,在需要審計的介面上加上 [AuditLog]
特性,就可以記錄這個介面的操作日誌,還有相關的實體變化記錄,還算是方便。
PS:後面我發現 ABP 裡自帶審計功能,突然感覺有點🤡了
重構
先對之前的程式碼進行重構,之前把跟審計有關的程式碼分散到各個目錄中,這個功能其實是個整體,應該把程式碼歸集到一起比較好。
建立 src/Acme.Demo/Contrib/Audit
目錄 (注:Acme.Demo
是專案名稱,隨便起的)
目錄結構
目錄結構如下
Audit
├─ Services
│ ├─ IAuditLogService.cs
│ ├─ AuditLogService.cs
│ └─ AuditLogMongoService.cs
├─ Middlewares
│ └─ AuditLogMiddleware.cs
├─ Filters
│ └─ AuditLogAttribute.cs
├─ Extensions
│ └─ CfgAudit.cs
├─ EventHandlers
│ └─ FreeSqlAuditEventHandler.cs
├─ Entities
│ ├─ EntityChangeInfo.cs
│ └─ AuditLog.cs
└─ AuditConstant.cs
6 directories, 10 files
建立 EntityChangeInfo
實體
用來儲存實體變化
public class EntityChangeInfo {
public string Entity { get; set; }
public string Action { get; set; }
public string Sql { get; set; }
public Dictionary<string, object?> Parameters { get; set; }
}
AuditLog
重構
之前我們是把實體變化內容直接儲存在 AuditLog
裡
現在要分離開,使用 List<EntityChangeInfo>
型別的 EntityChanges
屬性來存放實體變化
public class AuditLog {
/// <summary>
/// 事件唯一標識
/// </summary>
public string EventId { get; set; }
/// <summary>
/// 事件型別(例如:登入、登出、資料修改等)
/// </summary>
public string EventType { get; set; }
/// <summary>
/// 執行操作的使用者標識
/// </summary>
public string UserId { get; set; }
/// <summary>
/// 執行操作的使用者名稱
/// </summary>
public string Username { get; set; }
/// <summary>
/// 事件發生的時間戳
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// 使用者的IP地址
/// </summary>
public string? IPAddress { get; set; }
/// <summary>
/// 實體更改內容,可根據實際情況以JSON格式儲存
/// </summary>
public List<EntityChangeInfo>? EntityChanges { get; set; } = new();
/// <summary>
/// 路由資訊
/// </summary>
public Dictionary<string, object?> RouteData { get; set; }
/// <summary>
/// 事件描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 額外資訊 (考慮以 JSON 格式儲存)
/// </summary>
public object? Extra { get; set; }
/// <summary>
/// 建立時間
/// </summary>
public DateTime CreatedTime { get; set; } = DateTime.UtcNow;
/// <summary>
/// 修改時間
/// </summary>
public DateTime ModifiedTime { get; set; } = DateTime.UtcNow;
}
過濾器重構
修改 AuditLogAttribute
類
涉及到的改動不多,就是簡化了引數,只需要傳入 EventType
就行
其他的都會自動獲取
實體變化部分,需要使用到 ORM 的功能,接下來會介紹
public class AuditLogAttribute : ActionFilterAttribute {
public string EventType { get; set; }
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) {
var sp = context.HttpContext.RequestServices;
var ctxItems = context.HttpContext.Items;
try {
var authService = sp.GetRequiredService<AuthService>();
// 在操作執行前
var executedContext = await next();
// 在操作執行後
// 獲取當前使用者的身份資訊
var user = await authService.GetUserFromJwt(executedContext.HttpContext.User);
// 構造AuditLog物件
var auditLog = new AuditLog {
EventId = Guid.NewGuid().ToString(),
EventType = this.EventType,
UserId = user.UserId,
Username = user.Username,
Timestamp = DateTime.UtcNow,
IPAddress = GetIpAddress(executedContext.HttpContext),
Description = $"操作型別:{this.EventType}",
};
if (ctxItems.TryGetValue(AuditConstant.EntityChanges, out var item)) {
auditLog.EntityChanges = item as List<EntityChangeInfo>;
}
var routeData = new Dictionary<string, object?>();
foreach (var (key, value) in context.RouteData.Values) {
routeData.Add(key, value);
}
auditLog.RouteData = routeData;
var auditService = sp.GetRequiredService<IAuditLogService>();
await auditService.LogAsync(auditLog);
} catch (Exception ex) {
var logger = sp.GetRequiredService<ILogger<AuditLogAttribute>>();
logger.LogError(ex, "An error occurred while logging audit information.");
}
Console.WriteLine(
"執行 AuditLogAttribute, " +
$"EventId: {ctxItems["AuditLog_EventId"]}");
}
private string? GetIpAddress(HttpContext httpContext) {
// 首先檢查X-Forwarded-For頭(當應用部署在代理後面時)
var forwardedFor = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(forwardedFor)) {
return forwardedFor.Split(',').FirstOrDefault(); // 可能包含多個IP地址
}
// 如果沒有X-Forwarded-For頭,或者需要直接獲取連線的遠端IP地址
return httpContext.Connection.RemoteIpAddress?.ToString();
}
}
獲取實體變化
實體變化部分,需要使用到 ORM 的功能,不同的 ORM 能實現的實體變化監控不太一樣,需要每種 ORM 寫一個
我目前只實現了 FreeSQL 的實體變化監控
程式碼在 FreeSqlAuditEventHandler
中
public class FreeSqlAuditEventHandler {
private readonly ILogger<FreeSqlAuditEventHandler> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IDictionary<object, object?> _ctxItems;
public FreeSqlAuditEventHandler(IHttpContextAccessor httpContextAccessor,
ILogger<FreeSqlAuditEventHandler> logger) {
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_ctxItems = httpContextAccessor.HttpContext?.Items ?? new Dictionary<object, object?>();
}
public void HandleCurdBefore(object? sender, CurdBeforeEventArgs args) {
// 捕獲變更資訊
var changeInfo = new EntityChangeInfo {
Entity = args.EntityType.Name,
Action = Enum.GetName(typeof(CurdType), args.CurdType) ?? "unknown",
Sql = args.Sql,
Parameters = new Dictionary<string, object?>(
args.DbParms.Select(p => new KeyValuePair<string, object?>(p.ParameterName, p.Value))
)
};
// 處理CurdBefore事件,將實體變化資訊儲存到HttpContext.Items
_logger.LogDebug(
$"執行 FreeSql CurdBefore, " +
$"EventId: {_httpContextAccessor.HttpContext?.Items["AuditLog_EventId"]}, " +
$"entityType: {args.EntityType.Name}, " +
$"crud: {Enum.GetName(typeof(CurdType), args.CurdType)}, ");
List<EntityChangeInfo> changes = new();
if (_ctxItems.TryGetValue(AuditConstant.EntityChanges, out var item)) {
changes = item as List<EntityChangeInfo> ?? new List<EntityChangeInfo>();
} else {
_ctxItems[AuditConstant.EntityChanges] = changes;
}
changes.Add(changeInfo);
}
}
這裡很簡單,利用 FreeSQL 的 Aop.CurdBefore
事件,把 HandleCurdBefore
繫結到事件上,就可以獲取實體的變化了。
// 建立 IFreeSQL 例項
IFreeSql inst = ...;
// 實體 CRUD操作(create read update delete)事件
inst.Aop.CurdBefore += auditEventHandler.HandleCurdBefore;
這裡吐槽一下 FreeSQL 的命名,一般都叫 crud ,你卻搞特殊變成 curd ……
不過為了用國產資料庫,只能湊合用咯~
擴充套件方法
為了使用方便
我把註冊服務和中介軟體都放在擴充套件方法中,符合 AspNetCore 的開發習慣
public static class CfgAudit {
public static IServiceCollection AddAudit(this IServiceCollection services, IConfiguration conf) {
services.AddSingleton<IAuditLogService>(sp =>
new AuditLogMongoService(conf.GetConnectionString("MongoDB"), "stu_data_hub"));
services.AddSingleton<FreeSqlAuditEventHandler>();
return services;
}
public static IApplicationBuilder UseAudit(this IApplicationBuilder app) {
app.UseMiddleware<AuditLogMiddleware>();
return app;
}
}
在 Program.cs
裡註冊
// 註冊服務
builder.Services.AddAudit(builder.Configuration);
// 新增中介軟體
app.UseAudit();
PS:這裡把配置傳進去有點蠢,其實我完全可以在 AddAudit
方法裡透過依賴注入的方式來獲取配置物件的,不過既然都這樣寫了,懶得改了。
使用效果
來看下使用效果
首先在需要審計的介面上加上 [AuditLog]
特性
/// <summary>
/// 設定反饋結果
/// </summary>
[AuditLog(EventType = "設定反饋結果")]
[HttpPost("{taskId}/sub-tasks/{subId}/set-feedback")]
public async Task<ApiResponse> SetSubTaskFeedback(string taskId, string subId, [FromBody] SubTaskFeedbackDto dto) {}
之後在 MongoDB 裡可以看到審計日誌(資料已脫敏)
{
"_id": {
"$oid": "65ff019f6de4b7290e1da9e9"
},
"EventId": "eb81f052-ce84-4923-bf9e-57582e464992",
"EventType": "設定反饋結果",
"UserId": "eb81f052",
"Username": "使用者名稱",
"Timestamp": {
"$date": "2024-03-23T16:21:49.697Z"
},
"IPAddress": "1.2.3.4",
"EntityChanges": [
{
"Entity": "實體名稱",
"Action": "Select",
"Sql": "Select 語句已脫敏",
"Parameters": {}
},
{
"Entity": "實體名稱",
"Action": "Update",
"Sql": "UPDATE entity set some_col=:p_0",
"Parameters": {
":p_0": 6
}
}
],
"RouteData": {
"area": "Market",
"action": "SetSubTaskFeedback",
"controller": "Task",
"taskId": "eb81f052",
"subId": "57582e464992"
},
"Description": "操作型別:設定反饋結果",
"Extra": null,
"CreatedTime": {
"$date": "2024-03-23T16:21:49.697Z"
},
"ModifiedTime": {
"$date": "2024-03-23T16:21:49.697Z"
}
}
可以看到 EntityChanges
欄位包含了這次事件中的實體操作,也就是對資料庫的操作,共有兩個,一個是 select 查詢,另一個是 update 修改資料庫。
AuditLog
中介軟體
最後說下這個 AuditLogMiddleware
程式碼很簡單,就是在每個請求進來的時候,在 HttpContext.Items
裡新增一個 AuditConstant.EventId
public class AuditLogMiddleware {
private readonly RequestDelegate _next;
public AuditLogMiddleware(RequestDelegate next) {
_next = next;
}
public async Task Invoke(HttpContext context) {
// 生成 EventId 並儲存到 HttpContext.Items 中
context.Items[AuditConstant.EventId] = Guid.NewGuid().ToString();
await _next(context);
}
}
雖然寫了這個中介軟體,不過後面並沒有用上這個 EventId
這個本來是用來把實體更新和 Filter 關係起來的,不過後面發現用不上。
先留著吧,萬一後面有用呢?