[Abp 源码分析]十五、自动审计记录


0.简介

Abp 框架为我们自带了审计日志功能,审计日志可以方便地查看每次请求接口所耗的时间,能够帮助我们快速定位到某些性能有问题的接口。除此之外,审计日志信息还包含有每次调用接口时客户端请求的参数信息,客户端的 IP 与客户端使用的浏览器。有了这些数据之后,我们就可以很方便地复现接口产生 BUG 时的一些环境信息。

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

PS:

如果使用了 Abp.Zero 模块则自带的审计记录实现是存储到数据库当中的,但是在使用 EF Core + MySQL(EF Provider 为 Pomelo.EntityFrameworkCore.MySql) 在高并发的情况下会有数据库连接超时的问题,这块推荐是重写实现,自己采用 Redis 或者其他存储方式。

如果需要禁用审计日志功能,则需要在任意模块的预加载方法(PreInitialize()) 当中增加如下代码关闭审计日志功能。

public class XXXStartupModule
{
    public override PreInitialize()
    {
        // 禁用审计日志
        Configuration.Auditing.IsEnabled = false;
    }
}

1.启动流程

审计组件与参数校验组件一样,都是通过 MVC 过滤器与 Castle 拦截器来实现记录的。也就是说,在每次调用接口/方法时都会进入 过滤器/拦截器 并将其写入到数据库表 AbpAuditLogs 当中。

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

2.1 过滤器注入

同上一篇文章所讲的一样,过滤器是在 AddAbp() 方法内部的 ConfigureAspNetCore() 方法注入的。

private static void ConfigureAspNetCore(IServiceCollection services, IIocResolver iocResolver)
{
    // ... 其他代码
    
    //Configure MVC
    services.Configure(mvcOptions =>
    {
        mvcOptions.AddAbp(services);
    });
    
    // ... 其他代码
}

而下面就是过滤器的注入方法:

internal static class AbpMvcOptionsExtensions
{
    public static void AddAbp(this MvcOptions options, IServiceCollection services)
    {
        // ... 其他代码
        AddFilters(options);
        // ... 其他代码
    }
    
    // ... 其他代码

    private static void AddFilters(MvcOptions options)
    {
        // ... 其他过滤器注入
        
        // 注入审计日志过滤器
        options.Filters.AddService(typeof(AbpAuditActionFilter));
        
        // ... 其他过滤器注入
    }
    
    // ... 其他代码
}

2.2 拦截器注入

注入拦截器的地方与 DTO 自动验证的拦截器的位置一样,都是在 AbpBootstrapper 对象被构造的时候进行注册。

public class AbpBootstrapper : IDisposable
{
    private AbpBootstrapper([NotNull] Type startupModule, [CanBeNull] Action optionsAction = null)
    {
        // ... 其他代码

        if (!options.DisableAllInterceptors)
        {
            AddInterceptorRegistrars();
        }
    }

    // ... 其他代码

    // 添加各种拦截器
    private void AddInterceptorRegistrars()
    {
        ValidationInterceptorRegistrar.Initialize(IocManager);
        AuditingInterceptorRegistrar.Initialize(IocManager);
        EntityHistoryInterceptorRegistrar.Initialize(IocManager);
        UnitOfWorkRegistrar.Initialize(IocManager);
        AuthorizationInterceptorRegistrar.Initialize(IocManager);
    }

    // ... 其他代码
}

转到 AuditingInterceptorRegistrar 的具体实现可以发现,他在内部针对于审计日志拦截器的注入是区分了类型的。

internal static class AuditingInterceptorRegistrar
{
    public static void Initialize(IIocManager iocManager)
    {
        iocManager.IocContainer.Kernel.ComponentRegistered += (key, handler) =>
        {
            // 如果审计日志配置类没有被注入,则直接跳过
            if (!iocManager.IsRegistered())
            {
                return;
            }

            var auditingConfiguration = iocManager.Resolve();

            // 判断当前 DI 所注入的类型是否应该为其绑定审计日志拦截器
            if (ShouldIntercept(auditingConfiguration, handler.ComponentModel.Implementation))
            {
                handler.ComponentModel.Interceptors.Add(new InterceptorReference(typeof(AuditingInterceptor)));
            }
        };
    }
    
    // 本方法主要用于判断当前类型是否符合绑定拦截器的条件
    private static bool ShouldIntercept(IAuditingConfiguration auditingConfiguration, Type type)
    {
        // 首先判断当前类型是否在配置类的注册类型之中,如果是,则进行拦截器绑定
        if (auditingConfiguration.Selectors.Any(selector => selector.Predicate(type)))
        {
            return true;
        }

        // 当前类型如果拥有 Audited 特性,则进行拦截器绑定
        if (type.GetTypeInfo().IsDefined(typeof(AuditedAttribute), true))
        {
            return true;
        }

        // 如果当前类型内部的所有方法当中有一个方法拥有 Audited 特性,则进行拦截器绑定
        if (type.GetMethods().Any(m => m.IsDefined(typeof(AuditedAttribute), true)))
        {
            return true;
        }

        // 都不满足则返回 false,不对当前类型进行绑定
        return false;
    }
}

可以看到在判断是否绑定拦截器的时候,Abp 使用了 auditingConfiguration.Selectors 的属性来进行判断,那么默认 Abp 为我们添加了哪些类型是必定有审计日志的呢?

通过代码追踪,我们来到了 AbpKernalModule 类的内部,在其预加载方法里面有一个 AddAuditingSelectors() 的方法,该方法的作用就是添加了一个针对于应用服务类型的一个选择器对象。

public sealed class AbpKernelModule : AbpModule
{
    public override void PreInitialize()
    {
        // ... 其他代码

        AddAuditingSelectors();

        // ... 其他代码
    }

    // ... 其他代码

    private void AddAuditingSelectors()
    {
        Configuration.Auditing.Selectors.Add(
            new NamedTypeSelector(
                "Abp.ApplicationServices",
                type => typeof(IApplicationService).IsAssignableFrom(type)
            )
        );
    }

    // ... 其他代码
}

我们先看一下 NamedTypeSelector 的一个作用是什么,其基本类型定义由一个 stringFunc 组成,十分简单,重点就出在这个断言委托上面。

public class NamedTypeSelector
{
    // 选择器名称
    public string Name { get; set; }
    
    // 断言委托
    public Func Predicate { get; set; }

    public NamedTypeSelector(string name, Func predicate)
    {
        Name = name;
        Predicate = predicate;
    }
}

回到最开始的地方,当 Abp 为 Selectors 添加了一个名字为 "Abp.ApplicationServices" 的类型选择器。其断言委托的大体意思就是传入的 **type ** 参数是继承自 IApplicationService 接口的话,则返回 true,否则返回 false

这样在程序启动的时候,首先注入类型的时候,会首先进入上文所述的拦截器绑定类当中,这个时候会使用 Selectors 内部的类型选择器来调用这个集合内部的断言委托,只要这些选择器对象有一个返回 true,那么就直接与当前注入的 type 绑定拦截器。

2.代码分析

2.1 过滤器代码分析

首先查看这个过滤器的整体类型结构,一个标准的过滤器,肯定要实现 IAsyncActionFilter 接口。从下面的代码我们可以看到其注入了 IAbpAspNetCoreConfiguration 和一个 IAuditingHelper 对象。这两个对象的作用分别是判断是否记录日志,另一个则是用来真正写入日志所使用的。

public class AbpAuditActionFilter : IAsyncActionFilter, ITransientDependency
{
	// 审计日志组件配置对象
    private readonly IAbpAspNetCoreConfiguration _configuration;
    // 真正用来写入审计日志的工具类
    private readonly IAuditingHelper _auditingHelper;

    public AbpAuditActionFilter(IAbpAspNetCoreConfiguration configuration, IAuditingHelper auditingHelper)
    {
        _configuration = configuration;
        _auditingHelper = auditingHelper;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // ... 代码实现
    }
    
    // ... 其他代码
}

接着看 AbpAuditActionFilter() 方法内部的实现,进入这个过滤器的时候,通过 ShouldSaveAudit() 方法来判断是否要写审计日志。

之后呢与 DTO 自动验证的过滤器一样,通过 AbpCrossCuttingConcerns.Applying() 方法为当前的对象增加了一个标识,用来告诉拦截器说我已经处理过了,你就不要再重复处理了。

再往下就是创建审计信息,执行具体接口方法,并且如果产生了异常的话,也会存放到审计信息当中。

最后接口无论是否执行成功,还是说出现了异常信息,都会将其性能计数信息同审计信息一起,通过 IAuditingHelper 存储起来。

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
    // 判断是否写日志
    if (!ShouldSaveAudit(context))
    {
        await next();
        return;
    }

    // 为当前类型打上标识
    using (AbpCrossCuttingConcerns.Applying(context.Controller, AbpCrossCuttingConcerns.Auditing))
    {
        // 构造审计信息(AuditInfo)
        var auditInfo = _auditingHelper.CreateAuditInfo(
            context.ActionDescriptor.AsControllerActionDescriptor().ControllerTypeInfo.AsType(),
            context.ActionDescriptor.AsControllerActionDescriptor().MethodInfo,
            context.ActionArguments
        );

        // 开始性能计数
        var stopwatch = Stopwatch.StartNew();

        try
        {
            // 尝试调用接口方法
            var 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);
            await _auditingHelper.SaveAsync(auditInfo);
        }
    }
}

2.2 拦截器代码分析

拦截器处理时的总体思路与过滤器类似,其核心都是通过 IAuditingHelper 来创建审计信息和持久化审计信息的。只不过呢由于拦截器不仅仅是处理 MVC 接口,也会处理内部的一些类型的方法,所以针对同步方法与异步方法的处理肯定会复杂一点。

拦截器呢,我们关心一下他的核心方法 Intercept() 就行了。

public void Intercept(IInvocation invocation)
{
    // 判断过滤器是否已经处理了过了
    if (AbpCrossCuttingConcerns.IsApplied(invocation.InvocationTarget, AbpCrossCuttingConcerns.Auditing))
    {
        invocation.Proceed();
        return;
    }

    // 通过 IAuditingHelper 来判断当前方法是否需要记录审计日志信息
    if (!_auditingHelper.ShouldSaveAudit(invocation.MethodInvocationTarget))
    {
        invocation.Proceed();
        return;
    }

    // 构造审计信息
    var auditInfo = _auditingHelper.CreateAuditInfo(invocation.TargetType, invocation.MethodInvocationTarget, invocation.Arguments);

    // 判断方法的类型,同步方法与异步方法的处理逻辑不一样
    if (invocation.Method.IsAsync())
    {
        PerformAsyncAuditing(invocation, auditInfo);
    }
    else
    {
        PerformSyncAuditing(invocation, auditInfo);
    }
}

// 同步方法的处理逻辑与 MVC 过滤器逻辑相似
private void PerformSyncAuditing(IInvocation invocation, AuditInfo auditInfo)
{
    var stopwatch = Stopwatch.StartNew();

    try
    {
        invocation.Proceed();
    }
    catch (Exception ex)
    {
        auditInfo.Exception = ex;
        throw;
    }
    finally
    {
        stopwatch.Stop();
        auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);
        _auditingHelper.Save(auditInfo);
    }
}

// 异步方法处理
private void PerformAsyncAuditing(IInvocation invocation, AuditInfo auditInfo)
{
    var stopwatch = Stopwatch.StartNew();

    invocation.Proceed();

    if (invocation.Method.ReturnType == typeof(Task))
    {
        invocation.ReturnValue = InternalAsyncHelper.AwaitTaskWithFinally(
            (Task) invocation.ReturnValue,
            exception => SaveAuditInfo(auditInfo, stopwatch, exception)
        );
    }
    else //Task
    {
        invocation.ReturnValue = InternalAsyncHelper.CallAwaitTaskWithFinallyAndGetResult(
            invocation.Method.ReturnType.GenericTypeArguments[0],
            invocation.ReturnValue,
            exception => SaveAuditInfo(auditInfo, stopwatch, exception)
        );
    }
}

private void SaveAuditInfo(AuditInfo auditInfo, Stopwatch stopwatch, Exception exception)
{
    stopwatch.Stop();
    auditInfo.Exception = exception;
    auditInfo.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);

    _auditingHelper.Save(auditInfo);
}

这里异步方法的处理在很早之前的工作单元拦截器就有过讲述,这里就不再重复说明了。

2.3 核心的 IAuditingHelper

从代码上我们就可以看到,不论是拦截器还是过滤器都是最终都是通过 IAuditingHelper 对象来储存审计日志的。Abp 依旧为我们实现了一个默认的 AuditingHelper ,实现了其接口的所有方法。我们先查看一下这个接口的定义:

public interface IAuditingHelper
{
    // 判断当前方法是否需要存储审计日志信息
    bool ShouldSaveAudit(MethodInfo methodInfo, bool defaultValue = false);

    // 根据参数集合创建一个审计信息,一般用于拦截器
    AuditInfo CreateAuditInfo(Type type, MethodInfo method, object[] arguments);

    // 根据一个参数字典类来创建一个审计信息,一般用于 MVC 过滤器
    AuditInfo CreateAuditInfo(Type type, MethodInfo method, IDictionary arguments);

    // 同步保存审计信息
    void Save(AuditInfo auditInfo);

    // 异步保存审计信息
    Task SaveAsync(AuditInfo auditInfo);
}

我们来到其默认实现 AuditingHelper 类型,先看一下其内部注入了哪些接口。

public class AuditingHelper : IAuditingHelper, ITransientDependency
{
    // 日志记录器,用于记录日志
    public ILogger Logger { get; set; }
    // 用于获取当前登录用户的信息
    public IAbpSession AbpSession { get; set; }
    // 用于持久话审计日志信息
    public IAuditingStore AuditingStore { get; set; }

    // 主要作用是填充审计信息的客户端调用信息
    private readonly IAuditInfoProvider _auditInfoProvider;
    // 审计日志组件的配置相关
    private readonly IAuditingConfiguration _configuration;
    // 在调用 AuditingStore 进行持久化的时候使用,创建一个工作单元
    private readonly IUnitOfWorkManager _unitOfWorkManager;
    // 用于序列化参数信息为 JSON 字符串
    private readonly IAuditSerializer _auditSerializer;

    public AuditingHelper(
        IAuditInfoProvider auditInfoProvider,
        IAuditingConfiguration configuration,
        IUnitOfWorkManager unitOfWorkManager,
        IAuditSerializer auditSerializer)
    {
        _auditInfoProvider = auditInfoProvider;
        _configuration = configuration;
        _unitOfWorkManager = unitOfWorkManager;
        _auditSerializer = auditSerializer;

        AbpSession = NullAbpSession.Instance;
        Logger = NullLogger.Instance;
        AuditingStore = SimpleLogAuditingStore.Instance;
    }

    // ... 其他实现的接口
}

2.3.1 判断是否创建审计信息

首先分析一下其内部的 ShouldSaveAudit() 方法,整个方法的核心作用就是根据传入的方法类型来判定是否为其创建审计信息。

其实在这一串 if 当中,你可以发现有一句代码对方法是否标注了 DisableAuditingAttribute 特性进行了判断,如果标注了该特性,则不为该方法创建审计信息。所以我们就可以通过该特性来控制自己应用服务类,控制里面的的接口是否要创建审计信息。同理,我们也可以通过显式标注 AuditedAttribute 特性来让拦截器为这个方法创建审计信息。

public bool ShouldSaveAudit(MethodInfo methodInfo, bool defaultValue = false)
{
    if (!_configuration.IsEnabled)
    {
        return false;
    }

    if (!_configuration.IsEnabledForAnonymousUsers && (AbpSession?.UserId == null))
    {
        return false;
    }

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

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

    if (methodInfo.IsDefined(typeof(AuditedAttribute), true))
    {
        return true;
    }

    if (methodInfo.IsDefined(typeof(DisableAuditingAttribute), true))
    {
        return false;
    }

    var classType = methodInfo.DeclaringType;
    if (classType != null)
    {
        if (classType.GetTypeInfo().IsDefined(typeof(AuditedAttribute), true))
        {
            return true;
        }

        if (classType.GetTypeInfo().IsDefined(typeof(DisableAuditingAttribute), true))
        {
            return false;
        }

        if (_configuration.Selectors.Any(selector => selector.Predicate(classType)))
        {
            return true;
        }
    }

    return defaultValue;
}

2.3.2 创建审计信息

审计信息在创建的时候,就为我们将当前调用接口时的用户信息存放在了审计信息当中,之后通过 IAuditInfoProviderFill() 方法填充了客户端 IP 与浏览器信息。

public AuditInfo CreateAuditInfo(Type type, MethodInfo method, IDictionary arguments)
{
    // 构建一个审计信息对象
    var auditInfo = new AuditInfo
    {
        TenantId = AbpSession.TenantId,
        UserId = AbpSession.UserId,
        ImpersonatorUserId = AbpSession.ImpersonatorUserId,
        ImpersonatorTenantId = AbpSession.ImpersonatorTenantId,
        ServiceName = type != null
            ? type.FullName
            : "",
        MethodName = method.Name,
        // 将参数转换为 JSON 字符串
        Parameters = ConvertArgumentsToJson(arguments),
        ExecutionTime = Clock.Now
    };

    try
    {
        // 填充客户 IP 与浏览器信息等
        _auditInfoProvider.Fill(auditInfo);
    }
    catch (Exception ex)
    {
        Logger.Warn(ex.ToString(), ex);
    }

    return auditInfo;
}

2.4 审计信息持久化

通过上一小节我们知道了在调用审计信息保存接口的时候,实际上是调用的 IAuditingStore 所提供的 SaveAsync(AuditInfo auditInfo) 方法来持久化这些审计日志信息的。

如果你没有集成 Abp.Zero 项目的话,则使用的是默认的实现,就是简单通过 ILogger 输出审计信息到日志当中。

默认有这两种实现,至于第一种是 Abp 的单元测试项目所使用的。

这里我们就简单将一下 AuditingStore 这个实现吧,其实很简单的,就是注入了一个仓储,在保存的时候往审计日志表插入一条数据即可。

这里使用了 AuditLog.CreateFromAuditInfo() 方法将 AuditInfo 类型的审计信息转换为数据库实体,用于仓储进行插入操作。

public class AuditingStore : IAuditingStore, ITransientDependency
{
    private readonly IRepository _auditLogRepository;

    public AuditingStore(IRepository auditLogRepository)
    {
        _auditLogRepository = auditLogRepository;
    }

    public virtual Task SaveAsync(AuditInfo auditInfo)
    {
    	// 向表中插入数据
        return _auditLogRepository.InsertAsync(AuditLog.CreateFromAuditInfo(auditInfo));
    }
}

同样,这里建议重新实现一个 AuditingStore,存储在 Redis 或者其他地方。

3. 后记

前几天发现 Abp 的团队有开了一个新坑,叫做 Abp vNext 框架,该框架全部基于 .NET Core 进行开发,而且会针对微服务项目进行专门的设计,有兴趣的朋友可以持续关注。

其 GitHub 地址为:https://github.com/abpframework/abp/

官方地址为:https://abp.io/

4.