前言
最近一直在寫 Go 和 Python ,好久沒寫 C# ,重新回來寫 C# 程式碼時竟有一種親切感~
說回正題。
在當今這個數字化迅速發展的時代,每一個操作都可能對業務產生深遠的影響,無論是對資料的簡單查詢,還是對系統配置的修改。在這樣的背景下,審計日誌不僅僅是一種遵循最佳實踐的手段,更是確保資料安全、提高系統透明度、促進責任歸屬明晰的關鍵工具。透過詳細記錄誰在何時對系統進行了何種操作,審計日誌幫助組織追蹤使用者活動,分析系統問題,甚至在發生安全事件時,提供必要的線索進行調查。
實現審計日誌的方法多樣,但如何在不干擾主業務邏輯的同時,高效地整合這一功能,是開發者們面臨的一大挑戰。本文著重探討如何借鑑面向切面程式設計(Aspect-Oriented Programming, AOP)的設計思想,在ASP.NET Core應用中以最小化程式碼侵入性實現動態審計日誌功能。AOP允許我們透過預定義的模式,如日誌記錄、效能統計和安全控制,以宣告的方式增強程式碼功能,而無需修改實際的業務邏輯程式碼。
本文將指導讀者從概念的理解到具體的實施,再到最終的資料持久化處理,特別是如何利用MongoDB這一強大的NoSQL資料庫來持久化審計日誌資料。無論你是剛剛接觸ASP.NET Core的新手,還是尋求為現有專案增加審計功能的資深開發者,本文都將提供從理論到實踐的全面指導。透過本文,你將學習到如何設計和實現一個靈活、可擴充套件的審計日誌系統,同時保持對主業務邏輯的最小化干擾。
讓我們開始這一旅程,一步步探索如何在ASP.NET Core應用中整合高效、靈活的審計日誌機制,利用AOP設計思想實現高度解耦和動態增強的系統功能。
審計日誌基礎
定義和用途
審計日誌有助於追蹤使用者的操作行為、資料變更記錄以及系統的安全性分析等。
常用的審計日誌有這些型別。
- 操作審計:記錄使用者對系統的所有操作,例如登入、登出、資料增刪改查等。
- 資料審計:記錄資料的變更詳情,如記錄資料修改前後的值。
- 安全審計:記錄安全相關事件,如失敗的登入嘗試、許可權變更等。
- 效能審計:記錄關鍵操作的效能資料,幫助分析系統瓶頸。
本文的程式碼以實現操作審計為例。
模型定義&關鍵資訊
審計日誌是系統安全和管理的關鍵部分,它幫助我們理解系統內發生了什麼、何時發生、由誰觸發。為了實現這一目標,審計日誌記錄需要包含幾個關鍵的組成部分。
- EventId 是每條審計記錄的唯一識別符號。就像每個人都有一個獨一無二的身份證號一樣,每條審計日誌也有一個獨特的EventId。這使我們能夠輕鬆地找到和引用特定的審計事件。
- EventType 描述了發生的事件型別。這告訴我們這條記錄是關於什麼的——是使用者登入、資料修改,還是許可權更改等。透過檢視EventType,我們可以快速瞭解記錄的核心資訊,而無需深入研究細節。
- UserId 是觸發事件的使用者的標識。在審計日誌中記錄UserId非常重要,因為它幫助我們追蹤誰負責了什麼操作。如果發現了問題或者不當行為,我們可以透過UserId來確定責任人。
設計審計日誌模型
AuditLog 類
新建 AuditLog.cs
類,每個欄位都有註釋,我就不再贅述了。
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>
/// 被操作的實體名稱
/// </summary>
public string EntityName { get; set; }
/// <summary>
/// 被操作的實體標識
/// </summary>
public string EntityId { get; set; }
/// <summary>
/// 修改前的資料,可根據實際情況以JSON格式儲存
/// </summary>
public string? OriginalValues { get; set; }
/// <summary>
/// 修改後的資料,可根據實際情況以JSON格式儲存
/// </summary>
public string? CurrentValues { get; set; }
/// <summary>
/// 具體的更改內容,可根據實際情況以JSON格式儲存
/// </summary>
public string? Changes { get; set; }
/// <summary>
/// 事件描述
/// </summary>
public string? Description { get; set; }
}
捕獲審計日誌
IAuditLogService 介面
先寫一個介面,用來操作審計日誌。使用介面可以保持程式碼的整潔和重用,同時也便於將來對審計日誌記錄邏輯進行擴充套件或修改。
為了簡單起見,目前這裡我們只寫了一個記錄的方法。
public interface IAuditLogService {
Task LogAsync(AuditLog auditLog);
}
之後在依賴注入容器裡註冊(假設實現類的名稱為 AuditLogService
)
builder.Services.AddScope<IAuditLogService, AuditLogService>();
這個設計既保持了程式碼的清晰與簡潔,也為將來可能的需求變更(如改變審計日誌的儲存方式、增加審計欄位等)提供了足夠的靈活性。
具體實現會在後續的資料持久化部分介紹。
ActionFilter 方式
在ASP.NET Core中,Action過濾器提供了一種強大的機制,允許我們在控制器的動作執行前後插入自定義邏輯。
我們可以在不修改現有業務邏輯程式碼的情況下,自動地捕獲使用者的操作以及資料的更改。這種方式充分利用了AOP的思想,實現了程式碼的最小化侵入。
建立 AuditLogAttribute
類
直接上程式碼了,繼承自 ActionFilterAttribute
類,可以實現一個 Action 過濾器的特性,其中 EventType
和 EntityName
我設計成需要手動指定,其他的屬性可以透過各種方法來獲取。
public class AuditLogAttribute : ActionFilterAttribute {
public string EventType { get; set; }
public string EntityName { 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),
EntityName = this.EntityName,
EntityId = ctxItems["AuditLog_EntityId"]?.ToString() ?? "",
OriginalValues = ctxItems["AuditLog_OriginalValues"]?.ToString(),
CurrentValues = ctxItems["AuditLog_CurrentValues"]?.ToString(),
Changes = ctxItems["AuditLog_Changes"]?.ToString(),
Description = $"操作型別:{this.EventType},實體名稱:{this.EntityName}",
};
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.");
}
}
}
注意事項
- 異常處理:考慮到日誌記錄不應影響主要業務流程的執行,需要新增異常處理邏輯,確保即使日誌記錄過程中發生異常,也不會干擾到正常的業務邏輯。
- 效能問題:雖然已經在非同步方法中記錄審計日誌,但如果審計日誌的記錄過程很慢,可能會略微延遲響應時間。可以使用批處理、快取來非同步寫入資料庫,或者將記錄邏輯放到後臺任務、訊息佇列中。
獲取IP地址
透過HttpContext.Connection.RemoteIpAddress
屬性可以獲取 IP 地址,但如果應用部署在了代理伺服器後面(例如使用了負載均衡器),直接獲取的IP地址可能是代理伺服器的地址,而不是客戶端的真實IP地址。
所以這裡我封裝了 GetIpAddress
方法
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();
}
首先嚐試從X-Forwarded-For
請求頭中獲取IP地址,這是一個標準的HTTP頭,用於識別透過HTTP代理或負載均衡器傳送請求的客戶端的原始IP地址。如果請求沒有經過代理,或者想要獲取代理伺服器的地址,那麼它會回退到使用HttpContext.Connection.RemoteIpAddress
。
X-Forwarded-For
可能包含多個IP地址(如果請求透過多個代理傳遞),因此程式碼中使用了Split(',')
來處理這種情況,並且僅取第一個IP地址作為客戶端的真實IP地址。
使用方法
經過封裝後可以很方便的使用這個審計功能了,只需要在介面上新增一行程式碼就可以實現審計功能。
[AuditLog(EventType = nameof(SetSubTaskFeedback), EntityName = nameof(SubTask))]
[HttpPost("sub-tasks/{subId}/set-feedback")]
public async Task<ApiResponse> SetSubTaskFeedback(string subId, [FromBody] SubTaskFeedbackDto dto) {}
手動記錄方式
儘管使用Action過濾器是一種高效的自動化方式,但在某些情況下,需要更精細地控制審計日誌的記錄。這時候只能修改介面程式碼,在業務邏輯里加入審計日誌記錄。
這種方式雖然需要直接修改業務程式碼,但它提供了最大的靈活性和控制能力。
這個程式碼就沒什麼特別的了,直接在介面中呼叫 IAuditLogService
的 LogAsync
方法來記錄審計日誌即可。
透過 HttpContext 共享資料
有些引數是很難在 ActionFilter 裡自動獲取到的,這些往往跟業務邏輯是有關的,這時候 HttpContext 就成為了一個理想的橋樑。
我們可以將一些臨時資料,比如操作前的資料快照,儲存在 HttpContext.Items
中,然後在過濾器中訪問這些資料來完成審計日誌的記錄。這種方法不僅保持了程式碼的解耦,還允許我們靈活地在應用的不同部分共享資料。
HttpContext.Items
是一個鍵值對集合,可用於在一個請求的生命週期內共享資料。
這樣在介面中的程式碼就是
HttpContext.Items["AuditLog_OriginalValues"] = item.FeedbackId;
HttpContext.Items["AuditLog_CurrentValues"] = dto.FeedbackId;
HttpContext.Items["AuditLog_Changes"] = $"更新反饋結果 {item.FeedbackId} -> {dto.FeedbackId}";
注意事項
- 確保業務邏輯和
AuditLogAttribute
中使用的鍵(如AuditLog_OriginalValues
)唯一且一致,以避免潛在的衝突。這裡最好是自己封裝一個 class 來提供這些 const ; - 如果業務邏輯抽象到了 service 層,則需要注入
IHttpContextAccessor
才能訪問 HttpContext ,這個服務可以透過services.AddHttpContextAccessor()
來註冊;
日誌持久化
審計日誌的有效持久化是確保長期安全和合規性的關鍵。
選擇儲存方案
在選擇最合適的儲存方案時,需要考慮資料的重要性、查詢的頻率、成本以及維護的複雜性等多個因素。
關係型資料庫(RDS)
關係型資料庫,如MySQL、PostgreSQL等,以其穩定性和成熟性受到廣泛認可。它們提供了嚴格的資料完整性保障和複雜查詢的強大能力,適合需要執行復雜分析和報告的審計日誌。
- 優點:資料結構化、支援複雜查詢、成熟的管理工具。
- 缺點:相對較高的成本、可能需要複雜的架構來支援大規模資料。
NoSQL資料庫
NoSQL資料庫,如MongoDB、Cassandra等,提供了靈活的資料模型和良好的橫向擴充套件能力,適合於結構多變或資料量巨大的審計日誌。
- 優點:高可擴充套件性、靈活的資料模型、快速的寫入速度。
- 缺點:查詢功能相對有限、資料一致性模型較弱。
檔案系統
直接將審計日誌寫入檔案系統是最直接的儲存方式,適用於日誌量不是特別大或對查詢需求不高的場景。
- 優點:實現簡單、成本低廉、易於遷移;
- 缺點:查詢和分析不便、難以管理大量日誌檔案、擴充套件性有限。
每種儲存方案都有其適用場景,因此選擇哪一種方案應根據具體需求和資源情況綜合考慮。對於需要快速寫入和高度可擴充套件的審計日誌系統,NoSQL資料庫是一個不錯的選擇。
因此本文選擇了 MongoDB 來記錄日誌。
選擇MongoDB作為審計日誌的儲存方案,不僅因為它的高效能和可擴充套件性,還因為它支援靈活的文件資料模型,使得儲存非結構化或半結構化的審計資料變得簡單。
實現 AuditLogMongoService
在 C# 中使用 MongoDB 非常簡單。
需要先新增 MongoDB.Driver 的 nuget 包
dotnet add MongoDB.Driver
直接上程式碼吧,
public class AuditLogMongoService : IAuditLogService {
private readonly IMongoCollection<AuditLog> _auditLogs;
public AuditLogMongoService(string connectionString, string databaseName) {
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_auditLogs = database.GetCollection<AuditLog>("audit_logs");
}
public async Task LogAsync(AuditLog auditLog) {
await _auditLogs.InsertOneAsync(auditLog);
}
}
準備連線字串&註冊服務
為了避免硬編碼,將連線字串放在配置檔案(appsettings.json
)裡
"ConnectionStrings": {
"Redis": "redis:6379",
"MongoDB": "mongodb://username:password@path-to-mongo:27017"
}
註冊服務
builder.Services.AddSingleton<IAuditLogService>(sp => new AuditLogMongoService(builder.Configuration.GetConnectionString("MongoDB"), "db_name"));
搞定~
部署 MongoDB
附上 MongoDB 的部署方法吧,我這裡使用 docker ,很方便
version: '3.1'
services:
mongo:
image: mongo:4.4.6
restart: always
volumes:
- ./data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: username
MONGO_INITDB_ROOT_PASSWORD: password
ports:
- 27017:27017
mongo-express:
image: mongo-express
restart: always
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: username
ME_CONFIG_MONGODB_ADMINPASSWORD: password
ME_CONFIG_MONGODB_URL: mongodb://username:password@mongo:27017/
ports:
- 8081:8081
使用 docker-compose 來編排,對映了 27017 和 8081 埠
可以使用 8081 埠訪問 mongo-express 網頁服務
如何檢視日誌
- 使用 MongoDB Compass 這個軟體來檢視資料
- 使用 mongo-express 服務可以在網頁上檢視資料
小結
雖然是比較簡單的功能,不過使用 AOP 來實現用起來感覺還是蠻爽的,不得不說 AspNetCore 的功能確實豐富~