1. 程式人生 > >[Abp 原始碼分析]十四、DTO 自動驗證

[Abp 原始碼分析]十四、DTO 自動驗證

0.簡介

在平時開發 API 介面的時候需要對前端傳入的引數進行校驗之後才能進入業務邏輯進行處理,否則一旦前端傳入一些非法/無效資料到 API 當中,輕則導致程式報錯,重則導致整個業務流程出現問題。

用過傳統 ASP.NET MVC 資料註解的同學應該知道,我們可以通過在 Model 上面指定各種資料特性,然後在前端呼叫 API 的時候就會根據這些註解來校驗 Model 內部的欄位是否合法。

1.啟動流程

Abp 針對於資料校驗分為兩個地方進行,第一個是 MVC 的過濾器,也是我們最常使用的。第二個則是藉助於 Castle 的攔截器實現的 DTO 資料校驗功能,前者只能用於控制器方法,而後者則支援普通方法。

1.1 過濾器注入

在注入 Abp 的時候,通過 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(AbpValidationActionFilter));
        
        // ... 其他過濾器注入
    }
    
    // ... 其他程式碼
}

1.2 攔截器注入

Abp 針對於驗證攔截器的註冊始於 AbpBootstrapper 類,該基類在之前曾經多次出現過,也就是在使用者呼叫 IServiceCollection.AddAbp<TStartupModule>() 方法的時候會初始化該類的一個例項物件。在該類的建構函式當中,會呼叫一個 AddInterceptorRegistrars() 方法用於新增各種攔截器的註冊類例項。程式碼如下:

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);
    }

    // ... 其他程式碼\
}

來到 ValidationInterceptorRegistrar 型別定義當中可以看到,其內部就是通過 Castle 的 IocContainer 來針對每次注入的應用服務應用上引數驗證攔截器。

internal static class ValidationInterceptorRegistrar
{
    public static void Initialize(IIocManager iocManager)
    {
        iocManager.IocContainer.Kernel.ComponentRegistered += Kernel_ComponentRegistered;
    }

    private static void Kernel_ComponentRegistered(string key, IHandler handler)
    {
        // 判斷是否實現了 IApplicationService 介面,如果實現了,則為該物件新增攔截器
        if (typeof(IApplicationService).GetTypeInfo().IsAssignableFrom(handler.ComponentModel.Implementation))
        {
            handler.ComponentModel.Interceptors.Add(new InterceptorReference(typeof(ValidationInterceptor)));
        }
    }
}

2.程式碼分析

從 Abp 庫程式碼當中我們可以知道其攔截器與過濾器是在何時被注入的,下面我們就來具體分析一下他們的處理邏輯。

2.1 過濾器程式碼分析

Abp 在框架初始化的時候就將 AbpValidationActionFilter 新增到 MVC 的配置當中,其自定義實現的攔截器實現了 IAsyncActionFilter 介面,也就是說當每次介面被呼叫的時候都會進入該攔截器的內部。

public class AbpValidationActionFilter : IAsyncActionFilter, ITransientDependency
{
    // Ioc 解析器,用於解析各種注入的元件
    private readonly IIocResolver _iocResolver;
    // Abp 針對與 ASP.NET Core 的配置項,主要作用是判斷使用者是否需要檢測控制器方法
    private readonly IAbpAspNetCoreConfiguration _configuration;

    public AbpValidationActionFilter(IIocResolver iocResolver, IAbpAspNetCoreConfiguration configuration)
    {
        _iocResolver = iocResolver;
        _configuration = configuration;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // ... 處理邏輯
    }
}

在內部首先是結合配置項判斷使用者是否禁用了 MVC Controller 的引數驗證功能,禁用了則不進行任何操作。

public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
    // 判斷是否禁用了控制器檢測
    if (!_configuration.IsValidationEnabledForControllers || !context.ActionDescriptor.IsControllerAction())
    {
        await next();
        return;
    }

    // 針對應用服務增加一個驗證完成標識
    using (AbpCrossCuttingConcerns.Applying(context.Controller, AbpCrossCuttingConcerns.Validation))
    {
        // 解析出方法驗證器,傳入請求上下文,並且呼叫這些驗證器具體的驗證方法
        using (var validator = _iocResolver.ResolveAsDisposable<MvcActionInvocationValidator>())
        {
            validator.Object.Initialize(context);
            validator.Object.Validate();
        }

        await next();
    }
}

其實我們這裡看到有一個 AbpCrossCuttingConcerns.Applying() 方法,那麼該方法的作用是什麼呢?

在這裡我先大體講述一下該方法的作用,該方法主要是嚮應用服務物件 (也就是繼承了 ApplicationService 類的物件) 內部的 AppliedCrossCuttingConcerns 屬性增加一個常量值,在這裡也就是 AbpCrossCuttingConcerns.Validation 的值,也就是一個字串。

那麼其作用是什麼呢,就是防止重複驗證。從啟動流程一節我們就已經知道 Abp 框架在啟動的時候除了注入過濾器之外,還會注入攔截器進行介面引數驗證,當過濾器驗證過之後,其實沒必要再使用攔截器進行二次驗證。

所以在攔截器的 Intercept() 方法內部會有這樣一句程式碼:

public void Intercept(IInvocation invocation)
{
    // 判斷是否擁有處理過的標識
    if (AbpCrossCuttingConcerns.IsApplied(invocation.InvocationTarget, AbpCrossCuttingConcerns.Validation))
    {
        invocation.Proceed();
        return;
    }

    // ... 其他程式碼
}

解釋完 AbpCrossCuttingConcerns.Applying() 之後,我們繼續往下看程式碼。

// 解析出方法驗證器,傳入請求上下文,並且呼叫這些驗證器具體的驗證方法
using (var validator = _iocResolver.ResolveAsDisposable<MvcActionInvocationValidator>())
{
    validator.Object.Initialize(context);
    validator.Object.Validate();
}

await next();

這裡就比較簡單了,過濾器通過 IocResolver 解析出來了一個 MvcActionInvocationValidator 物件,使用該物件來校驗具體的引數內容。

2.2 攔截器程式碼分析

看完過濾器程式碼之後,其實攔截器程式碼更加簡單。整體邏輯上面與過濾器差不多,只不過針對於攔截器,它是通過一個 MethodInvocationValidator 物件來校驗傳入的引數內容。

public class ValidationInterceptor : IInterceptor
{
    // Ioc 解析器,用於解析各種注入的元件
    private readonly IIocResolver _iocResolver;

    public ValidationInterceptor(IIocResolver iocResolver)
    {
        _iocResolver = iocResolver;
    }

    public void Intercept(IInvocation invocation)
    {
        // 判斷過濾器是否已經處理過
        if (AbpCrossCuttingConcerns.IsApplied(invocation.InvocationTarget, AbpCrossCuttingConcerns.Validation))
        {
            // 處理過則直接進入具體方法內部,執行業務邏輯
            invocation.Proceed();
            return;
        }

        // 解析出方法驗證器,傳入請求上下文,並且呼叫這些驗證器具體的驗證方法
        using (var validator = _iocResolver.ResolveAsDisposable<MethodInvocationValidator>())
        {
            validator.Object.Initialize(invocation.MethodInvocationTarget, invocation.Arguments);
            validator.Object.Validate();
        }

        invocation.Proceed();
    }
}

可以看到兩個過濾器與攔截器業務邏輯相似,但都是通過驗證器來進行處理的,那麼驗證器又是個什麼鬼東西呢?

2.3 引數驗證器

驗證器即是用來具體執行驗證邏輯的工具,從上述程式碼裡面我們可以看到過濾器和攔截器都是通過解析出 MethodInvocationValidator/MvcActionInvocationValidator 之後呼叫其驗證方法進行驗證的。

首先我們來看一下 MVC 的驗證器是如何進行處理的,看方法型別的定義,可以看到其繼承了一個基類,叫 ActionInvocationValidatorBase,而這個基類呢,又繼承自 MethodInvocationValidator

public class MvcActionInvocationValidator : ActionInvocationValidatorBase
{
    // ... 其他程式碼
}
public abstract class ActionInvocationValidatorBase : MethodInvocationValidator
{
    // ... 其他程式碼
}

所以我們分析程式碼的順序調整一下,先看一下 MethodInvocationValidator 的內部是如何做處理的吧,這個型別內部還是比較簡單的,可能除了有一個遞迴有點繞之外。

其主要功能就是拿著傳遞進來的引數值,通過在 Abp 框架啟動的時候注入的具體驗證器(使用者自定義驗證器)來遞迴校驗每個引數的值。

/// <summary>
/// 本類用於需要引數驗證的方法.
/// </summary>
public class MethodInvocationValidator : ITransientDependency
{
    // 最大迭代驗證次數
    private const int MaxRecursiveParameterValidationDepth = 8;

    // 待驗證的方法資訊
    protected MethodInfo Method { get; private set; }
    // 傳入的引數值
    protected object[] ParameterValues { get; private set; }
    // 方法引數資訊
    protected ParameterInfo[] Parameters { get; private set; }
    protected List<ValidationResult> ValidationErrors { get; }
    protected List<IShouldNormalize> ObjectsToBeNormalized { get; }

    private readonly IValidationConfiguration _configuration;
    private readonly IIocResolver _iocResolver;

    public MethodInvocationValidator(IValidationConfiguration configuration, IIocResolver iocResolver)
    {
        _configuration = configuration;
        _iocResolver = iocResolver;

        ValidationErrors = new List<ValidationResult>();
        ObjectsToBeNormalized = new List<IShouldNormalize>();
    }

    // 初始化攔截器引數
    public virtual void Initialize(MethodInfo method, object[] parameterValues)
    {
        Check.NotNull(method, nameof(method));
        Check.NotNull(parameterValues, nameof(parameterValues));

        Method = method;
        ParameterValues = parameterValues;
        Parameters = method.GetParameters();
    }
    
    // 開始驗證引數的有效性
    public void Validate()
    {
        // 檢測是否初始化,沒有初始化則丟擲系統級異常
        CheckInitialized();

        // 檢測方法是否有引數
        if (Parameters.IsNullOrEmpty())
        {
            return;
        }

        // 檢測方法是否為公開方法
        if (!Method.IsPublic)
        {
            return;
        }

        // 如果沒有開啟方法引數檢測,則直接返回
        if (IsValidationDisabled())
        {
            return;                
        }

        // 如果方法所定義的引數數量與傳入的引數值數量匹配不上,則丟擲系統級異常
        if (Parameters.Length != ParameterValues.Length)
        {
            throw new Exception("Method parameter count does not match with argument count!");
        }

        // 遍歷方法的引數列表,使用傳入的引數值進行校驗
        for (var i = 0; i < Parameters.Length; i++)
        {
            ValidateMethodParameter(Parameters[i], ParameterValues[i]);
        }

        // 如果校驗的錯誤結果集合有任意一條資料,則丟擲使用者異常,返回給前端展示
        if (ValidationErrors.Any())
        {
            ThrowValidationError();
        }

        foreach (var objectToBeNormalized in ObjectsToBeNormalized)
        {
            objectToBeNormalized.Normalize();
        }
    }

    // ... 忽略的程式碼
    
    // 校驗呼叫方法時傳遞的引數與引數值
    protected virtual void ValidateMethodParameter(ParameterInfo parameterInfo, object parameterValue)
    {
        // 如果引數值為空的情況下,做一系列特殊判斷
        if (parameterValue == null)
        {
            if (!parameterInfo.IsOptional && 
                !parameterInfo.IsOut && 
                !TypeHelper.IsPrimitiveExtendedIncludingNullable(parameterInfo.ParameterType, includeEnums: true))
            {
                ValidationErrors.Add(new ValidationResult(parameterInfo.Name + " is null!", new[] { parameterInfo.Name }));
            }

            return;
        }

        // 遞迴校驗引數
        ValidateObjectRecursively(parameterValue, 1);
    }

    protected virtual void ValidateObjectRecursively(object validatingObject, int currentDepth)
    {
        // 驗證層級是否超過了最大層級(8)
        if (currentDepth > MaxRecursiveParameterValidationDepth)
        {
            return;
        }

        // 值是否為空,為空則不繼續進行校驗
        if (validatingObject == null)
        {
            return;
        }

        // 判斷其型別是否是使用者配置的忽略型別,忽略則不進行校驗
        if (_configuration.IgnoredTypes.Any(t => t.IsInstanceOfType(validatingObject)))
        {
            return;
        }

        // 判斷引數型別是否為基本型別
        if (TypeHelper.IsPrimitiveExtendedIncludingNullable(validatingObject.GetType()))
        {
            return;
        }

        SetValidationErrors(validatingObject);

        // 判定引數型別是否實現了 IEnumerabe 介面,如果實現了,則遞迴遍歷校驗其內部的元素
        if (IsEnumerable(validatingObject))
        {
            foreach (var item in (IEnumerable) validatingObject)
            {
                ValidateObjectRecursively(item, currentDepth + 1);
            }
        }

        // 如果實現了標準化介面,則進行標準化操作
        if (validatingObject is IShouldNormalize)
        {
            ObjectsToBeNormalized.Add(validatingObject as IShouldNormalize);
        }

        // 是否還需要繼續遞迴校驗
        if (ShouldMakeDeepValidation(validatingObject))
        {
            var properties = TypeDescriptor.GetProperties(validatingObject).Cast<PropertyDescriptor>();
            foreach (var property in properties)
            {
                // 如果有禁止校驗的特性則忽略
                if (property.Attributes.OfType<DisableValidationAttribute>().Any())
                {
                    continue;
                }

                ValidateObjectRecursively(property.GetValue(validatingObject), currentDepth + 1);
            }
        }
    }
    
    // ... 其他程式碼

    protected virtual bool ShouldValidateUsingValidator(object validatingObject, Type validatorType)
    {
        return true;
    }

    // 是否進行深度驗證
    protected virtual bool ShouldMakeDeepValidation(object validatingObject)
    {
        // 不需要遞迴集合物件
        if (validatingObject is IEnumerable)
        {
            return false;
        }

        var validatingObjectType = validatingObject.GetType();

        // 不需要遞迴基礎型別的物件
        if (TypeHelper.IsPrimitiveExtendedIncludingNullable(validatingObjectType))
        {
            return false;
        }

        return true;
    }
    
    // ... 其他程式碼
}

有朋友可能會奇怪,在方法內部不是通過 IEnumerable 判斷之後來進行遞迴校驗麼,為什麼在最後面還有一個深度驗證呢?

這是因為當前物件除了是一個集合的情況之外,還有可能其內部某個物件是另外一個使用者所自定義的複雜物件,這個時候就必須要通過深度驗證來校驗各個引數的值。不過這個遞迴也是有限度的,通過 MaxRecursiveParameterValidationDepth 來控制這個迭代層數為 8 層。如果不加以限制的話,那麼很有可能出現迴圈引用而產生死迴圈的情況,或者是層級過深導致介面相應緩慢。

那麼在這裡執行具體校驗操作的則是那些實現了 IMethodParameterValidator 介面的物件,這些物件在 Abp 核心模組(AbpKernelModule)的預載入的時候被新增到了 Configuration.Validation.Validators 屬性當中。

當然使用者也可以在自己的模組預載入方法當中增加自己的引數驗證器,只要實現該介面即可。

public sealed class AbpKernelModule : AbpModule
{
    public override void PreInitialize()
    {
        // ... 其他程式碼
        // 增加需要忽略的型別
        AddIgnoredTypes();
        // 增加引數校驗器
        AddMethodParameterValidators();
    }

    private void AddMethodParameterValidators()
    {
        Configuration.Validation.Validators.Add<DataAnnotationsValidator>();
        Configuration.Validation.Validators.Add<ValidatableObjectValidator>();
        Configuration.Validation.Validators.Add<CustomValidator>();
    }

    // Abp 預設需要忽略的物件
    private void AddIgnoredTypes()
    {
        var commonIgnoredTypes = new[]
        {
            typeof(Stream),
            typeof(Expression)
        };

        foreach (var ignoredType in commonIgnoredTypes)
        {
            Configuration.Auditing.IgnoredTypes.AddIfNotContains(ignoredType);
            Configuration.Validation.IgnoredTypes.AddIfNotContains(ignoredType);
        }

        var validationIgnoredTypes = new[] { typeof(Type) };
        foreach (var ignoredType in validationIgnoredTypes)
        {
            Configuration.Validation.IgnoredTypes.AddIfNotContains(ignoredType);
        }
    }
}

之後呢,回到之前的校驗方法,可以看到在 SetValidationErrors(object validatingObject) 方法裡面遍歷了之前被注入的驗證器集合,然後呼叫其 Validate() 方法來進行具體的引數校驗。

protected virtual void SetValidationErrors(object validatingObject)
{
    foreach (var validatorType in _configuration.Validators)
    {
        if (ShouldValidateUsingValidator(validatingObject, validatorType))
        {
            using (var validator = _iocResolver.ResolveAsDisposable<IMethodParameterValidator>(validatorType))
            {
                var validationResults = validator.Object.Validate(validatingObject);
                ValidationErrors.AddRange(validationResults);
            }
        }
    }
}

2.4 具體的引數驗證器

這裡以 Abp 預設實現的 DataAnnotationValidator 型別為例,可以看看他是怎麼來根據引數的資料註解來驗證引數是否正確的。

public class DataAnnotationsValidator : IMethodParameterValidator
{
    public virtual IReadOnlyList<ValidationResult> Validate(object validatingObject)
    {
        return GetDataAnnotationAttributeErrors(validatingObject);
    }
    
    protected virtual List<ValidationResult> GetDataAnnotationAttributeErrors(object validatingObject)
    {
        var validationErrors = new List<ValidationResult>();

        var properties = TypeDescriptor.GetProperties(validatingObject).Cast<PropertyDescriptor>();
        // 獲得引數值的所有屬性,如果傳入的是一個 DTO 物件的話,他內部肯定會有很多屬性的
        foreach (var property in properties)
        {
            var validationAttributes = property.Attributes.OfType<ValidationAttribute>().ToArray();
            // 沒有資料註解特性,跳過當前屬性處理
            if (validationAttributes.IsNullOrEmpty())
            {
                continue;
            }

            // 建立一個錯誤資訊上下文,使用者資料註解工具進行校驗
            var validationContext = new ValidationContext(validatingObject)
            {
                DisplayName = property.DisplayName,
                MemberName = property.Name
            };

            // 根據特性來校驗引數結果
            foreach (var attribute in validationAttributes)
            {
                var result = attribute.GetValidationResult(property.GetValue(validatingObject), validationContext);
                if (result != null)
                {
                    validationErrors.Add(result);
                }
            }
        }

        return validationErrors;
    }
}

3. 後記

最近工作較忙,可能更新速度不會像原來那麼快,不過我儘可能在國慶結束後完成剩餘文章,謝謝大家的支援。