天天看點

[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

來擷取到一些用戶端的關鍵資訊。

[Abp 源碼分析]十五、自動審計記錄

2.1 過濾器注入

同上一篇文章所講的一樣,過濾器是在

AddAbp()

方法内部的

ConfigureAspNetCore()

方法注入的。

private static void ConfigureAspNetCore(IServiceCollection services, IIocResolver iocResolver)
{
    // ... 其他代碼
    
    //Configure MVC
    services.Configure<MvcOptions>(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<AbpBootstrapperOptions> 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<IAuditingConfiguration>())
            {
                return;
            }

            var auditingConfiguration = iocManager.Resolve<IAuditingConfiguration>();

            // 判斷目前 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

的一個作用是什麼,其基本類型定義由一個

string

Func<Type, bool>

組成,十分簡單,重點就出在這個斷言委托上面。

public class NamedTypeSelector
{
    // 選擇器名稱
    public string Name { get; set; }
    
    // 斷言委托
    public Func<Type, bool> Predicate { get; set; }

    public NamedTypeSelector(string name, Func<Type, bool> 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<TResult>
    {
        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<string, object> 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 建立審計資訊

審計資訊在建立的時候,就為我們将目前調用接口時的使用者資訊存放在了審計資訊當中,之後通過

IAuditInfoProvider

Fill()

方法填充了用戶端 IP 與浏覽器資訊。

public AuditInfo CreateAuditInfo(Type type, MethodInfo method, IDictionary<string, object> 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 源碼分析]十五、自動審計記錄

預設有這兩種實作,至于第一種是 Abp 的單元測試項目所使用的。

這裡我們就簡單将一下

AuditingStore

這個實作吧,其實很簡單的,就是注入了一個倉儲,在儲存的時候往審計日志表插入一條資料即可。

這裡使用了

AuditLog.CreateFromAuditInfo()

方法将

AuditInfo

類型的審計資訊轉換為資料庫實體,用于倉儲進行插入操作。

public class AuditingStore : IAuditingStore, ITransientDependency
{
    private readonly IRepository<AuditLog, long> _auditLogRepository;

    public AuditingStore(IRepository<AuditLog, long> 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.點此跳轉到總目錄