大可 • Duke

专注,谦逊,分享

.NetCoe实现审计日志

概念

维基百科:审计跟踪(也称为审核日志)是一个安全相关的时间顺序记录,记录这些记录的目的是为已经影响在任何时候的详细操作,提供程序运行的证明文件记录、源或事件

今天我们参照ABP的审计功能来为AdmBoots实现一个审计日志,它能自动记录所有与应用的交互和有意的方法调用和调用者信息与参数,包括:

  • 调用者id,
  • 调用者姓名
  • 被调用的服务名
  • 被调用的方法名
  • 执行参数
  • 执行时间
  • 执行时长
  • 客户端IP
  • 客户端电脑名
  • 调用异常

有了这些信息,可以方便地查看每次请求接口所耗的时间,能够帮助我们快速定位到某些性能有问题的接口。有了这些数据之后,我们就可以很方便地复现接口产生 BUG 时的一些环境信息。

当然如果你还可以根据这些数据来开发一个可视化的图形界面,方便开发与测试人员来快速定位问题。

系列教程

01.NetCore(.Net5)快速开发框架一:前言
02.NetCore(.Net5)快速开发框架二:快速开发
03.NetCore(.Net5)快速开发框架三:WebAPI性能监控-MiniProfiler与Swagger集成
04.NetCore(.Net5)快速开发框架四:实现审计日志
...

启动流程

审计日志的实现,就是在每次调用接口/方法时将相关信息记录下来并将其写入到数据库表 AuditLog 当中。

那么,如何获取每次调用接口/方法的相关信息呢?可以通过以下三种方式来解决:

其核心思想十分简单,就是在执行具体接口方法的时候,先使用 StopWatch 对象来记录执行完一个方法所需要的时间,并且还能够通过 HttpContext 来获取到一些客户端的关键信息。

本篇主要讲解使用过滤器实现审计日志,拦截器处理时的总体思路与过滤器类似,只不过由于拦截器不仅仅是处理 接口,也会处理内部的一些类型的方法,所以针对同步方法与异步方法的处理肯定会复杂一点。如何使用拦截器实现该功能可以参考AdmBoots的工作单元实现,基本思路都一样。

实现思路:

  1. 定义AuditedAttribute(开启审计),DisableAuditingAttribute(忽略审计)
  2. 在过滤器中,对请求接口/方法进行拦截,判断当前接口/方法是否有上面两个特性,根据特性定义开启或忽略当前接口/方法相审计信息
  3. 将审计信息持久化

定义特性

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
    public class AuditedAttribute : Attribute {

    }

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property)]
    public class DisableAuditingAttribute : Attribute {

    }

定义模型

 [Table("AuditLog")]
    public class AuditLog : Entity {

        /// <summary>
        /// 用户ID
        /// </summary>
        public int? UserId { get; set; }

        /// <summary>
        /// 用户姓名
        /// </summary>
        public string UserName { get; set; }

        /// <summary>
        /// 服务 (类/接口) 名
        /// </summary>
        public string ServiceName { get; set; }

        /// <summary>
        /// 执行方法名称
        /// </summary>
        public string MethodName { get; set; }

        /// <summary>
        /// 调用参数
        /// </summary>
        public string Parameters { get; set; }

        /// <summary>
        /// 返回值
        /// </summary>
        public string ReturnValue { get; set; }

        /// <summary>
        /// 方法执行的开始时间
        /// </summary>
        public DateTime ExecutionTime { get; set; }

        /// <summary>
        /// 方法调用的总持续时间(毫秒)
        /// </summary>
        public int ExecutionDuration { get; set; }

        /// <summary>
        /// 客户端的IP地址
        /// </summary>
        public string ClientIpAddress { get; set; }

        /// <summary>
        /// 客户端的名称(通常是计算机名)
        /// </summary>
        public string ClientName { get; set; }

        /// <summary>
        /// 浏览器信息
        /// </summary>
        public string BrowserInfo { get; set; }

        /// <summary>
        /// 方法执行期间发生异常
        /// </summary>
        public string Exception { get; set; }

        /// <summary>
        /// 自定义数据
        /// </summary>
        public string CustomData { get; set; }
    }

这个模型的属性就是数据库字段了,无论时CodeFirst还是自行创建表结构,要注意字段长度,不要太短,避免因审计日志出错而影响应用程序。

定义过滤器

public class AuditActionFilter : IAsyncActionFilter {
    //AuditLog服务对象,用于保存/查询等操作
        private readonly IAuditLogService _auditLogService;
    //当前登录用户对象,获取当前用户信息
        private readonly IAdmSession _admSession;
    //系统日志接口,用于记录一些系统异常信息
        private readonly ILogger<AuditActionFilter> _logger;
    //客户端信息接口,获取浏览器,IP等信息
        private readonly IClientInfoProvider _clientInfoProvider;

        public AuditActionFilter(IAuditLogService auditLogService,
            IAdmSession admSession,
            ILogger<AuditActionFilter> logger,
            IClientInfoProvider clientInfoProvider) {
            _auditLogService = auditLogService;
            _admSession = admSession;
            _logger = logger;
            _clientInfoProvider = clientInfoProvider;
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) {
            // 判断是否写日志
            if (!ShouldSaveAudit(context)) {
                await next();
                return;
            }
			//接口Type
            var type = (context.ActionDescriptor as ControllerActionDescriptor).ControllerTypeInfo.AsType();
            //方法信息
            var method = (context.ActionDescriptor as ControllerActionDescriptor).MethodInfo;
            //方法参数
            var arguments = context.ActionArguments;
            //开始计时
            var stopwatch = Stopwatch.StartNew();

            var auditInfo = new AuditInfo {
                UserId = _admSession?.UserId,
                UserName = _admSession?.Name,
                //TruncateWithPostfix方法作用是截取字符串长度
                ServiceName = type != null
                    ? type.FullName.TruncateWithPostfix(EntityDefault.FieldsLength250)
                    : "",
                //5.0版本以上,varchar(50),指的是50字符,无论存放的是数字、字母还是UTF8汉字(每个汉字3字节),都可以存放50个。其他数据库要注意下这里
                MethodName = method.Name.TruncateWithPostfix(EntityDefault.FieldsLength250),
                //请求参数转Json
                Parameters = ConvertArgumentsToJson(arguments).TruncateWithPostfix(EntityDefault.FieldsLength2000),
                ExecutionTime = DateTime.Now,
                BrowserInfo = _clientInfoProvider.BrowserInfo.TruncateWithPostfix(EntityDefault.FieldsLength250),
                ClientIpAddress = _clientInfoProvider.ClientIpAddress.TruncateWithPostfix(EntityDefault.FieldsLength50),
                ClientName = _clientInfoProvider.ComputerName.TruncateWithPostfix(EntityDefault.FieldsLength100),
            };

            ActionExecutedContext result = null;
            try {
                result = await next();
                if (result.Exception != null && !result.ExceptionHandled) {
                    auditInfo.Exception = result.Exception;
                }
            } catch (Exception ex) {
                auditInfo.Exception = ex;
                throw;
            } finally {
                stopwatch.Stop();
                auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);

                if (result != null) {
                    switch (result.Result) {
                        case ObjectResult objectResult:
                            auditInfo.ReturnValue = JsonConvert.SerializeObject(objectResult.Value);
                            break;

                        case JsonResult jsonResult:
                            auditInfo.ReturnValue = JsonConvert.SerializeObject(jsonResult.Value);
                            break;

                        case ContentResult contentResult:
                            auditInfo.ReturnValue = contentResult.Content;
                            break;
                    }
                }
                Console.WriteLine(auditInfo.ToString());
                auditInfo.ReturnValue = auditInfo.ReturnValue.TruncateWithPostfix(EntityDefault.FieldsLength20);
                //保存审计日志
                await _auditLogService.SaveAsync(auditInfo);
            }
        }   
    }

这是一个实现IAsyncActionFilter 接口的标准过滤器,进入这个过滤器的时候,通过 ShouldSaveAudit() 方法来判断是否要写审计日志,

下面是创建审计信息,执行具体接口方法,并且如果产生了异常的话,也会存放到审计信息当中。最后接口无论是否执行成功,还是说出现了异常信息,都会将其性能计数信息同审计信息一起,通过AuditLogService存储起来。

TruncateWithPostfix是字符串的扩展,用于截取字符串长度。避免因保存审计日志时字符数大于数据库定义长度而导致应用程序崩溃。

判断是否创建审计信息

整个方法的核心作用就是根据传入的方法类型来判定是否为其创建审计信息。

可以看到,如果标注了 DisableAuditingAttribute特性,则不为该方法创建审计信息。所以我们就可以通过该特性来控制自己应用服务类,控制里面的的接口是否要创建审计信息。同理,我们也可以通过显式标注 AuditedAttribute 特性来让拦截器为这个方法创建审计信息。

    private static bool ShouldSaveAudit(ActionExecutingContext context, bool defaultValue = false) {
        if (!(context.ActionDescriptor is ControllerActionDescriptor))
            return false;
        var methodInfo = (context.ActionDescriptor as ControllerActionDescriptor).MethodInfo;

        if (methodInfo == null) {
            return false;
        }

        if (!methodInfo.IsPublic) {
            return false;
        }

        if (methodInfo.HasAttribute<AuditedAttribute>()) {
            return true;
        }

        if (methodInfo.HasAttribute<DisableAuditingAttribute>()) {
            return false;
        }

        var classType = methodInfo.DeclaringType;
        if (classType != null) {
            if (classType.GetTypeInfo().HasAttribute<AuditedAttribute>()) {
                return true;
            }
            
            if (classType.GetTypeInfo().HasAttribute<DisableAuditingAttribute>()) {
                return false;
            }
        }
        return defaultValue;
    }
  • memberInfo.HasAttribute() 是我封装的方法,可用 memberInfo.IsDefined(typeof(T), true)代替

核心的IAuditLogService

  public interface IAuditLogService : ITransientDependency {

        Task SaveAsync(AuditInfo auditInfo);
    }

 public class AuditiLogService : AppServiceBase, IAuditLogService {
        private readonly IRepository<AuditLog, int> _auditLogRepository;

        public AuditiLogService(IRepository<AuditLog, int> auditLogRepository) {
            _auditLogRepository = auditLogRepository;
        }
        public Task SaveAsync(AuditInfo auditInfo) {
            var auditLog = ObjectMapper.Map<AuditLog>(auditInfo);
            return _auditLogRepository.InsertAsync(auditLog);
        }
    }

DTO-AuditInfo

public class AuditInfo {

        /// <summary>
        /// 用户ID
        /// </summary>
        public int? UserId { get; set; }

        public string UserName { get; set; }

        /// <summary>
        /// 服务 (类/接口) 名
        /// </summary>
        public string ServiceName { get; set; }

        /// <summary>
        /// 执行方法名称
        /// </summary>
        public string MethodName { get; set; }

        /// <summary>
        /// 调用参数
        /// </summary>
        public string Parameters { get; set; }

        /// <summary>
        /// 返回值
        /// </summary>
        public string ReturnValue { get; set; }

        /// <summary>
        /// 方法执行的开始时间
        /// </summary>
        public DateTime ExecutionTime { get; set; }

        /// <summary>
        /// 方法调用的总持续时间(毫秒)
        /// </summary>
        public int ExecutionDuration { get; set; }

        /// <summary>
        /// 客户端的IP地址
        /// </summary>
        public string ClientIpAddress { get; set; }

        /// <summary>
        /// 客户端的名称(通常是计算机名)
        /// </summary>
        public string ClientName { get; set; }

        /// <summary>
        /// 浏览器信息
        /// </summary>
        public string BrowserInfo { get; set; }

        /// <summary>
        /// 方法执行期间发生异常
        /// </summary>
        public Exception Exception { get; set; }

        /// <summary>
        /// 自定义数据
        /// </summary>
        public string CustomData { get; set; }

        public override string ToString() {
            var loggedUserId = UserId.HasValue
                                   ? "user " + UserName
                                   : "an anonymous user";

            var exceptionOrSuccessMessage = Exception != null
                ? "exception: " + Exception.Message
                : "succeed";

            return $"AUDIT LOG: {ServiceName}.{MethodName} is executed by {loggedUserId} in {ExecutionDuration} ms from {ClientIpAddress} IP address with {exceptionOrSuccessMessage}.";
        }
    }

AuditInfo虽然与AuditLog长得很像,但是他们最好不要互相代替,因为不符合DDD思想。(为什么扯到DDD,因为本篇代码是在我的开源框架AdmBoots上开发的)。

注入过滤器

写了上面一堆的代码,感觉万事俱备,只欠F5了。NO!最关键的一步我们还没有做:注入过滤器

Startup.csConfigureServices

public void ConfigureServices(IServiceCollection services) {
            ...
            services.AddControllers(option => {
                ...
                option.Filters.Add(typeof(AuditActionFilter));
            }).AddNewtonsoftJson();
            ...
        }

这时候我们的工作就全部完成了,找个接口(Controller)写上[Audited],然后执行下该接口,在数据库中我们就可以看到生成的审计信息了。

最后

本篇还有部分不重要的代码没有贴出来,具体可以查看源代码 源码

如果有疑问,请留言或进群。

posted @ 2020-12-01 21:49  大可·Duke  阅读(2870)  评论(2编辑  收藏  举报