前言#
上次说了利用 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 关系起来的,不过后面发现用不上。
先留着吧,万一后面有用呢?