Asp-Net-Core開發筆記:進一步實現非侵入性審計日誌功能

程序设计实验室發表於2024-04-29

前言

上次說了利用 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 關係起來的,不過後面發現用不上。

先留著吧,萬一後面有用呢?

相關文章