Abp vNext异常处理

发布时间 2023-12-13 10:44:04作者: .NET好耶

Abp vNext异常处理

使用Abp vNext 6.0

先来看看官方说的

当满足下面任意一个条件时,AbpExceptionFilter 会处理此异常:
* 当controller action方法返回类型是object result(而不是view result)并有异常抛出时.
* 当一个请求为AJAX(Http请求头中X-Requested-With为XMLHttpRequest)时.
* 当客户端接受的返回类型为application/json(Http请求头中accept 为application/json)时.
如果异常被处理过,则会自动记录日志并将格式化的JSON消息返回给客户端.
  • 首先,第一条就有问题,如果我抛出了异常,throw new Exception("XXX")那就不能返回object result了,似乎只有BusinessExceptionUserFriendlyException这种apb框架的异常才能自动捕获,所以这个很扯淡,在abp的源码里面似乎用了AbpExceptionHandlingMiddleware这个中间件来捕获异常来写日志
    这个中间件也可以配置,因为我打算重写,所以就懒得去看这个了
services.Configure<AbpExceptionHandlingOptions>(options =>
{

});
  • 然后是application/json的问题,实际上前端没问题,但是postman默认给的是*/*,这就不方便测试了
  • 还有controller action的问题,比如DataAnnotations字段验证的异常,这个在dto里面可以捕获到,但是给属性或函数参数加这个就是抛出异常了
  • BusinessExceptionUserFriendlyException这种异常也不是真的异常,因为状态码不一定是500,这俩自带的状态码字段是字符串,并不是webapi自带的枚举

throw

简单测试一下异常捕获,postman的Accept要改成application/json才能返回json格式的异常

Exception

public IActionResult Tset()
{
    throw new Exception("错误消息");
    return Ok();
}

状态码500

{
    "error": {
        "code": null,
        "message": "An internal error occurred during your request!",
        "details": null,
        "data": {},
        "validationErrors": null
    }
}

UserFriendlyException

public IActionResult Tset()
{
    throw new UserFriendlyException("错误消息");
    return Ok();
}

状态码403

{
    "error": {
        "code": null,
        "message": "错误消息",
        "details": null,
        "data": {},
        "validationErrors": null
    }
}

BusinessException

public IActionResult Tset()
{
    throw new BusinessException("错误消息");
    return Ok();
}

状态码403

{
    "error": {
        "code": "错误消息",
        "message": "An internal error occurred during your request!",
        "details": null,
        "data": {},
        "validationErrors": null
    }
}

DataAnnotations

定义一个dto

public class CreateOpenIddictApplicationDto
{
    [Required]
    public string ClientId { get; set; }
    [MinLength(6)]
    [EmailAddress]
    public string ClientSecret { get; set; }
}

加个参数

public IActionResult Tset2([FromBody] CreateOpenIddictApplicationDto input)
{
    return Ok();
}

状态码400,这个其实是.net自带的啦

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-35b0d6a5e66350c32f8e6b4aa929d574-59668175c9fea9cd-00",
    "errors": {
        "ClientId": [
            "The ClientId field is required."
        ],
        "ClientSecret": [
            "The field ClientSecret must be a string or array type with a minimum length of '6'.",
            "The ClientSecret field is not a valid e-mail address."
        ]
    }
}

说实话,用起来属实难受,前端最多就接收一个错误信息,处理500和401就得了,还是重写比较靠谱,这种还是留给mvc玩吧

源码分析

Exception

源码里面是这样调用中间件的

private const string ExceptionHandlingMiddlewareMarker = "_AbpExceptionHandlingMiddleware_Added";

public static IApplicationBuilder UseAbpExceptionHandling(this IApplicationBuilder app)
{
    if (app.Properties.ContainsKey(ExceptionHandlingMiddlewareMarker))
    {
        return app;
    }

    app.Properties[ExceptionHandlingMiddlewareMarker] = true;
    return app.UseMiddleware<AbpExceptionHandlingMiddleware>();
}

public static IApplicationBuilder UseUnitOfWork(this IApplicationBuilder app)
{
    return app
        .UseAbpExceptionHandling()
        .UseMiddleware<AbpUnitOfWorkMiddleware>();
}

所以,如果我们要自定义异常中间件,要在app.UseUnitOfWork()之前,并且把app.Properties["_AbpExceptionHandlingMiddleware_Added"]设置为true

那么我们还需要找到AbpExceptionHandlingMiddleware,至于这个中间件的作用,就是把异常放到响应里面

await httpContext.Response.WriteAsync(
    jsonSerializer.Serialize(
        new RemoteServiceErrorResponse(
            errorInfoConverter.Convert(exception, options =>
            {
                options.SendExceptionsDetailsToClients = exceptionHandlingOptions.SendExceptionsDetailsToClients;
                options.SendStackTraceToClients = exceptionHandlingOptions.SendStackTraceToClients;
            })
        )
    )
);

这里面就是序列号响应对象,所以重要的在于这个errorInfoConverter.Convert函数
显而易见的,这个转换函数在IExceptionToErrorInfoConverter里面,源码里面只有一个实现类DefaultExceptionToErrorInfoConverter

public RemoteServiceErrorInfo Convert(Exception exception, Action<AbpExceptionHandlingOptions>? options = null)
{
    var exceptionHandlingOptions = CreateDefaultOptions();
    options?.Invoke(exceptionHandlingOptions);

    var errorInfo = CreateErrorInfoWithoutCode(exception, exceptionHandlingOptions);

    if (exception is IHasErrorCode hasErrorCodeException)
    {
        errorInfo.Code = hasErrorCodeException.Code;
    }

    return errorInfo;
}

这里面判断了这个IHasErrorCode接口,显然是BusinessException和UserFriendlyException,并且输出格式与控制台一致

然后是AbpExceptionFilterAbpExceptionPageFilter,这俩是一样的,区别似乎只是webapi和mvc,下面的代码是节选

remoteServiceErrorInfo = exceptionToErrorInfoConverter.Convert(context.Exception, options =>
{
    options.SendExceptionsDetailsToClients = exceptionHandlingOptions.SendExceptionsDetailsToClients;
    options.SendStackTraceToClients = exceptionHandlingOptions.SendStackTraceToClients;
});

context.HttpContext.Response.Headers.Add(AbpHttpConsts.AbpErrorFormat, "true");
context.HttpContext.Response.StatusCode = (int)context
    .GetRequiredService<IHttpExceptionStatusCodeFinder>()
    .GetStatusCode(context.HttpContext, context.Exception);

context.Result = new ObjectResult(new RemoteServiceErrorResponse(remoteServiceErrorInfo));

filter则是在AbpMvcOptionsExtensions中添加

private static void AddActionFilters(MvcOptions options)
{
    options.Filters.AddService(typeof(GlobalFeatureActionFilter));
    options.Filters.AddService(typeof(AbpAuditActionFilter));
    options.Filters.AddService(typeof(AbpNoContentActionFilter));
    options.Filters.AddService(typeof(AbpFeatureActionFilter));
    options.Filters.AddService(typeof(AbpValidationActionFilter));
    options.Filters.AddService(typeof(AbpUowActionFilter));
    options.Filters.AddService(typeof(AbpExceptionFilter));
}

private static void AddPageFilters(MvcOptions options)
{
    options.Filters.AddService(typeof(GlobalFeaturePageFilter));
    options.Filters.AddService(typeof(AbpExceptionPageFilter));
    options.Filters.AddService(typeof(AbpAuditPageFilter));
    options.Filters.AddService(typeof(AbpFeaturePageFilter));
    options.Filters.AddService(typeof(AbpUowPageFilter));
}

public static void AddAbp(this MvcOptions options, IServiceCollection services)
{
    AddConventions(options, services);
    AddActionFilters(options);
    AddPageFilters(options);
    AddModelBinders(options);
    AddMetadataProviders(options, services);
    AddFormatters(options);
}

这个异常捕获其实是很奇怪的,因为abp在AbpExceptionFilter就处理了异常,并且把异常设置为null,所以异常是传不到AbpExceptionHandlingMiddleware里面的

context.Exception = null; //Handled!

DataAnnotations

首先可以猜测,需要IActionFilterIAsyncActionFilter过滤器,.net自带流程嘛,abp有一个AbpValidationActionFilter
AbpValidationActionFilter里面有一句

context.GetRequiredService<IModelStateValidator>().Validate(context.ModelState);

IModelStateValidator的实现

public class ModelStateValidator : IModelStateValidator, ITransientDependency
{
    public virtual void Validate(ModelStateDictionary modelState)
    {
        var validationResult = new AbpValidationResult();

        AddErrors(validationResult, modelState);

        if (validationResult.Errors.Any())
        {
            throw new AbpValidationException(
                "ModelState is not valid! See ValidationErrors for details.",
                validationResult.Errors
            );
        }
    }

    public virtual void AddErrors(IAbpValidationResult validationResult, ModelStateDictionary modelState)
    {
        if (modelState.IsValid)
        {
            return;
        }

        foreach (var state in modelState)
        {
            foreach (var error in state.Value.Errors)
            {
                validationResult.Errors.Add(new ValidationResult(error.ErrorMessage, new[] { state.Key }));
            }
        }
    }
}

抛出AbpValidationException异常,然后AbpExceptionHandlingMiddleware捕获这个异常
emmm,看起来挺真的,但是我关掉.net自带的模型验证后,这个过滤器也是没触发的,实际上没用啦
一开始我认为,AbpValidationActionFilter是IAsyncActionFilter,这个是在IAsyncExceptionFilter之后的,而AbpExceptionFilter是直接捕获异常然后赋值null,AbpExceptionHandlingMiddleware抓不到这个异常
其实更扯的是这个filter根本不会执行,这个filter是IAsyncActionFilter,如果你写一个IActionFilter,按照.net的执行顺序,应该是执行IAsyncActionFilter,IActionFilter就不会执行了,但实际上执行的是IActionFilter,甚至.net自带的ModelStateInvalidFilter都是IActionFilter,所以abp的这个filter就不符合.net了,说明AbpValidationActionFilter压根没用,abp那一堆fitler,有没有用还真不好说

扩展部分

关于数据验证,abp的文档里的验证部分还有提到一个IObjectValidator,这个接口的实现类ObjectValidator里有一段

public class ObjectValidator : IObjectValidator, ITransientDependency
{
    protected IServiceScopeFactory ServiceScopeFactory { get; }
    protected AbpValidationOptions Options { get; }

    public ObjectValidator(IOptions<AbpValidationOptions> options, IServiceScopeFactory serviceScopeFactory)
    {
        ServiceScopeFactory = serviceScopeFactory;
        Options = options.Value;
    }

    public virtual async Task ValidateAsync(object validatingObject, string? name = null, bool allowNull = false)
    {
        var errors = await GetErrorsAsync(validatingObject, name, allowNull);

        if (errors.Any())
        {
            throw new AbpValidationException(
                "Object state is not valid! See ValidationErrors for details.",
                errors
            );
        }
    }

    public virtual async Task<List<ValidationResult>> GetErrorsAsync(object validatingObject, string? name = null, bool allowNull = false)
    {
        if (validatingObject == null)
        {
            if (allowNull)
            {
                return new List<ValidationResult>(); //TODO: Returning an array would be more performent
            }
            else
            {
                return new List<ValidationResult>
                    {
                        name == null
                            ? new ValidationResult("Given object is null!")
                            : new ValidationResult(name + " is null!", new[] {name})
                    };
            }
        }

        var context = new ObjectValidationContext(validatingObject);

        using (var scope = ServiceScopeFactory.CreateScope())
        {
            foreach (var contributorType in Options.ObjectValidationContributors)
            {
                var contributor = (IObjectValidationContributor)
                    scope.ServiceProvider.GetRequiredService(contributorType);
                await contributor.AddErrorsAsync(context);
            }
        }

        return context.Errors;
    }
}

可以看到ObjectValidator是有抛出AbpValidationException异常的,而IObjectValidationContributor负责提供错误信息
IObjectValidationContributor有两个实现类FluentObjectValidationContributorDataAnnotationObjectValidationContributor

IObjectValidator接口被MethodInvocationValidator调用,而IMethodInvocationValidator接口又被ValidationInterceptor调用,一眼拦截器

public class ValidationInterceptor : AbpInterceptor, ITransientDependency
{
    private readonly IMethodInvocationValidator _methodInvocationValidator;

    public ValidationInterceptor(IMethodInvocationValidator methodInvocationValidator)
    {
        _methodInvocationValidator = methodInvocationValidator;
    }

    public override async Task InterceptAsync(IAbpMethodInvocation invocation)
    {
        await ValidateAsync(invocation);
        await invocation.ProceedAsync();
    }

    protected virtual async Task ValidateAsync(IAbpMethodInvocation invocation)
    {
        await _methodInvocationValidator.ValidateAsync(
            new MethodInvocationValidationContext(
                invocation.TargetObject,
                invocation.Method,
                invocation.Arguments
            )
        );
    }
}

从源码看起来,我们可以实现IObjectValidationContributor接口来自定义错误信息,但是这个拦截器的流程其实是有点复杂的,abp的拦截器是Castle DynamicProxy,按理来说abp是可以处理任何地方的DataAnnotations,但是实际测试我并没有发现这个有用,除了dto外的数据验证都是直接抛异常了,而且官方文档这块也啥都没说,所以这个其实相当不靠谱
abp文档里还有一个IValidatableObject,可以用dto实现这个接口来自定义验证或返回验证信息,我试了一下,优先级比.net自带的filter低

实现

Exception

大概有两个思路

  • 重写中间件
  • 实现IExceptionToErrorInfoConverter接口

这两个思路其实是一样的,都是改IExceptionToErrorInfoConverter接口,不过写中间件的话,就是想怎么写就怎么写,实现接口会被返回值RemoteServiceErrorInfo限制,所以我还是重写中间件吧
其实这里还有个问题,我们是需要一个自定义的异常类的,因为我们需要这个异常类来确定这个异常是否属于要展示给前端的,相当于是只展示手动抛出的异常
abp的异常处理是对全部异常的,什么聚合异常、身份验证异常、数据验证异常,还有本地化错误信息,因为这个中间件是处理响应返回值的,这个异常处理的IExceptionToErrorInfoConverter也不进日志,所以这个只对前端,我们只对指定的异常显示消息,其它的异常统一显示"内部异常"就足够了

首先,先声明一个全局异常类GlobalException

public class GlobalException : Exception
{
    public GlobalException() : base()
    {
    }

    public GlobalException(string message) : base(message)
    {
    }
}

如果要让中间件捕获异常,那就先把filter移除

context.Services.Configure<MvcOptions>(options =>
{
    //禁用AbpExceptionFilter
    var abpExceptionFilterService = new ServiceFilterAttribute(typeof(AbpExceptionFilter));
    options.Filters.Remove(abpExceptionFilterService);
});

异常响应信息模型GlobalExceptionResponseInfoModel

public class GlobalExceptionResponseInfoModel
{
    public string Error { get; set; }

    public GlobalExceptionResponseInfoModel()
    {
        this.Error = string.Empty;
    }
}

异常响应模型GlobalExceptionResponseModel

public class GlobalExceptionResponseModel
{
    /// <summary>
    /// 错误信息
    /// </summary>
    public string Error { get; set; }

    public GlobalExceptionResponseModel()
    {
        this.Error = string.Empty;
    }

    public GlobalExceptionResponseModel(GlobalExceptionResponseInfoModel exceptionInfo)
    {
        this.Error = exceptionInfo.Error;
    }
}

假装我们有一个中间件的配置类GlobalExceptionHandlerMiddlewareOptions,这个就是AbpExceptionHandlingOptions

public class GlobalExceptionHandlerMiddlewareOptions
{
    public bool SendExceptionsDetailsToClient { get; set; }

    public bool SendStackTraceToClient { get; set; }

    public GlobalExceptionHandlerMiddlewareOptions()
    {
        SendExceptionsDetailsToClient = false;
        SendStackTraceToClient = true;
    }
}

然后就是全局异常处理中间件GlobalExceptionHandlerMiddleware,这里基本就是照抄AbpExceptionHandlingMiddleware,把最后的响应替换掉就可以了
这个中间件要放到app.UseUnitOfWork()前面

public class GlobalExceptionHandlerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<GlobalExceptionHandlerMiddleware> _logger;
    private readonly Func<object, Task> _clearCacheHeadersDelegate;

    private readonly GlobalExceptionHandlerMiddlewareOptions _option;
    public GlobalExceptionHandlerMiddleware(RequestDelegate next, IOptions<GlobalExceptionHandlerMiddlewareOptions> options, ILogger<GlobalExceptionHandlerMiddleware> logger)
    {
        this._next = next;
        this._option = options.Value;
        this._logger = logger;

        this._clearCacheHeadersDelegate = ClearCacheHeaders;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        try
        {
            await this._next(httpContext);
        }
        catch (Exception exception)
        {
            await HandleAndWrapException(httpContext, exception);
        }
    }

    private async Task HandleAndWrapException(HttpContext httpContext, Exception exception)
    {
        this._logger.LogException(exception);

        await httpContext
            .RequestServices
            .GetRequiredService<IExceptionNotifier>()
            .NotifyAsync(
                new ExceptionNotificationContext(exception)
            );

        if (exception is AbpAuthorizationException)
        {
            await httpContext.RequestServices.GetRequiredService<IAbpAuthorizationExceptionHandler>()
                .HandleAsync(exception.As<AbpAuthorizationException>(), httpContext);
        }
        else
        {
            var statusCodeFinder = httpContext.RequestServices.GetRequiredService<IHttpExceptionStatusCodeFinder>();
            var jsonSerializer = httpContext.RequestServices.GetRequiredService<IJsonSerializer>();
            var exceptionHandlingOptions = httpContext.RequestServices.GetRequiredService<IOptions<GlobalExceptionHandlerMiddlewareOptions>>().Value;

            httpContext.Response.Clear();
            httpContext.Response.StatusCode = (int)statusCodeFinder.GetStatusCode(httpContext, exception);
            httpContext.Response.OnStarting(_clearCacheHeadersDelegate, httpContext.Response);
            httpContext.Response.Headers.Add(AbpHttpConsts.AbpErrorFormat, "true");
            httpContext.Response.Headers.Add("Content-Type", "application/json");

            await httpContext.Response.WriteAsync(jsonSerializer.Serialize(new GlobalExceptionResponseModel(GlobalExceptionResponseConverter.Convert(exception, options =>
            {
                options.SendExceptionsDetailsToClient = exceptionHandlingOptions.SendExceptionsDetailsToClient;
                options.SendStackTraceToClient = exceptionHandlingOptions.SendStackTraceToClient;
            }))));
        }
    }

    private Task ClearCacheHeaders(object state)
    {
        var response = (HttpResponse)state;

        response.Headers[HeaderNames.CacheControl] = "no-cache";
        response.Headers[HeaderNames.Pragma] = "no-cache";
        response.Headers[HeaderNames.Expires] = "-1";
        response.Headers.Remove(HeaderNames.ETag);

        return Task.CompletedTask;
    }
}

最后就是全局异常响应转换器GlobalExceptionResponseConverter

public class GlobalExceptionResponseConverter
{
    public static GlobalExceptionResponseInfoModel Convert(Exception exception, Action<GlobalExceptionHandlerMiddlewareOptions>? options = null)
    {
        var exceptionHandlingOptions = GlobalExceptionResponseConverter.CreateDefaultOptions();
        options?.Invoke(exceptionHandlingOptions);

        var exceptionResponse = GlobalExceptionResponseConverter.CreateExceptionResponseFromException(exception, exceptionHandlingOptions);
        return exceptionResponse;
    }

    /// <summary>
    /// 创建异常响应--错误信息
    /// </summary>
    /// <param name="exception"></param>
    /// <param name="options"></param>
    /// <returns></returns>
    private static GlobalExceptionResponseInfoModel CreateExceptionResponseFromException(Exception exception, GlobalExceptionHandlerMiddlewareOptions options)
    {
        if (true == options.SendExceptionsDetailsToClient)
        {
            return GlobalExceptionResponseConverter.CreateDetailExceptionResponseFromException(exception, options.SendStackTraceToClient);
        }

        GlobalExceptionResponseInfoModel exceptionResponse = new GlobalExceptionResponseInfoModel();
        if (exception is GlobalException)
        {
            exceptionResponse.Error = exception.Message;
        }
        else
        {
            exceptionResponse.Error = "内部错误";
        }

        return exceptionResponse;
    }

    /// <summary>
    /// 创建异常响应--详细错误信息
    /// </summary>
    /// <param name="exception"></param>
    /// <param name="sendStackTraceToClient"></param>
    /// <returns></returns>
    private static GlobalExceptionResponseInfoModel CreateDetailExceptionResponseFromException(Exception exception, bool sendStackTraceToClient)
    {
        var detailBuilder = new StringBuilder();

        GlobalExceptionResponseConverter.AddExceptionToDetails(exception, detailBuilder, sendStackTraceToClient);

        GlobalExceptionResponseInfoModel exceptionResponse = new GlobalExceptionResponseInfoModel();
        exceptionResponse.Error = detailBuilder.ToString();

        return exceptionResponse;
    }

    /// <summary>
    /// 添加详细异常信息到StringBuilder
    /// </summary>
    /// <param name="exception"></param>
    /// <param name="detailBuilder"></param>
    /// <param name="sendStackTraceToClient"></param>
    private static void AddExceptionToDetails(Exception exception, StringBuilder detailBuilder, bool sendStackTraceToClient)
    {
        string globalExceptionMessage = $"{exception.GetType().Name}:内部异常";
        if (exception is GlobalException)
        {
            globalExceptionMessage = $"{exception.GetType().Name}:{exception.Message}";
        }
        detailBuilder.AppendLine(globalExceptionMessage);

        //Details
        if (exception is IHasErrorDetails)
        {
            string details = ((IHasErrorDetails)exception).Details;
            if (false == string.IsNullOrWhiteSpace(details))
            {
                detailBuilder.AppendLine(details);
            }
        }

        //StackTrace
        if (true == sendStackTraceToClient)
        {
            string stackTrace = $"STACK TRACE: {exception.StackTrace}";
            detailBuilder.AppendLine(stackTrace);
        }

        //InnerException
        if (null != exception.InnerException)
        {
            GlobalExceptionResponseConverter.AddExceptionToDetails(exception.InnerException, detailBuilder, sendStackTraceToClient);
        }
    }

    /// <summary>
    /// 创建默认全局异常配置
    /// </summary>
    /// <returns></returns>
    private static GlobalExceptionHandlerMiddlewareOptions CreateDefaultOptions()
    {
        return new GlobalExceptionHandlerMiddlewareOptions();
    }
}

试试效果

还是这么个请求

public IActionResult Tset()
{
    //throw new Exception("错误消息");
    throw new GlobalException("错误消息");
    return Ok();
}

Exception,状态码500

{
    "error": "内部错误"
}

GlobalException,状态码500

{
    "error": "错误消息"
}

成啦

其实这里面有个地方我懒得改了,IHttpExceptionStatusCodeFinder接口会根据异常类型返回状态码,估计也是可以直接改成500,毕竟abp那些filter都神奇的不会执行,似乎都执行.net自带的filter去了

DataAnnotations

这里跟asp.net core原版一样,把默认的filter禁用,再添加自己写的就行了
先定义一个异常,继承上面的全局异常,因为我是用来验证dto的,所以就叫DTOStateValidationException

public class DTOStateValidationException : GlobalException
{
    public DTOStateValidationException() : base()
    {
    }

    public DTOStateValidationException(string message) : base(message)
    {
    }
}

.net原版的filter里面有日志记录,那么我们也写一个日志,与源码保持一致,但是源码的日志用的是扩展方法,访问权限是internal,所以我们从源码复制一个出来

internal static class DTOStateValidateLoggerExtensions
{
    private static readonly Action<ILogger, Exception> _modelStateInvalidFilterExecuting;

    static DTOStateValidateLoggerExtensions()
    {
        DTOStateValidateLoggerExtensions._modelStateInvalidFilterExecuting = LoggerMessage.Define(
            LogLevel.Debug,
            new EventId(1, "ModelStateInvalidFilterExecuting"),
            "The request has model state errors, returning an error response.");
    }

    public static void ModelStateInvalidFilterExecuting(this ILogger logger) => _modelStateInvalidFilterExecuting(logger, null);
}

因为我们这个filter的设计就是要抛出异常,这个filter只是对异常消息做处理,所以我们可以借鉴一下AbpExceptionFilter和AbpExceptionHandlingMiddleware,这俩的操作基本一致,所以我们的filter也要跟全局异常中间件保持一致,那我们就跟全局异常中间件一样写一个converter,这个converter的操作就是遍历一遍错误信息,然后把第一条错误信息作为异常信息返回

public class DTOStateValidationExceptionConverter
{
    /// <summary>
    /// 上下文转换为异常
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public static DTOStateValidationException Convert(ActionContext context)
    {
        string message = DTOStateValidationExceptionConverter.CreateInvalidMessage(context.HttpContext, context.ModelState);

        DTOStateValidationException exception = new DTOStateValidationException(message);
        return exception;
    }

    /// <summary>
    /// 创建错误信息
    /// </summary>
    /// <param name="context"></param>
    /// <param name="modelState"></param>
    /// <returns></returns>
    private static string CreateInvalidMessage(HttpContext context, ModelStateDictionary modelState)
    {
        List<DTOPropertyInvalidModel> errorList = new List<DTOPropertyInvalidModel>();

        foreach (var modelPropertyDic in modelState)
        {
            string propertyName = modelPropertyDic.Key;
            ModelStateEntry modelStateEntry = modelPropertyDic.Value;
            if (null != modelStateEntry)
            {
                var propertyErrorList = modelStateEntry.Errors;
                if (null != propertyErrorList && propertyErrorList.Count > 0)
                {
                    foreach (var propertyError in propertyErrorList)
                    {
                        string propertyErrorMessage = propertyError.ErrorMessage;
                        if (false == string.IsNullOrWhiteSpace(propertyErrorMessage))
                        {
                            DTOPropertyInvalidModel invalidModel = new DTOPropertyInvalidModel();
                            invalidModel.Name = propertyName;
                            invalidModel.ErrorMessage = propertyErrorMessage;
                            errorList.Add(invalidModel);
                        }
                    }
                }
            }
        }

        string message = DTOStateValidationExceptionConverter.CreateInvalidMessageFromErrorList(errorList);

        return message;
    }

    /// <summary>
    /// 根据错误列表创建错误信息
    /// </summary>
    /// <param name="errorList"></param>
    /// <returns></returns>
    private static string CreateInvalidMessageFromErrorList(List<DTOPropertyInvalidModel> errorList)
    {
        string message = "数据验证异常";

        if (null != errorList && errorList.Count > 0)
        {
            DTOPropertyInvalidModel propertyInvalidModel = errorList.FirstOrDefault();
            if (null != propertyInvalidModel)
            {
                message = propertyInvalidModel.ErrorMessage;
            }
        }

        return message;
    }
}

这里和中间件一样需要一个model,其实可以用Dictionary将就一下啦

public class DTOPropertyInvalidModel
{
    /// <summary>
    /// 异常属性名称
    /// </summary>
    public string Name { get; set; }
    /// <summary>
    /// 异常信息
    /// </summary>
    public string ErrorMessage { get; set; }
}

然后就是filter,这里的操作就是日志输出一下,然后抛出异常

public class DTOStateValidateFilter : IActionFilter, ITransientDependency
{
   private readonly ILogger<DTOStateValidateFilter> _logger;

   public DTOStateValidateFilter(ILogger<DTOStateValidateFilter> logger)
   {
       this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
   }

   public void OnActionExecuted(ActionExecutedContext context)
   {

   }

   public void OnActionExecuting(ActionExecutingContext context)
   {
       if (null == context.Result && false == context.ModelState.IsValid)
       {
           this._logger.ModelStateInvalidFilterExecuting();

           //构建异常DTOStateValidationException
           DTOStateValidationException exception = DTOStateValidationExceptionConverter.Convert(context);

           throw exception;
       }
   }
}

最后是配置

//关闭默认模型验证filter
context.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.SuppressModelStateInvalidFilter = true;
});

//添加自定义模型验证filter
context.Services.Configure<MvcOptions>(options =>
{
    options.Filters.Add(typeof(DTOStateValidateFilter));
});

试试效果
还是那个dto和请求

public class CreateOpenIddictApplicationDto
{
    [Required]
    public string ClientId { get; set; }
    [MinLength(6)]
    [EmailAddress]
    public string ClientSecret { get; set; }
}
[HttpPost("test2")]
public IActionResult Tset2([FromBody] CreateOpenIddictApplicationDto input)
{
    return Ok();
}

结果符合预期

{
    "error": "The ClientId field is required."
}

Abp vNext异常处理 结束