G
N
I
D
A
O
L

如何优雅地让 ASP.NET Core 支持异步模型验证

前言

在ASP.NET Core官方仓库中有个一直很受关注的问题Please reconsider allowing async model validationFluentValidation的作者也非常关心这个问题,因为FluentValidation内置异步验证功能,但是由于MVC内置的模型验证管道是同步的,使可兼容的功能和集成都受到严重阻碍。每次MVC修改验证功能都有可能导致集成出问题。

不仅如此,FluentValidation虽然是一个优秀的对象验证库,但其实现方式还是导致了一些与ASP.NET Core集成上的问题。例如他的验证消息本地化功能是通过全局对象管理的,这导致想要把消息本地化和依赖注入集成在一起的方式非常别扭。

最近在问题的评论中发现了一个复制并修改原始模型绑定系统得到的异步模型验证服务。这个修改版看上去还是比较直观的,使用自定义模型绑定器替换内置绑定器,然后用自定义的异步验证服务替换内置验证服务。同时提供了配套的异步验证特性和异步验证器助手。

但是使用过程中还是发现了一些不尽如人意的地方:

  1. 这个库直接面向ASP.NET Core框架,如果只想使用基本的异步验证功能,会被强制依赖无关内容。
  2. 异步验证特性的实现方式没有完全还原同步特性的使用方式,使用抽象方法强制要求实现返回ValidationResult的验证方法。内置特性只需要重写返回boolValidationResult的任意一个方法即可,对于大多数简单的验证需求而言返回bool的版本完全足够。因此易用性不足。
  3. 如果为模型标记了异步验证特性且使用控制器提供的手动验证模型的方法,会导致手动验证发生异常。这导致在代码上调整模型后重新验证的功能不再可用。同时也没有提供对应的手动重新异步验证模型的方法。
  4. 模型验证是MVC的内部功能,无法在类似最小API的场合使用。内置的模型验证器又不会自动递归验证,反过来又导致可能需要换用FluentValidation。之前说过FluentValidation和MVC集成是有瑕疵的。如果这么做,又可能出现同时使用多种验证方案使管理成本上升的情况。

总之就是无论如何都感觉别扭。因此笔者基于原始的改造重写了一个新版以期解决以上问题。

新书宣传

有关新书的更多介绍欢迎查看《C#与.NET6 开发从入门到实践》上市,作者亲自来打广告了!
image

正文

异步模型验证

解决第一个问题,首先就是分离基本验证器和MVC集成的类到不同项目,因此基本验证器的相关内容放到CoreDX.Extensions.Validation中,MVC相关的内容放到CoreDX.Extensions.AspNetCore.Validation中。

解决第二个问题则是简单的根据原版验证特性重新按套路添加异步方法。

AsyncValidationAttribute

public abstract class AsyncValidationAttribute : ValidationAttribute
{
    private volatile bool _hasBaseIsValidAsync;

    protected override sealed ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        throw new InvalidOperationException("Async validation called synchronously.");
    }

    public override sealed bool IsValid(object? value)
    {
        throw new InvalidOperationException("Async validation called synchronously.");
    }

    public virtual async ValueTask<bool> IsValidAsync(object? value, CancellationToken cancellationToken = default)
    {
        if (!_hasBaseIsValidAsync)
        {
            // track that this method overload has not been overridden.
            _hasBaseIsValidAsync = true;
        }

        // call overridden method.
        // The IsValid method without a validationContext predates the one accepting the context.
        // This is theoretically unreachable through normal use cases.
        // Instead, the overload using validationContext should be called.
        return await IsValidAsync(value, null!, cancellationToken: cancellationToken) == ValidationResult.Success;
    }

    protected virtual async ValueTask<ValidationResult?> IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (_hasBaseIsValidAsync)
        {
            // this means neither of the IsValidAsync methods has been overridden, throw.
            throw new NotImplementedException("IsValidAsync(object value, CancellationToken cancellationToken) has not been implemented by this class.  The preferred entry point is GetValidationResultAsync() and classes should override IsValidAsync(object value, ValidationContext context, CancellationToken cancellationToken).");
        }

        // call overridden method.
        return await IsValidAsync(value, cancellationToken)
            ? ValidationResult.Success
            : CreateFailedValidationResult(validationContext);
    }

    public async ValueTask<ValidationResult?> GetValidationResultAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }

        ValidationResult? result = await IsValidAsync(value, validationContext, cancellationToken);

        // If validation fails, we want to ensure we have a ValidationResult that guarantees it has an ErrorMessage
        if (result != null)
        {
            if (string.IsNullOrEmpty(result.ErrorMessage))
            {
                var errorMessage = FormatErrorMessage(validationContext.DisplayName);
                result = new ValidationResult(errorMessage, result?.MemberNames);
            }
        }

        return result;
    }

    public async ValueTask ValidateAsync(object? value, string name, CancellationToken cancellationToken = default)
    {
        if (!(await IsValidAsync(value, cancellationToken)))
        {
            throw new ValidationException(FormatErrorMessage(name), this, value);
        }
    }

    public async ValueTask ValidateAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken = default)
    {
        if (validationContext == null)
        {
            throw new ArgumentNullException(nameof(validationContext));
        }

        ValidationResult? result = await GetValidationResultAsync(value, validationContext, cancellationToken: cancellationToken);

        if (result != null)
        {
            // Convenience -- if implementation did not fill in an error message,
            throw new ValidationException(result, this, value);
        }
    }
}

基本思路就是继承原版验证特性,密封原版验证方法抛出异常,添加对应的异步验证方法。同时笔者也添加了对应的自定义异步验证特性CustomAsyncValidationAttribute,代码较多,基本思路也没变,感兴趣的可以查看仓库代码CustomAsyncValidationAttribute.cs。当然用于直接在类型上实现验证功能的接口IValidatableObject也添加了对应的异步版本IAsyncValidatableObject,做戏做全套嘛。

然后就是实现对应的异步验证器,方法也非常简单,复制原版验证器的代码,把所有需要的方法改为异步方法,在内部添加对普通同步验证特性和异步验证特性的分别处理,最后加上自定义新接口的支持部分就大功告成了。详细代码可以查看AsyncValidator.cs

根据命名规范,如果一个类型是明确的异步专用类型,内部的方法可以不使用Async后缀,例如Task类中的方法,因此笔者使用没有后缀的方法名,也方便修改使用验证器的代码,只需要修改类型便可。

解决第三个问题的关键是IModelValidator接口的实现,评论提供的代码直接抛出异常,非常粗暴,因此这里直接修改成返回空白结果集即可。为了配合异步验证,添加一个新接口IAsyncModelValidator实现IModelValidator并添加对应的异步方法,实现同步接口的原因是为了能顺利注册到MVC框架中。这一套下来需要重新实现十个左右的服务,代码量较大,和原版服务的区别也仅限于同步和异步,因此不再展示,感兴趣的朋友可以查看仓库代码CoreDX.Extensions.AspNetCore.Validation

其中值得关注的一点是,复刻版DefaultComplexObjectValidationStrategy需要使用ModelMetadata的内部成员,只能反射。好在 .NET8.0 添加了一个专门用来简化静态反射的功能,可以用起来简化代码提高性能。

#if NET8_0_OR_GREATER
    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = nameof(ThrowIfRecordTypeHasValidationOnProperties))]
    internal extern static void ThrowIfRecordTypeHasValidationOnProperties(ModelMetadata modelMetadata);

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_BoundProperties")]
    internal extern static IReadOnlyList<ModelMetadata> GetBoundProperties(ModelMetadata modelMetadata);

    [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_BoundConstructorParameterMapping")]
    internal extern static IReadOnlyDictionary<ModelMetadata, ModelMetadata> GetBoundConstructorParameterMapping(ModelMetadata modelMetadata);
#endif

最后在为服务注册和手动重新验证添加相应的辅助方法,即可正常使用。此时如果继续使用同步的手动模型验证,虽然不会发生异常,但是异步验证特性也会被忽略。

MVC异步模型验证服务注册扩展

namespace Microsoft.AspNetCore.Mvc;

public static class AsyncValidationExtension
{
    public static IMvcBuilder AddAsyncDataAnnotations(this IMvcBuilder builder)
    {
        builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, ConfigureMvcOptionsSetup>();
        builder.Services.AddSingleton<ParameterBinder, AsyncParamterBinder>();
        builder.Services.TryAddSingleton<IAsyncObjectModelValidator>(s =>
        {
            var options = s.GetRequiredService<IOptions<MvcOptions>>().Value;
            var metadataProvider = s.GetRequiredService<IModelMetadataProvider>();
            return new DefaultAsyncObjecValidator(metadataProvider, options.ModelValidatorProviders, options);
        });
        return builder;
    }

    public static IMvcCoreBuilder AddAsyncDataAnnotations(this IMvcCoreBuilder builder)
    {
        builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, ConfigureMvcOptionsSetup>();
        builder.Services.AddSingleton<ParameterBinder, AsyncParamterBinder>();
        builder.Services.TryAddSingleton<IAsyncObjectModelValidator>(s =>
        {
            var options = s.GetRequiredService<IOptions<MvcOptions>>().Value;
            var cache = s.GetRequiredService<ValidatorCache>();
            var metadataProvider = s.GetRequiredService<IModelMetadataProvider>();
            return new DefaultAsyncObjecValidator(metadataProvider, options.ModelValidatorProviders, options);
        });
        return builder;
    }

    internal sealed class ConfigureMvcOptionsSetup : IConfigureOptions<MvcOptions>
    {
        private readonly IStringLocalizerFactory? _stringLocalizerFactory;
        private readonly IValidationAttributeAdapterProvider _validationAttributeAdapterProvider;
        private readonly IOptions<MvcDataAnnotationsLocalizationOptions> _dataAnnotationLocalizationOptions;

        public ConfigureMvcOptionsSetup(
            IValidationAttributeAdapterProvider validationAttributeAdapterProvider,
            IOptions<MvcDataAnnotationsLocalizationOptions> dataAnnotationLocalizationOptions)
        {
            ArgumentNullException.ThrowIfNull(validationAttributeAdapterProvider);
            ArgumentNullException.ThrowIfNull(dataAnnotationLocalizationOptions);

            _validationAttributeAdapterProvider = validationAttributeAdapterProvider;
            _dataAnnotationLocalizationOptions = dataAnnotationLocalizationOptions;
        }

        public ConfigureMvcOptionsSetup(
            IValidationAttributeAdapterProvider validationAttributeAdapterProvider,
            IOptions<MvcDataAnnotationsLocalizationOptions> dataAnnotationLocalizationOptions,
            IStringLocalizerFactory stringLocalizerFactory)
            : this(validationAttributeAdapterProvider, dataAnnotationLocalizationOptions)
        {
            _stringLocalizerFactory = stringLocalizerFactory;
        }

        public void Configure(MvcOptions options)
        {
            ArgumentNullException.ThrowIfNull(options);

            options.ModelValidatorProviders.Insert(0, new AsyncDataAnnotationsModelValidatorProvider(
                _validationAttributeAdapterProvider,
                _dataAnnotationLocalizationOptions,
                _stringLocalizerFactory));

            options.ModelValidatorProviders.Insert(0, new DefaultAsyncModelValidatorProvider());
        }
    }
}

public static class AsyncValidatiorExtension
{
    public static Task<bool> TryValidateModelAsync(
        this ControllerBase controller,
        object model,
        CancellationToken cancellationToken = default)
    {
        return TryValidateModelAsync(controller, model, null, cancellationToken);
    }

    public static async Task<bool> TryValidateModelAsync(
        this ControllerBase controller,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        await TryValidateModelAsync(
            controller.ControllerContext,
            model: model,
            prefix: prefix ?? string.Empty,
            cancellationToken);

        return controller.ModelState.IsValid;
    }

    public static Task<bool> TryValidateModelAsync(
        this PageModel page,
        object model,
        CancellationToken cancellationToken = default)
    {
        return TryValidateModelAsync(page, model, null, cancellationToken);
    }

    public static async Task<bool> TryValidateModelAsync(
        this PageModel page,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        await TryValidateModelAsync(
            page.PageContext,
            model: model,
            prefix: prefix ?? string.Empty,
            cancellationToken);

        return page.ModelState.IsValid;
    }

    private static Task TryValidateModelAsync(
        ActionContext context,
        object model,
        string? prefix,
        CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(context);
        ArgumentNullException.ThrowIfNull(model);

        var validator = context.HttpContext.RequestServices.GetRequiredService<IAsyncObjectModelValidator>();

        return validator.ValidateAsync(
            context,
            validationState: null,
            prefix: prefix ?? string.Empty,
            model: model,
            cancellationToken);
    }
}

对象图递归验证

终于来到了最后一个问题,这个其实才是最困难的。Nuget上有一些别人写的递归验证器,但是笔者查看过代码和issues后发现这些验证器都有这样那样的问题。首先这些验证器都不支持异步验证,而且笔者有自己的异步验证基础类,就算这些验证器支持异步验证也和笔者提供的类型不兼容;其次这些验证器的API形状和内部运行机制没有完全对齐官方版本,这也意味着手动拆包对象后用官方验证器出来的结果可能对不上;再次这些验证器都存在没有解决的issue;作者也基本弃坑了。最后还是只能自己写一个。

在 Blazor 中曾经有一个实验性的递归表单模型验证器。但是这个验证器首先只能在 Blazor 中使用,其次需要一个专用特性表示模型的某个属性是复杂对象类型,需要继续深入验证他的内部属性,这种实现方式又会导致如果某个属性本身不需要验证,但内部的其他属性需要验证,就要链式地为整个属性链全部标记这个特性。这种半自动的用法还是不太方便。如果这个类型的源代码不归自己管无法修改,那就彻底没戏了。

在参考了这个已经不存在的表单验证器后,笔者实现了第一版对象图验证器,但是调试时发现一个极其麻烦的问题,循环引用对象的自动短路无论如何表现的都非常奇怪。要么是某些对象没有被验证,要么是某些对象被验证两次,要么干脆直接栈溢出,怎么调整短路条件都不对。而且此时还只是实现了同步验证,如果要再加上异步验证,一定会变成一个更麻烦的问题。

多次尝试无果后只能重新整理思路和代码。皇天不负有心人,在将近半个月的摸索后终于灵光一闪,想通了问题的关键。画龙点睛的一行代码写完后,一切终于如预期一样工作,甚至验证结果的出现顺序都和预想的完全一致。完成这个全自动对象图验证器后,最小API之类的其他场景也终于可以像MVC一样用验证特性验证整个对象模型了。并且这个验证器位于基础功能包,不依赖ASP.NET Core,可以在任意 .NET 项目中使用。

整个验证类代码较多,超过两千行,API形状和基础行为和官方验证器保持一致(笔者查看过的其他验证器代码基本在500行以内,几乎无法避免存在缺陷),因此只展示一下关键部分,完整代码请查看仓库ObjectGraphValidation

private static bool TryValidateObjectRecursive(
    object instance,
    ValidationContext validationContext,
    ValidationResultStore? validationResults,
    AsyncValidationBehavior asyncValidationBehavior,
    bool validateAllProperties,
    Func<Type, bool>? predicate,
    bool throwOnFirstError)
{
    if (instance == null)
    {
        throw new ArgumentNullException(nameof(instance));
    }
    if (validationContext == null)
    {
        throw new ArgumentNullException(nameof(validationContext));
    }
    if (instance != validationContext.ObjectInstance)
    {
        throw new ArgumentException("The instance provided must match the ObjectInstance on the ValidationContext supplied.", nameof(instance));
    }

    // 这里就是关键,只要在这里记录访问历史,一切都会好起来的
    if (!(validationContext.Items.TryGetValue(_validatedObjectsKey, out var item)
        && item is HashSet<object> visited
        && visited.Add(instance)))
    {
        return true;
    }

    bool isValid = true;
    bool breakOnFirstError = (validationResults == null);

    foreach (ValidationError err in GetObjectValidationErrors(
        instance,
        validationContext,
        asyncValidationBehavior,
        validateAllProperties,
        breakOnFirstError))
    {
        if (throwOnFirstError) err.ThrowValidationException();

        isValid = false;

        if (breakOnFirstError) break;

        TransferErrorToResult(validationResults!, err);
    }

    if (!isValid && breakOnFirstError) return isValid;

    var propertyObjectsAreValid = TryValidatePropertyObjects(
        instance,
        validationContext,
        validationResults,
        asyncValidationBehavior,
        validateAllProperties,
        predicate,
        throwOnFirstError);

    if (isValid && !propertyObjectsAreValid) isValid = false;

    return isValid;
}

这个方法是一切递归的开始,因此对象的访问记录也应该从这里开始,只是当时被 Blazor 的代码干扰了一下,老想着在别处处理这个问题被坑了半个月。参数里的委托是一个自定义判定条件,用于决定是否要验证这个类型的对象,如果你很清楚某个类型内部不会再有验证特性,可以在这里阻止无用的递归。笔者已经在内部排除了大部分已知的无需深入验证的内置类型,例如intList<T>之类的基本类型和内部不会再有其他直接或间接存在验证特性标记的属性的复杂类型。这里的List<T>中不会继续深入验证是指这个类型本身的属性,例如Count,如果T类型是可能有验证标记的类型是会正常验证的,如果直接继承List<T>再添加自己的新属性也可以正常验证。

对象图的验证结果可能来自深层对象,因此需要一种方法来保留这种结构信息以提供更有价值的验证结果。此处笔者参考Blazor的验证器做了一个更符合这里的需求的版本。如果要重写值类型的相等性判断,则需要谨慎,否则可能出现问题。

public sealed class FieldIdentifier : IEquatable<FieldIdentifier>
{
    private static readonly object TopLevelObjectFaker = new();

    public static FieldIdentifier GetFakeTopLevelObjectIdentifier(string fieldName)
    {
        return new(TopLevelObjectFaker, fieldName, null);
    }

    public FieldIdentifier(object model, string fieldName, FieldIdentifier? modelOwner)
    {
        Model = model ?? throw new ArgumentNullException(nameof(model));

        CheckTopLevelObjectFaker(model, modelOwner);

        // Note that we do allow an empty string. This is used by some validation systems
        // as a place to store object-level (not per-property) messages.
        FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));

        ModelOwner = modelOwner;
    }

    public FieldIdentifier(object model, int enumerableElementIndex, FieldIdentifier? modelOwner)
    {
        Model = model ?? throw new ArgumentNullException(nameof(model));

        CheckTopLevelObjectFaker(model, modelOwner);

        if (enumerableElementIndex < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(enumerableElementIndex), "The index must be great than or equals 0.");
        }

        EnumerableElementIndex = enumerableElementIndex;

        ModelOwner = modelOwner;
    }

    private static void CheckTopLevelObjectFaker(object model, FieldIdentifier? modelOwner)
    {
        if (model == TopLevelObjectFaker && modelOwner is not null)
        {
            throw new ArgumentException($"{nameof(modelOwner)} must be null when {nameof(model)} is {nameof(TopLevelObjectFaker)}", nameof(modelOwner));
        }
    }

    public object Model { get; }

    public bool ModelIsCopiedInstanceOfValueType => Model.GetType().IsValueType;

    public bool ModelIsTopLevelFakeObject => Model == TopLevelObjectFaker;

    public string? FieldName { get; }

    public int? EnumerableElementIndex { get; }

    public FieldIdentifier? ModelOwner { get; }

    /// <inheritdoc />
    public override int GetHashCode()
    {
        // We want to compare Model instances by reference. RuntimeHelpers.GetHashCode returns identical hashes for equal object references (ignoring any `Equals`/`GetHashCode` overrides) which is what we want.
        var modelHash = RuntimeHelpers.GetHashCode(Model);
        var fieldHash = FieldName is null ? 0 : StringComparer.Ordinal.GetHashCode(FieldName);
        var indexHash = EnumerableElementIndex ?? 0;
        var ownerHash = RuntimeHelpers.GetHashCode(ModelOwner);
        return (modelHash, fieldHash, indexHash, ownerHash).GetHashCode();
    }

    /// <inheritdoc />
    public override bool Equals(object? obj)
        => obj is FieldIdentifier otherIdentifier
        && Equals(otherIdentifier);

    /// <inheritdoc />
    public bool Equals(FieldIdentifier? otherIdentifier)
    {
        return (ReferenceEquals(otherIdentifier?.Model, Model) || Equals(otherIdentifier?.Model, Model))
            && string.Equals(otherIdentifier?.FieldName, FieldName, StringComparison.Ordinal)
            && Nullable.Equals(otherIdentifier?.EnumerableElementIndex, EnumerableElementIndex)
            && ReferenceEquals(otherIdentifier?.ModelOwner, ModelOwner);
    }

    /// <inheritdoc/>
    public static bool operator ==(FieldIdentifier? left, FieldIdentifier? right)
    {
        if (left is not null) return left.Equals(right);
        if (right is not null) return right.Equals(left);
        return Equals(left, right);
    }

    /// <inheritdoc/>
    public static bool operator !=(FieldIdentifier? left, FieldIdentifier? right) => !(left == right);

    /// <inheritdoc/>
    public override string? ToString()
    {
        if (ModelIsTopLevelFakeObject) return FieldName;

        var sb = new StringBuilder();
        var fieldIdentifier = this;
        var chainHasTopLevelFaker = false;
        do
        {
            sb.Insert(0, fieldIdentifier.FieldName is not null ? $".{fieldIdentifier.FieldName}" : $"[{fieldIdentifier.EnumerableElementIndex}]");

            if (chainHasTopLevelFaker is false && fieldIdentifier.ModelIsTopLevelFakeObject) chainHasTopLevelFaker = true;

            fieldIdentifier = fieldIdentifier.ModelOwner;

        } while (fieldIdentifier != null && !fieldIdentifier.ModelIsTopLevelFakeObject);

        if (fieldIdentifier is null && !chainHasTopLevelFaker) sb.Insert(0, "$");
        else if (fieldIdentifier is { ModelIsTopLevelFakeObject: true }) sb.Insert(0, fieldIdentifier.FieldName);

        return sb.ToString();
    }
}

这里有一个专门用来表示函数参数名或者本地变量名的特殊对象,这个特殊对象只能是根对象,如果没有这个特殊对象做根,根对象会用$符号表示。对于值类型的对象,可以通过属性知道这里保存的对象是复制版,修改这里保存的对象可能无法反馈到原始数据。Blazor则是简单粗暴地在发现传入对象是结构体时直接抛出异常。

最小API自动验证

对于.NET6.0 来说,自己在API端点用对象图验证器验证一下就可以了,也只能这么做。但是 .NET7.0 为最小API也添加了过滤器功能,这使得模拟MVC的自动验证机制成为可能。本来这篇文章应该在将近半个月前发布的,就是因为偶然看见 .NET7.0 增加了最小API过滤器,笔者临时决定一不做二不休,把自动验证的管道过滤器也一并做完再发。

MinimalApis.Extensions是官方文档引用的过滤器应用示例,因此也是下载量比较大的扩展库,另外也有一个基于FluentValidation的。但是笔者的验证功能已经完全自己实现,想要接入进去也不可能了,而且这些库都没有还原出MVC的使用体验。既然如此,干脆完全重写一个更接近MVC体验的算了。

在此,笔者规划实现以下MVC功能:

  • 自动验证绑定参数:包括复杂对象、简单值和多参数。
  • 可选的自动验证错误响应:普通控制器在验证失败时会继续执行控制器动作,而API控制器会自动返回验证错误。
  • 手动通过代码获取验证结果。
  • 手动重新验证参数。
  • 本地化验证错误信息。

可能是这个野心太大,本来以为两三天能搞定的,结果在被无尽的bug折磨中将近半个月就过去了。好在这些时间没有白花,预定目标全部实现。

类似MVC,这个验证过滤器也是基于模型元数据的,但是是笔者自己写的独立版本。因为MVC的元数据系统太复杂,而且最小API本来就是为不想用MVC这种复杂框架的人准备的精简版功能,现在回过去依赖MVC的功能也不好。这个元数据系统的核心是保存绑定参数的名字,附加的验证特性和本地化特性等信息。

端点参数验证元数据

internal sealed class EndpointBindingParametersValidationMetadata : IReadOnlyDictionary<string, ParameterValidationMetadata>
{
    private readonly MethodInfo _endpointMethod;
    private readonly IReadOnlyDictionary<string, ParameterValidationMetadata> _metadatas;

    public MethodInfo EndpointMethod => _endpointMethod;

    public EndpointBindingParametersValidationMetadata(MethodInfo endpointMethod, params IEnumerable<ParameterValidationMetadata> metadatas)
    {
        ArgumentNullException.ThrowIfNull(endpointMethod);

        Dictionary<string, ParameterValidationMetadata> tempMetadatas = [];
        HashSet<string> names = [];
        foreach (var metadata in metadatas)
        {
            if (!names.Add(metadata.ParameterName)) throw new ArgumentException("metadata's parameter name must be unique.", nameof(metadatas));

            tempMetadatas.Add(metadata.ParameterName, metadata);
        }

        _metadatas = tempMetadatas.AsReadOnly();
        _endpointMethod = endpointMethod;
    }

    public async ValueTask<Dictionary<string, ValidationResultStore>?> ValidateAsync(IDictionary<string, object?> arguments, CancellationToken cancellationToken = default)
    {
        Dictionary<string, ValidationResultStore> result = [];
        foreach (var argument in arguments)
        {
            if (!_metadatas.TryGetValue(argument.Key, out var metadata))
            {
                throw new InvalidOperationException($"Parameter named {argument.Key} does not exist.");
            }

            var argumentResults = await metadata.ValidateAsync(argument.Value, cancellationToken);
            if (argumentResults is not null) result.TryAdd(metadata.ParameterName, argumentResults);
        }
        return result.Count > 0 ? result : null;
    }

    public IEnumerable<string> Keys => _metadatas.Keys;

    public IEnumerable<ParameterValidationMetadata> Values => _metadatas.Values;

    public int Count => _metadatas.Count;

    public ParameterValidationMetadata this[string key] => _metadatas[key];

    public bool ContainsKey(string key) => _metadatas.ContainsKey(key);

    public bool TryGetValue(
        string key,
        [MaybeNullWhen(false)] out ParameterValidationMetadata value)
        => _metadatas.TryGetValue(key, out value);

    public IEnumerator<KeyValuePair<string, ParameterValidationMetadata>> GetEnumerator() => _metadatas.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    internal sealed class ParameterValidationMetadata
    {
        private ParameterInfo _parameterInfo;
        private string? _displayName;
        private RequiredAttribute? _requiredAttribute;
        private ImmutableList<ValidationAttribute> _otherValidationAttributes;

        public ParameterValidationMetadata(ParameterInfo parameterInfo)
        {
            _parameterInfo = parameterInfo ?? throw new ArgumentNullException(nameof(parameterInfo));

            if (string.IsNullOrEmpty(parameterInfo.Name)) throw new ArgumentException("Parameter must be have name.", nameof(parameterInfo));

            _displayName = parameterInfo.GetCustomAttribute<DisplayAttribute>()?.Name
                ?? parameterInfo.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;

            _requiredAttribute = parameterInfo.GetCustomAttribute<RequiredAttribute>();
            _otherValidationAttributes = parameterInfo
                .GetCustomAttributes<ValidationAttribute>()
                .Where(attr => attr is not RequiredAttribute)
                .ToImmutableList();
        }

        public string ParameterName => _parameterInfo.Name!;

        public string? DisplayName => _displayName;

        public ParameterInfo Parameter => _parameterInfo;

        public async ValueTask<ValidationResultStore?> ValidateAsync(object? argument, CancellationToken cancellationToken = default)
        {
            if (argument is not null && !argument.GetType().IsAssignableTo(_parameterInfo.ParameterType))
            {
                throw new InvalidCastException($"Object cannot assign to {ParameterName} of type {_parameterInfo.ParameterType}.");
            }

            var topName = ParameterName ?? $"<argumentSelf({argument?.GetType()?.Name})>";
            ValidationResultStore resultStore = new();
            List<ValidationResult> results = [];

            var validationContext = new ValidationContext(argument ?? new())
            {
                MemberName = ParameterName
            };

            if (DisplayName is not null) validationContext.DisplayName = DisplayName;

            // 验证位于参数上的特性
            if (argument is null && _requiredAttribute is not null)
            {
                var result = _requiredAttribute.GetValidationResult(argument, validationContext)!;
                result = new LocalizableValidationResult(result.ErrorMessage, result.MemberNames, _requiredAttribute, validationContext);
                results.Add(result);
            }

            if (argument is not null)
            {
                foreach (var validation in _otherValidationAttributes)
                {
                    if (validation is AsyncValidationAttribute asyncValidation)
                    {
                        var result = await asyncValidation.GetValidationResultAsync(argument, validationContext, cancellationToken);
                        if (result != ValidationResult.Success)
                        {
                            result = new LocalizableValidationResult(result!.ErrorMessage, result.MemberNames, validation, validationContext);
                            results.Add(result);
                        }
                    }
                    else
                    {
                        var result = validation.GetValidationResult(argument, validationContext);
                        if (result != ValidationResult.Success)
                        {
                            result = new LocalizableValidationResult(result!.ErrorMessage, result.MemberNames, validation, validationContext);
                            results.Add(result);
                        }
                    }
                }

                // 验证对象内部的特性
                await ObjectGraphValidator.TryValidateObjectAsync(
                    argument,
                    new ValidationContext(argument),
                    resultStore,
                    true,
                    static type => !IsRequestDelegateFactorySpecialBoundType(type),
                    topName,
                    cancellationToken);
            }

            if (results.Count > 0)
            {
                var id = FieldIdentifier.GetFakeTopLevelObjectIdentifier(topName);
                resultStore.Add(id, results);
            }

            return resultStore.Any() ? resultStore : null;
        }
    }
}

internal static bool IsRequestDelegateFactorySpecialBoundType(Type type) =>
    type.IsAssignableTo(typeof(HttpContext))
    || type.IsAssignableTo(typeof(HttpRequest))
    || type.IsAssignableTo(typeof(HttpResponse))
    || type.IsAssignableTo(typeof(ClaimsPrincipal))
    || type.IsAssignableTo(typeof(CancellationToken))
    || type.IsAssignableTo(typeof(IFormFile))
    || type.IsAssignableTo(typeof(IEnumerable<IFormFile>))
    || type.IsAssignableTo(typeof(Stream))
    || type.IsAssignableTo(typeof(PipeReader));

端点过滤器工厂

需要在端点构建阶段使用端点信息生成参数验证元数据并保存到端点元数据备用,再通过元数据生成结果决定是否需要添加验证过滤器,端点过滤器工厂刚好能实现这个目的。

public static EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> AddEndpointParameterDataAnnotations<TBuilder>(
    this TBuilder endpointConvention)
    where TBuilder : IEndpointConventionBuilder
{
    endpointConvention.Add(static endpointBuilder =>
    {
        var loggerFactory = endpointBuilder.ApplicationServices.GetRequiredService<ILoggerFactory>();
        var logger = loggerFactory.CreateLogger(_filterLoggerName);

        // 排除MVC端点
        if (endpointBuilder.Metadata.Any(static md => md is ActionDescriptor))
        {
            logger.LogDebug("Cannot add parameter data annotations validation filter to MVC controller or Razor pages endpoint {actionName}.", endpointBuilder.DisplayName);
            return;
        }

        // 检查重复注册自动验证过滤器
        if (endpointBuilder.Metadata.Any(static md => md is EndpointBindingParametersValidationMetadata))
        {
            logger.LogDebug("Already has a parameter data annotations validation filter on endpoint {actionName}.", endpointBuilder.DisplayName);
            return;
        }

        if (endpointBuilder.Metadata.Any(static md => md is EndpointBindingParametersValidationMetadataMark))
        {
            logger.LogDebug("Already called method AddEndpointParameterDataAnnotations before on endpoint {actionName}.", endpointBuilder.DisplayName);
            return;
        }

        // 标记自动验证过滤器已经注册
        endpointBuilder.Metadata.Add(new EndpointBindingParametersValidationMetadataMark());

        endpointBuilder.FilterFactories.Add((filterFactoryContext, next) =>
        {
            var loggerFactory = filterFactoryContext.ApplicationServices.GetRequiredService<ILoggerFactory>();
            var logger = loggerFactory.CreateLogger(_filterLoggerName);

            var parameters = filterFactoryContext.MethodInfo.GetParameters();

            // 查找绑定参数,记录索引备用
            var isServicePredicate = filterFactoryContext.ApplicationServices.GetService<IServiceProviderIsService>();
            List<int> bindingParameterIndexs = new(parameters.Length);
            for (int i = 0; i < parameters.Length; i++)
            {
                ParameterInfo? parameter = parameters[i];
                if (IsRequestDelegateFactorySpecialBoundType(parameter.ParameterType)) continue;
                if (parameter.GetCustomAttribute<FromServicesAttribute>() is not null) continue;
#if NET8_0_OR_GREATER
                if (parameter.GetCustomAttribute<FromKeyedServicesAttribute>() is not null) continue;
#endif
                if (isServicePredicate?.IsService(parameter.ParameterType) is true) continue;

                bindingParameterIndexs.Add(i);
            }

            if (bindingParameterIndexs.Count is 0)
            {
                logger.LogDebug("Route handler method '{methodName}' does not contain any validatable parameters, skipping adding validation filter.", filterFactoryContext.MethodInfo.Name);
            }

            // 构建参数模型验证元数据添加到端点元数据集合
            EndpointBindingParametersValidationMetadata? validationMetadata;
            try
            {
                List<ParameterValidationMetadata> bindingParameters = new(bindingParameterIndexs.Count);
                foreach (var argumentIndex in bindingParameterIndexs)
                {
                    bindingParameters.Add(new(parameters[argumentIndex]));
                }
                validationMetadata = new(filterFactoryContext.MethodInfo, bindingParameters);
            }
            catch (Exception e)
            {
                validationMetadata = null;
                logger.LogError(e, "Build parameter validation metadate failed for route handler method '{methodName}', skipping adding validation filter.", filterFactoryContext.MethodInfo.Name);
            }

            if (validationMetadata?.Any() is not true) return invocationContext => next(invocationContext);

            endpointBuilder.Metadata.Add(validationMetadata);

            // 一切顺利,注册验证过滤器
            return async invocationContext =>
            {
                var endpoint = invocationContext.HttpContext.GetEndpoint();
                var metadata = endpoint?.Metadata
                    .FirstOrDefault(static md => md is EndpointBindingParametersValidationMetadata) as EndpointBindingParametersValidationMetadata;

                if (metadata is null) return await next(invocationContext);

                Dictionary<string, object?> arguments = new(bindingParameterIndexs.Count);
                foreach (var argumentIndex in bindingParameterIndexs)
                {
                    arguments.Add(parameters[argumentIndex].Name!, invocationContext.Arguments[argumentIndex]);
                }

                try
                {
                    var results = await metadata.ValidateAsync(arguments);
                    if (results != null) invocationContext.HttpContext.Items.Add(_validationResultItemName, results);
                }
                catch (Exception e)
                {
                    logger.LogError(e, "Validate parameter failed for route handler method '{methodName}'.", filterFactoryContext.MethodInfo.Name);
                }

                return await next(invocationContext);
            };
        });
    });

    return new(endpointConvention);
}

public sealed class EndpointBindingParametersValidationMetadataMark;

这里返回特殊类型的构造器并用作错误结果过滤器的参数,确保错误结果过滤器只能在参数验证过滤器之后注册。

自动验证错误返回过滤器

public static TBuilder AddValidationProblemResult<TBuilder>(
    this EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> validationEndpointBuilder,
    int statusCode = StatusCodes.Status400BadRequest)
    where TBuilder : IEndpointConventionBuilder
{
    validationEndpointBuilder.InnerBuilder.Add(endpointBuilder =>
    {
        var loggerFactory = endpointBuilder.ApplicationServices.GetRequiredService<ILoggerFactory>();
        var logger = loggerFactory.CreateLogger(_filterLoggerName);

        // 检查 OpenAPI 元数据是否存在
        if (!endpointBuilder.Metadata.Any(static md =>
            md is IProducesResponseTypeMetadata pr
            && (pr.Type?.IsAssignableTo(typeof(HttpValidationProblemDetails))) is true)
        )
        {
            // 添加 OpenAPI 元数据
            endpointBuilder.Metadata.Add(
                new ProducesResponseTypeMetadata(
                    statusCode,
                    typeof(HttpValidationProblemDetails),
                    ["application/problem+json", "application/json"]
                )
            );
        }

        // 检查重复注册自动验证错误返回过滤器
        if (endpointBuilder.Metadata.Any(static md => md is EndpointParameterDataAnnotationsValidationProblemResultMark))
        {
            logger.LogDebug("Already has a parameter data annotations validation problem result filter on endpoint {actionName}.", endpointBuilder.DisplayName);
            return;
        }

        // 标记自动验证错误返回过滤器已经注册
        endpointBuilder.Metadata.Add(new EndpointParameterDataAnnotationsValidationProblemResultMark());

        endpointBuilder.FilterFactories.Add(static (filterFactoryContext, next) =>
        {
            return async invocationContext =>
            {
                var errors = invocationContext.HttpContext.GetEndpointParameterDataAnnotationsProblemDetails();

                if (errors is { Count: > 0 }) return Results.ValidationProblem(errors);
                else return await next(invocationContext);
            };
        });
    });

    return validationEndpointBuilder.InnerBuilder;
}

public static TBuilder RestoreToOriginalBuilder<TBuilder>(this EndpointParameterDataAnnotationsRouteHandlerBuilder<TBuilder> validationEndpointBuilder)
    where TBuilder : IEndpointConventionBuilder
{
    return validationEndpointBuilder.InnerBuilder;
}

public sealed class EndpointParameterDataAnnotationsValidationProblemResultMark;

注册错误结果过滤器后返回原始构造器,可用于继续注册其他东西。或者用专门的辅助方法直接还原,跳过注册错误结果过滤器。

手动重新验证参数

public static async Task<bool> TryValidateEndpointParametersAsync(
    this HttpContext httpContext,
    params IEnumerable<KeyValuePair<string, object?>> arguments)
{
     ArgumentNullException.ThrowIfNull(httpContext);

    var metadata = httpContext.GetEndpointBindingParameterValidationMetadata();
    if (metadata is null) return false;

    if (!arguments.Any()) throw new ArgumentException("There are no elements in the sequence.", nameof(arguments));

    HashSet<string> names = [];
    foreach (var name in arguments.Select(arg => arg.Key))
    {
        if (string.IsNullOrEmpty(name)) throw new ArgumentException("Argument's name cannot be null or empty.", nameof(arguments));
        if (!names.Add(name)) throw new ArgumentException("Argument's name must be unique.", nameof(arguments));
    }

    var currentResults = httpContext.GetEndpointParameterDataAnnotationsValidationResultsCore();
    var newResults = await metadata.ValidateAsync(arguments.ToDictionary(arg => arg.Key, arg => arg.Value));

    if (newResults is null) // 本次验证结果没有任何错误
    {
        if (currentResults != null)
        {
            // 移除本次验证结果中没有验证错误数据的参数项
            foreach (var argument in arguments) currentResults.Remove(argument.Key);
            // 如果移除后变成空集,直接清除结果集
            if (currentResults.Count is 0) httpContext.Items.Remove(_validationResultItemName);
        }
    }
    else
    {
        if (currentResults != null)
        {
            // 如果上次的验证结果中有同名参数的数据,但本次验证结果中没有,移除该参数的过时的旧结果数据
            foreach (var argument in arguments)
            {
                if (!newResults.Keys.Any(key => key == argument.Key)) currentResults.Remove(argument.Key);
            }
        }
        else
        {
            // 上次验证结果显示没有任何错误,新建错误结果集
            httpContext.Items.Remove(_validationResultItemName);

            currentResults = [];
            httpContext.Items.Add(_validationResultItemName, currentResults);
        }
        // 添加上次验证中没有错误数据的参数项,或者更新同名参数项的验证错误数据
        foreach (var newResult in newResults) currentResults[newResult.Key] = newResult.Value;
    }

    return true;
}

可以说构建元数据的根本目的就是为了支持手动重新验证,MVC的手动重新验证同样也是依靠元数据系统。确实是个好想法,借鉴过来用。

获取验证结果

internal static Dictionary<string, ValidationResultStore>? GetEndpointParameterDataAnnotationsValidationResultsCore(this HttpContext httpContext)
{
    ArgumentNullException.ThrowIfNull(httpContext);

    httpContext.Items.TryGetValue(_validationResultItemName, out var result);
    return result as Dictionary<string, ValidationResultStore>;
}

public static EndpointArgumentsValidationResults? GetEndpointParameterDataAnnotationsValidationResults(this HttpContext httpContext)
{
    var results = httpContext.GetEndpointParameterDataAnnotationsValidationResultsCore();
    if (results is null) return null;

    return new(results
        .ToDictionary(
            static r => r.Key,
            static r => new ArgumentPropertiesValidationResults(r.Value.ToDictionary(
                static fr => fr.Key.ToString()!,
                static fr => fr.Value.ToImmutableList())
            )
        )
    );
}

获取本地化的验证错误消息

我们知道MVC的本地化功能非常灵活,支持消息模板,能在最大程度上使本地化资源通用。但是不同的验证特性的消息模板占位符不尽相同,除了0号占位符统一表示属性名以外,其他占位符的数量是不确定的。因此MVC框架使用了一套适配器来允许开发者自行开有发针对性的消息生成,同时为内置验证特性准备了一组适配器。笔者也借鉴这套适配器系统开发了一个简易版,并内置了官方现有验证特性的适配器。主要区别是这套适配器没有客户端验证相关的功能。

public interface IAttributeAdapter
{
    Type CanProcessAttributeType { get; }

    object[]? GetLocalizationArguments(ValidationAttribute attribute);
}

public abstract class AttributeAdapterBase<TAttribute> : IAttributeAdapter
    where TAttribute : ValidationAttribute
{
    public Type CanProcessAttributeType => typeof(TAttribute);

    public object[]? GetLocalizationArguments(ValidationAttribute attribute)
    {
        return GetLocalizationArgumentsInternal((TAttribute)attribute);
    }

    protected abstract object[]? GetLocalizationArgumentsInternal(TAttribute attribute);
}

public sealed class RangeAttributeAdapter : AttributeAdapterBase<RangeAttribute>
{
    protected override object[]? GetLocalizationArgumentsInternal(RangeAttribute attribute)
    {
        return [attribute.Minimum, attribute.Maximum];
    }
}

有了消息模板占位符参数后,剩下的就好办了。

public static Dictionary<string, string[]>? GetEndpointParameterDataAnnotationsProblemDetails(this HttpContext httpContext)
{
    Dictionary<string, string[]>? result = null;

    var validationResult = httpContext.GetEndpointParameterDataAnnotationsValidationResultsCore();
    if (validationResult?.Any(vrp => vrp.Value.Any()) is true)
    {
        var localizerFactory = httpContext.RequestServices.GetService<IStringLocalizerFactory>();

        EndpointParameterValidationLocalizationOptions? localizationOptions = null;
        AttributeLocalizationAdapters? adapters = null;
        if (localizerFactory != null)
        {
            localizationOptions = httpContext.RequestServices
                .GetService<IOptions<EndpointParameterValidationLocalizationOptions>>()
                ?.Value;

            adapters = localizationOptions?.Adapters;
        }

        var metadatas = httpContext.GetEndpointBindingParameterValidationMetadata();
        Debug.Assert(metadatas != null);
        var endpointHandlerType = metadatas.EndpointMethod.ReflectedType;
        Debug.Assert(endpointHandlerType != null);

        var errors = validationResult.SelectMany(vrp => vrp.Value);
        result = localizerFactory is null || !(adapters?.Count > 0)
            ? errors
                .ToDictionary(
                    static fvr => fvr.Key.ToString()!,
                    static fvr => fvr.Value.Select(ToErrorMessage).ToArray()
                )
            : errors
                .ToDictionary(
                    static fvr => fvr.Key.ToString()!,
                    fvr => fvr.Value
                        .Select(vr => 
                            ToLocalizedErrorMessage(
                                vr,
                                fvr.Key.ModelIsTopLevelFakeObject
                                    ? new KeyValuePair<Type, ParameterValidationMetadata>(
                                        endpointHandlerType,
                                        (metadatas?.TryGetValue(fvr.Key.FieldName!, out var metadata)) is true
                                            ? metadata
                                            : null! /* never null */)
                                    : null,
                                adapters,
                                localizerFactory
                            )
                        )
                        .ToArray()
                );
    }

    return result;

    static string ToErrorMessage(ValidationResult result)
    {
        return result.ErrorMessage!;
    }

    string ToLocalizedErrorMessage(
        ValidationResult result,
        KeyValuePair<Type, ParameterValidationMetadata>? parameterMetadata,
        AttributeLocalizationAdapters adapters,
        IStringLocalizerFactory localizerFactory)
    {
        if (result is LocalizableValidationResult localizable)
        {
            var localizer = localizerFactory.Create(localizable.InstanceObjectType);

            string displayName;
            if (!string.IsNullOrEmpty(parameterMetadata?.Value.DisplayName))
            {
                var parameterLocalizer = localizerFactory.Create(parameterMetadata.Value.Key);
                displayName = parameterLocalizer[parameterMetadata.Value.Value.DisplayName];
            }
            else displayName = GetDisplayName(localizable, localizer);

            var adapter = adapters.FirstOrDefault(ap => localizable.Attribute.GetType().IsAssignableTo(ap.CanProcessAttributeType));
            if (adapter != null
                && !string.IsNullOrEmpty(localizable.Attribute.ErrorMessage)
                && string.IsNullOrEmpty(localizable.Attribute.ErrorMessageResourceName)
                && localizable.Attribute.ErrorMessageResourceType == null)
            {
                return localizer
                [
                    localizable.Attribute.ErrorMessage,
                    [displayName, .. adapter.GetLocalizationArguments(localizable.Attribute) ?? []]
                ];
            }

            return localizable.Attribute.FormatErrorMessage(displayName);
        }

        return result.ErrorMessage!;

        static string GetDisplayName(LocalizableValidationResult localizable, IStringLocalizer localizer)
        {
            string? displayName = null;
            ValidationAttributeStore store = ValidationAttributeStore.Instance;
            DisplayAttribute? displayAttribute = null;
            DisplayNameAttribute? displayNameAttribute = null;

            if (string.IsNullOrEmpty(localizable.MemberName))
            {
                displayAttribute = store.GetTypeDisplayAttribute(localizable.Context);
                displayNameAttribute = store.GetTypeDisplayNameAttribute(localizable.Context);
            }
            else if (store.IsPropertyContext(localizable.Context))
            {
                displayAttribute = store.GetPropertyDisplayAttribute(localizable.Context);
                displayNameAttribute = store.GetPropertyDisplayNameAttribute(localizable.Context);
            }

            if (displayAttribute != null)
            {
                displayName = displayAttribute.GetName();
            }
            else if (displayNameAttribute != null)
            {
                displayName = displayNameAttribute.DisplayName;
            }

            return string.IsNullOrEmpty(displayName)
                ? localizable.DisplayName
                : localizer[displayName];
        }
    }
}

本地化错误消息可以说是整个工程里最麻烦的部分,为此甚至改造了一番对象图验证器。因为MVC根本没有使用验证器,而是直接使用原始验证特性,这意味着MVC可以获得最原始的信息进行任何自定义处理。为了做到同样的效果,必须让对象图验证器也返回包含原始信息的验证结果。其中的关键就是LocalizableValidationResult,这个新类型保存了本地化所需的一切原始信息。对象图验证器会在获得验证结果后将原始结果重新包装一次,因此这些信息在非MVC环境下同样可用。

还有就是针对参数本身的特殊处理,参数名和参数上的特性和对象内部的特性无法通用一套逻辑,必须分别处理。为此又把事情给弄麻烦了一些,也算是再次感受到MVC的元数据系统的强大。

基本使用方法

Nuget包中有一个比较完整的示例说明,也比较长,就不赘述了,可以到这里查看使用说明

结语

这真是一次漫长的开发之旅,一开始就对开发难度估计不足,把自己坑进去一个多星期,然后半路临时追加新功能,又被坑进去一个多星期。还好前期开发的功能比较严谨,为临时功能做改造还算顺利。也算是好好体会了一把严谨设计的威力,跟着微软的代码照猫画虎是真的能提前避开很多坑啊。

QQ群

读者交流QQ群:540719365
image

欢迎读者和广大朋友一起交流,如发现本书错误也欢迎通过博客园、QQ群等方式告知笔者。

本文地址:如何优雅地让 ASP.NET Core 支持异步模型验证

posted @ 2024-12-16 10:20  coredx  阅读(333)  评论(1编辑  收藏  举报