Abp vNext 源码分析 - 8. 审计日志

一、简要说明

ABP vNext 当中的审计模块早在 **依赖注入与拦截器**一文中有所提及,但没有详细的对其进行分析。

审计模块是 ABP vNext 框架的一个基本组件,它能够提供一些实用日志记录。不过这里的日志不是说系统日志,而是说接口每次调用之后的执行情况(执行时间、传入参数、异常信息、请求 IP)。

除了常规的日志功能以外,关于 实体聚合 的审计字段接口也是存放在审计模块当中的。(创建人创建时间修改人修改时间删除人删除时间

二、源码分析

2.1. 审计日志拦截器

2.1.1 审计日志拦截器的注册

Volo.Abp.Auditing 的模块定义十分简单,主要是提供了 审计日志拦截器 的注册功能。下面代码即在组件注册的时候,会调用 AuditingInterceptorRegistrar.RegisterIfNeeded 方法来判定是否为实现类型(ImplementationType) 注入审计日志拦截器。

public class AbpAuditingModule : AbpModule
{
    public override void PreConfigureServices(ServiceConfigurationContext context)
    {
        context.Services.OnRegistred(AuditingInterceptorRegistrar.RegisterIfNeeded);
    }
}

跳转到具体的实现,可以看到内部会结合三种类型进行判断。分别是 AuditedAttributeIAuditingEnabledDisableAuditingAttribute

前两个作用是,只要类型标注了 AuditedAttribute 特性,或者是实现了 IAuditingEnable 接口,都会为该类型注入审计日志拦截器。

DisableAuditingAttribute 类型则相反,只要类型上标注了该特性,就不会启用审计日志拦截器。某些接口需要 提升性能 的话,可以尝试使用该特性禁用掉审计日志功能。

public static class AuditingInterceptorRegistrar
{
    public static void RegisterIfNeeded(IOnServiceRegistredContext context)
    {
        // 满足条件时,将会为该类型注入审计日志拦截器。
        if (ShouldIntercept(context.ImplementationType))
        {
            context.Interceptors.TryAdd<AuditingInterceptor>();
        }
    }

    private static bool ShouldIntercept(Type type)
    {
        // 首先判断类型上面是否使用了辅助类型。
        if (ShouldAuditTypeByDefault(type))
        {
            return true;
        }

        // 如果任意方法上面标注了 AuditedAttribute 特性,则仍然为该类型注入拦截器。
        if (type.GetMethods().Any(m => m.IsDefined(typeof(AuditedAttribute), true)))
        {
            return true;
        }

        return false;
    }

    //TODO: Move to a better place
    public static bool ShouldAuditTypeByDefault(Type type)
    {
        // 下面就是根据三种辅助类型进行判断,是否为当前 type 注入审计日志拦截器。
        if (type.IsDefined(typeof(AuditedAttribute), true))
        {
            return true;
        }

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

        if (typeof(IAuditingEnabled).IsAssignableFrom(type))
        {
            return true;
        }

        return false;
    }
}

2.1.2 审计日志拦截器的实现

审计日志拦截器的内部实现,主要使用了三个类型进行协同工作。它们分别是负责管理审计日志信息的 IAuditingManager,负责创建审计日志信息的 IAuditingHelper,还有统计接口执行时常的 Stopwatch

整个审计日志拦截器的大体流程如下:

  1. 首先是判定 MVC 审计日志过滤器是否进行处理。
  2. 再次根据特性,和类型进行二次验证是否应该创建审计日志信息。
  3. 根据调用信息,创建 AuditLogInfoAuditLogActionInfo 审计日志信息。
  4. 调用 StopWatch 的计时方法,如果出现了异常则将异常信息添加到刚才构建的 AuditLogInfo 对象中。
  5. 无论是否出现异常,都会进入 finally 语句块,这个时候会调用 StopWatch 实例的停止方法,并统计完成执行时间。
public override async Task InterceptAsync(IAbpMethodInvocation invocation)
{
    if (!ShouldIntercept(invocation, out var auditLog, out var auditLogAction))
    {
        await invocation.ProceedAsync();
        return;
    }

    // 开始进行计时操作。
    var stopwatch = Stopwatch.StartNew();

    try
    {
        await invocation.ProceedAsync();
    }
    catch (Exception ex)
    {
        // 如果出现了异常,一样的将异常信息添加到审计日志结果中。
        auditLog.Exceptions.Add(ex);
        throw;
    }
    finally
    {
        // 统计完成,并将信息加入到审计日志结果中。
        stopwatch.Stop();
        auditLogAction.ExecutionDuration = Convert.ToInt32(stopwatch.Elapsed.TotalMilliseconds);
        auditLog.Actions.Add(auditLogAction);
    }
}

可以看到,只有当 ShouldIntercept() 方法返回 true 的时候,下面的统计等操作才会被执行。

protected virtual bool ShouldIntercept(
    IAbpMethodInvocation invocation, 
    out AuditLogInfo auditLog, 
    out AuditLogActionInfo auditLogAction)
{
    auditLog = null;
    auditLogAction = null;

    if (AbpCrossCuttingConcerns.IsApplied(invocation.TargetObject, AbpCrossCuttingConcerns.Auditing))
    {
        return false;
    }

    // 如果没有获取到 Scop,则返回 false。
    var auditLogScope = _auditingManager.Current;
    if (auditLogScope == null)
    {
        return false;
    }

    // 进行二次判断是否需要存储审计日志。
    if (!_auditingHelper.ShouldSaveAudit(invocation.Method))
    {
        return false;
    }

    // 构建审计日志信息。
    auditLog = auditLogScope.Log;
    auditLogAction = _auditingHelper.CreateAuditLogAction(
        auditLog,
        invocation.TargetObject.GetType(),
        invocation.Method, 
        invocation.Arguments
    );

    return true;
}

2.2 审计日志的持久化

大体流程和我们上面说的一样,不过好像缺少了重要的一步,那就是 持久化操作。你可以在 Volo.Abp.Auditing 模块发现有 IAuditingStore 接口的定义,但是它的 SaveAsync() 方法却没有在拦截器内部被调用。同样在 MVC 的审计日志过滤器实现,你也会发现没有调用持久化方法。

那么我们的审计日志是在什么时候被持久化的呢?找到 SaveAsync() 被调用的地方,发现 ABP vNext 实现了一个审计日志的 ASP.NET Core 中间件。

1570420399962

在这个中间件内部的实现比较简单,首先通过一个判定方法,决定是否为本次请求执行 IAuditingManager.BeginScope() 方法。如果判定通过,则执行,否则不仅行任何操作。

public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
    if (!ShouldWriteAuditLog(context))
    {
        await next(context);
        return;
    }

    using (var scope = _auditingManager.BeginScope())
    {
        try
        {
            await next(context);
        }
        finally
        {
            await scope.SaveAsync();
        }
    }
}

可以看到,在这里 ABP vNext 使用 IAuditingManager 构建,调用其 BeginScope() 构建了一个 IAuditLogSaveHandle 对象,并使用其提供的 SaveAsync() 方法进行持久化操作。

2.2.1 嵌套的持久化操作

在构造出来的 IAuditLogSaveHandle 对象里面,还是使用的 IAuditingManager 的默认实现 AuditingManager 所提供的 SaveAsync() 方法进行持久化

阅读源码之后,发现了下面两个问题:

  1. IAuditingManager 没有将持久化方法公开 出来,而是作为一个 protected 级别的方法。
  2. 为什么还要借助 IAuditLogSaveHandle 间接地调用 管理器的持久化方法。

这就要从中间件的代码说起了,可以看到它是构造出了一个可以被释放的 IAuditLogSaveHandle 对象。ABP vNext 这样做的目的,就是可以嵌套多个 Scope,即 只在某个范围内 才将审计日志记录下来。这种特性类似于 工作单元 的用法,其底层实现是 之前文章 讲过的 IAmbientScopeProvider 对象。

例如在某个应用服务内部,我可以这样写代码:

using (var scope = _auditingManager.BeginScope())
{
    await myAuditedObject1.DoItAsync(new InputObject { Value1 = "我是内部嵌套测试方法1。", Value2 = 5000 });
    using (var scope2 = _auditingManager.BeginScope())
    {
        await myAuditedObject1.DoItAsync(new InputObject {Value1 = "我是内部嵌套测试方法2。", Value2 = 10000});
        await scope2.SaveAsync();
    }
    await scope.SaveAsync();
}

想一下之前的代码,在拦截器内部,我们是通过 IAuditingManager.Current 拿到当前可用的 IAuditLogScope ,而这个 Scope 就是在调用 IAuditingManager.BeginScope() 之后生成的

2.2.3 最终的持久化代码

通过上述的流程,我们得知最后的审计日志信息会通过 IAuditingStore 进行持久化。ABP vNext 为我们提供了一个默认的 SimpleLogAuditingStore 实现,其内部就是调用 ILogger 将信息输出。如果需要将审计日志持久化到数据库,你可以实现 IAUditingStore 接口,覆盖原有实现 ,或者使用 ABP vNext 提供的 Volo.Abp.AuditLogging 模块。

2.3 审计日志的序列化

审计日志的序列化处理是在 IAuditingHelper 的默认实现内部被使用,可以看到构建审计日志的方法内部,通过自定义的序列化器来将 Action 的参数进行序列化处理,方便存储。

public virtual AuditLogActionInfo CreateAuditLogAction(
    AuditLogInfo auditLog,
    Type type, 
    MethodInfo method, 
    IDictionary<string, object> arguments)
{
    var actionInfo = new AuditLogActionInfo
    {
        ServiceName = type != null
            ? type.FullName
            : "",
        MethodName = method.Name,
        // 序列化参数信息。
        Parameters = SerializeConvertArguments(arguments),
        ExecutionTime = Clock.Now
    };

    //TODO Execute contributors

    return actionInfo;
}

protected virtual string SerializeConvertArguments(IDictionary<string, object> arguments)
{
    try
    {
        if (arguments.IsNullOrEmpty())
        {
            return "{}";
        }

        var dictionary = new Dictionary<string, object>();

        foreach (var argument in arguments)
        {
            // 忽略的代码,主要作用是构建参数字典。
        }

        // 调用序列化器,序列化 Action 的调用参数。
        return AuditSerializer.Serialize(dictionary);
    }
    catch (Exception ex)
    {
        Logger.LogException(ex, LogLevel.Warning);
        return "{}";
    }
}

下面就是具体序列化器的代码:

public class JsonNetAuditSerializer : IAuditSerializer, ITransientDependency
{
    protected AbpAuditingOptions Options;

    public JsonNetAuditSerializer(IOptions<AbpAuditingOptions> options)
    {
        Options = options.Value;
    }

    public string Serialize(object obj)
    {
        // 使用 JSON.NET 进行序列化操作。
        return JsonConvert.SerializeObject(obj, GetSharedJsonSerializerSettings());
    }

    // ... 省略的代码。
}

2.4 审计日志的配置参数

针对审计日志相关的配置参数的定义,都存放在 AbpAuditingOptions 当中。下面我会针对各个参数的用途,对其进行详细的说明。

public class AbpAuditingOptions
{
    //TODO: Consider to add an option to disable auditing for application service methods?

    // 该参数目前版本暂未使用,为保留参数。
    public bool HideErrors { get; set; }

    // 是否启用审计日志功能,默认值为 true。
    public bool IsEnabled { get; set; }

    // 审计日志的应用程序名称,默认值为 null,主要在构建 AuditingInfo 被使用。
    public string ApplicationName { get; set; }

    // 是否为匿名请求记录审计日志默认值 true。
    public bool IsEnabledForAnonymousUsers { get; set; }

    // 审计日志功能的协作者集合,默认添加了 AspNetCoreAuditLogContributor 实现。
    public List<AuditLogContributor> Contributors { get; }

    // 默认的忽略类型,主要在序列化时使用。
    public List<Type> IgnoredTypes { get; }

    // 实体类型选择器。
    public IEntityHistorySelectorList EntityHistorySelectors { get; }

    //TODO: Move this to asp.net core layer or convert it to a more dynamic strategy?
    // 是否为 Get 请求记录审计日志,默认值 false。
    public bool IsEnabledForGetRequests { get; set; }

    public AbpAuditingOptions()
    {
        IsEnabled = true;
        IsEnabledForAnonymousUsers = true;
        HideErrors = true;

        Contributors = new List<AuditLogContributor>();

        IgnoredTypes = new List<Type>
        {
            typeof(Stream),
            typeof(Expression)
        };

        EntityHistorySelectors = new EntityHistorySelectorList();
    }
}

2.4 实体相关的审计信息

在文章开始就谈到,除了对 HTTP 请求有审计日志记录以外,ABP vNext 还提供了实体审计信息的记录功能。所谓的实体的审计信息,指的就是实体继承了 ABP vNext 提供的接口之后,ABP vNext 会自动维护实现的接口字段,不需要开发人员自己再进行处理。

这些接口包括创建实体操作的相关信息 IHasCreationTimeIMayHaveCreatorICreationAuditedObject 以及删除实体时,需要记录的相关信息接口 IHasDeletionTimeIDeletionAuditedObject 等。除了审计日志模块定义的类型以外,在 Volo.Abp.Ddd.Domain 模块的 Auditing 里面也有很多审计实体的默认实现。

我在这里就不再一一列举,下面仅快速讲解一下 ABP vNext 是如何通过这些接口,实现对审计字段的自动维护的。

在审计日志模块的内部,我们看到一个接口名字叫做 IAuditPropertySetter,它提供了三个方法,分别是:

public interface IAuditPropertySetter
{
    void SetCreationProperties(object targetObject);

    void SetModificationProperties(object targetObject);

    void SetDeletionProperties(object targetObject);
}

所以,这几个方法就是用于设置创建信息、修改信息、删除信息的。现在跳转到默认实现 AuditPropertySetter,随便找一个 SetCreationTime() 方法。该方法内部首先是判断传入的 object 是否实现了 IHasCreationTime 接口,如果实现了对其进行强制类型转换,然后赋值即可。

private void SetCreationTime(object targetObject)
{
    if (!(targetObject is IHasCreationTime objectWithCreationTime))
    {
        return;
    }

    if (objectWithCreationTime.CreationTime == default)
    {
        objectWithCreationTime.CreationTime = Clock.Now;
    }
}

其他几个 Set 方法大同小异,那我们看一下有哪些地方使用到了上述三个方法。

1570459669067

可以看到使用者就包含有 EF Core 模块和 MongoDB 模块,这里我以 EF Core 模块为例,猜测应该是传入了实体对象过来。

1570459763182

果不其然...查看这个方法的调用链,发现是 DbContext 每次进行 SaveChanges/SaveChangesAsync 的时候,就会对实体进行审计字段自动赋值操作。

1570459991612

三、总结

审计日志是 ABP vNext 为我们提供的一个可选组件,当开启审计日志功能后,我们可以根据审计日志信息快速定位问题。但审计日志的开启,也会较大的影响性能,因为每次请求都会创建审计日志信息,之后再进行持久化。因此在使用审计日志功能时,可以结合 DisableAuditingAttribute 特性和 IAuditingManager.BeginScope(),按需开启审计日志功能。