Abp 审计模块源码解读

Abp 审计模块源码解读

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

初探

我通过abp脚手架创建了一个Acme.BookStore项目在BookStoreWebModule类使用了app.UseAuditing()拓展方法。

我们通过F12可以看到AbpApplicationBuilderExtensions中间件拓展类源码地址如下代码AbpAuditingMiddleware中间件。

    public static IApplicationBuilder UseAuditing(this IApplicationBuilder app)
    {
        return app
            .UseMiddleware<AbpAuditingMiddleware>();
    }

我们继续查看AbpAuditingMiddleware中间件源码源码地址下面我把代码贴上来一一解释(先从小方法解释)

  • 请求过滤(因为不是所以方法我们都需要记录,比如用户登录/用户支付)
    // 判断当前请求路径是否需要过滤
    private bool IsIgnoredUrl(HttpContext context)
    {
        // AspNetCoreAuditingOptions.IgnoredUrls是abp维护了一个过滤URL的一个容器
        return context.Request.Path.Value != null &&
               AspNetCoreAuditingOptions.IgnoredUrls.Any(x => context.Request.Path.Value.StartsWith(x));
    }
  • 是否保存审计日志
    private bool ShouldWriteAuditLog(HttpContext httpContext, bool hasError)
    {
        // 是否记录报错的审计日志
        if (AuditingOptions.AlwaysLogOnException && hasError)
        {
            return true;
        }

        // 是否记录未登录产生的审计日志
        if (!AuditingOptions.IsEnabledForAnonymousUsers && !CurrentUser.IsAuthenticated)
        {
            return false;
        }
        
        // 是否记录get请求产生的审计日志
        if (!AuditingOptions.IsEnabledForGetRequests &&
            string.Equals(httpContext.Request.Method, HttpMethods.Get, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        return true;
    }
  • 执行审计模块中间件
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        // 判断审计模块是否开启,IsIgnoredUrl就是我们上面说的私有方法了。
        if (!AuditingOptions.IsEnabled || IsIgnoredUrl(context))
        {
            await next(context);
            return;
        }
        // 是否出现报错
        var hasError = false;
        // 审计模块管理
        using (var saveHandle = _auditingManager.BeginScope())
        {
            Debug.Assert(_auditingManager.Current != null);

            try
            {
                await next(context);
                // 审计模块是否有记录错误到日志
                if (_auditingManager.Current.Log.Exceptions.Any())
                {
                    hasError = true;
                }
            }
            catch (Exception ex)
            {
                hasError = true;
                // 判断当前错误信息是否已经记录了
                if (!_auditingManager.Current.Log.Exceptions.Contains(ex))
                {
                    _auditingManager.Current.Log.Exceptions.Add(ex);
                }

                throw;
            }
            finally
            {
                // 判断是否记录
                if (ShouldWriteAuditLog(context, hasError))
                {
                    // 判断是否有工作单元(这里主要就是防止因为记录日志信息报错了,会影响主要的业务流程)
                    if (UnitOfWorkManager.Current != null)
                    {
                        await UnitOfWorkManager.Current.SaveChangesAsync();
                    }
                    // 执行保存
                    await saveHandle.SaveAsync();
                }
            }
        }
    }

上面我们主要梳理了审计模块的中间件逻辑,到这里我们对审计日志的配置会有一些印象了,AuditingOptions我们需要着重的注意,因为关系到审计模块一些使用细节。(这里我说说我的看法不管是在学习Abp的那一个模块,我们都需要知道对于的配置类中,每个属性的作用以及使用场景。)

深入

我们前面了解到审计模块的使用方式,为了了解其中的原理我们需要查看源码Volo.Abp.Auditing类库源码地址

AbpAuditingOptions配置类

public class AbpAuditingOptions
{
    /// <summary>
    /// 隐藏错误,默认值:true (没有看到使用)
    /// </summary>
    public bool HideErrors { get; set; }

    /// <summary>
    /// 启用审计模块,默认值:true
    /// </summary>
    public bool IsEnabled { get; set; }

    /// <summary>
    /// 审计日志的应用程序名称,默认值:null
    /// </summary>
    public string ApplicationName { get; set; }

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

    /// <summary>
    /// 记录所以报错,默认值:true(在上面中间件代码有用到)
    /// </summary>
    public bool AlwaysLogOnException { get; set; }

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

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

    /// <summary>
    /// 实体类型选择器。上下文中SaveChangesAsync有使用到
    /// </summary>
    public IEntityHistorySelectorList EntityHistorySelectors { get; }

    /// <summary>
    /// Get请求是否启用,默认值:false
    /// </summary>
    public bool IsEnabledForGetRequests { get; set; }

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

        Contributors = new List<AuditLogContributor>();

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

        EntityHistorySelectors = new EntityHistorySelectorList();
    }
}

AbpAuditingModule模块入口

下面代码即在组件注册的时候,会调用 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 (DynamicProxyIgnoreTypes.Contains(type))
        {
            return false;
        }

        // 是否启用审计
        if (ShouldAuditTypeByDefaultOrNull(type) == true)
        {
            return true;
        }

        // 该类型是否存在方法使用了AuditedAttribut特性
        if (type.GetMethods().Any(m => m.IsDefined(typeof(AuditedAttribute), true)))
        {
            return true;
        }
        return false;
    }

    public static bool? ShouldAuditTypeByDefaultOrNull(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 null;
    }
}

AuditingManager审计管理

上面我们讲了审计模块中间件,审计模块配置,以及特殊过滤配置,接下来我们就要继续深入到实现细节部分,前面中间件AuditingManager.BeginScope()代码是我们的入口,那就从这里开始下手源码地址

从下面的代码我们可以知道其实就是创建一个DisposableSaveHandle代理类。(我们需要注意构造参数的值)

  • 第一个this主要是将当前对象传入方法中
  • 第二个ambientScope重点是_auditingHelper.CreateAuditLogInfo()创建AuditLogInfo(对应Current.log)
  • 第三个Current.log当前AuditLogInfo信息
  • 第四个Stopwatch.StartNew()计时器
    public IAuditLogSaveHandle BeginScope()
    {
        // 创建AuditLogInfo类复制到Current.Log中(其实是维护了一个内部的字典)
        var ambientScope = _ambientScopeProvider.BeginScope(
            AmbientContextKey,
            new AuditLogScope(_auditingHelper.CreateAuditLogInfo())
        );
        return new DisposableSaveHandle(this, ambientScope, Current.Log, Stopwatch.StartNew());
    }

_auditingHelper.CreateAuditLogInfo()从http请求上下文中获取,当前的url/请求参数/请求浏览器/ip…..

    // 从http请求上下文中获取,当前的url/请求参数/请求浏览器/ip.....
    public virtual AuditLogInfo CreateAuditLogInfo()
    {
        var auditInfo = new AuditLogInfo
        {
            ApplicationName = Options.ApplicationName,
            TenantId = CurrentTenant.Id,
            TenantName = CurrentTenant.Name,
            UserId = CurrentUser.Id,
            UserName = CurrentUser.UserName,
            ClientId = CurrentClient.Id,
            CorrelationId = CorrelationIdProvider.Get(),
            ExecutionTime = Clock.Now,
            ImpersonatorUserId = CurrentUser.FindImpersonatorUserId(),
            ImpersonatorUserName = CurrentUser.FindImpersonatorUserName(),
            ImpersonatorTenantId = CurrentUser.FindImpersonatorTenantId(),
            ImpersonatorTenantName = CurrentUser.FindImpersonatorTenantName(),
        };
        ExecutePreContributors(auditInfo);
        return auditInfo;
    }

DisposableSaveHandle代理类中提供了一个SaveAsync()方法,调用AuditingManager.SaveAsync()当然这个SaveAsync()方法大家还是有一点点印象的吧,毕竟中间件最后完成之后就会调用该方法。

    protected class DisposableSaveHandle : IAuditLogSaveHandle
    {
        public AuditLogInfo AuditLog { get; }
        public Stopwatch StopWatch { get; }

        private readonly AuditingManager _auditingManager;
        private readonly IDisposable _scope;

        public DisposableSaveHandle(
            AuditingManager auditingManager,
            IDisposable scope,
            AuditLogInfo auditLog,
            Stopwatch stopWatch)
        {
            _auditingManager = auditingManager;
            _scope = scope;
            AuditLog = auditLog;
            StopWatch = stopWatch;
        }

        // 包装AuditingManager.SaveAsync方法
        public async Task SaveAsync()
        {
            await _auditingManager.SaveAsync(this);
        }

        public void Dispose()
        {
            _scope.Dispose();
        }
    }

AuditingManager.SaveAsync()主要做的事情也主要是组建AuditLogInfo信息,然后调用SimpleLogAuditingStore.SaveAsync(),SimpleLogAuditingStore 实现,其内部就是调用 ILogger 将信息输出。如果需要将审计日志持久化到数据库,你可以实现 IAUditingStore 接口,覆盖原有实现 ,或者使用 ABP vNext 提供的 Volo.Abp.AuditLogging 模块。

    protected virtual async Task SaveAsync(DisposableSaveHandle saveHandle)
    {
        // 获取审计记录
        BeforeSave(saveHandle);
        // 调用AuditingStore.SaveAsync
        await _auditingStore.SaveAsync(saveHandle.AuditLog);
    }

    // 获取审计记录
    protected virtual void BeforeSave(DisposableSaveHandle saveHandle)
    {
        saveHandle.StopWatch.Stop();
        saveHandle.AuditLog.ExecutionDuration = Convert.ToInt32(saveHandle.StopWatch.Elapsed.TotalMilliseconds);
        // 获取请求返回Response.StatusCode
        ExecutePostContributors(saveHandle.AuditLog);
        // 获取实体变化
        MergeEntityChanges(saveHandle.AuditLog);
    }

    // 获取请求返回Response.StatusCode
    protected virtual void ExecutePostContributors(AuditLogInfo auditLogInfo)
    {
        using (var scope = ServiceProvider.CreateScope())
        {
            var context = new AuditLogContributionContext(scope.ServiceProvider, auditLogInfo);

            foreach (var contributor in Options.Contributors)
            {
                try
                {
                    contributor.PostContribute(context);
                }
                catch (Exception ex)
                {
                    Logger.LogException(ex, LogLevel.Warning);
                }
            }
        }
    }

总结

首先审计模块的一些设计思路YYDS,审计模块的作用显而易见,但是在使用过程中注意利弊,好处就是方便我们进行错误排除,实时监控系统的健康。但是同时也会导致我们接口变慢(毕竟要记录日志信息),当然还要提到一点就是我们在阅读源码的过程中先了解模块是做什么的,然后了解基础的配置信息,再然后就是通过代码入口一层一层剖析就好了。