Abp 審計模組原始碼解讀

是你晨曦哥呀發表於2022-02-03

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,審計模組的作用顯而易見,但是在使用過程中注意利弊,好處就是方便我們進行錯誤排除,實時監控系統的健康。但是同時也會導致我們介面變慢(畢竟要記錄日誌資訊),當然還要提到一點就是我們在閱讀原始碼的過程中先了解模組是做什麼的,然後瞭解基礎的配置資訊,再然後就是通過程式碼入口一層一層剖析就好了。

相關文章