前言
上次说了利用 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 关系起来的,不过后面发现用不上。
先留着吧,万一后面有用呢?
            程序设计实验室
            微信公众号