【问题标题】:Validation for a CQS system that throws an exception验证引发异常的 CQS 系统
【发布时间】:2017-09-11 13:41:26
【问题描述】:

我一直在阅读,异常应该只针对“异常”的东西,而不是用于控制程序的流程。但是,对于 CQS 实现,这似乎是不可能的,除非我开始破解实现来处理它。我想展示我是如何实现的,看看这是否真的很糟糕。我正在使用装饰器,因此命令不能返回任何内容(异步任务除外),因此 ValidationResult 是不可能的。告诉我!

本示例将使用 ASP.NET MVC

控制器:(api)

[Route(ApiConstants.ROOT_API_URL_VERSION_1 + "DigimonWorld2Admin/Digimon/Create")]
public class CreateCommandController : MetalKidApiControllerBase
{
    private readonly IMediator _mediator;

    public CreateCommandController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task Post([FromBody]CreateCommand command) => 
        await _mediator.ExecuteAsync(command);
}

CommandExceptionDecorator 在链中是第一个:

public class CommandHandlerExceptionDecorator<TCommand> : ICommandHandler<TCommand> where TCommand : ICommand
{
    private readonly ICommandHandler<TCommand> _commandHandler;
    private readonly ILogger _logger;
    private readonly IUserContext _userContext;

    public CommandHandlerExceptionDecorator(ICommandHandler<TCommand> commandHandler, ILogger logger,
        IUserContext userContext)
    {
        Guard.IsNotNull(commandHandler, nameof(commandHandler));
        Guard.IsNotNull(logger, nameof(logger));

        _commandHandler = commandHandler;
        _logger = logger;
        _userContext = userContext;
    }

    public async Task ExecuteAsync(TCommand command, CancellationToken token = default(CancellationToken))
    {
        try
        {
            await _commandHandler.ExecuteAsync(command, token).ConfigureAwait(false);
        }
        catch (BrokenRuleException)
        {
            throw; // Let caller catch this directly
        }
        catch (UserFriendlyException ex)
        {
            await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                "Friendly exception with command: " + typeof(TCommand).FullName, ex, command)).ConfigureAwait(false);
            throw; // Let caller catch this directly
        }
        catch (NoPermissionException ex)
        {
            await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                "No Permission exception with command: " + typeof(TCommand).FullName, ex, command)).ConfigureAwait(false);
            throw new UserFriendlyException(CommonResource.Error_NoPermission); // Rethrow with a specific message
        }
        catch (ConcurrencyException ex)
        {
            await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                "Concurrency error with command: " + typeof(TCommand).FullName, ex, command)).ConfigureAwait(false);
            throw new UserFriendlyException(CommonResource.Error_Concurrency); // Rethrow with a specific message
        }
        catch (Exception ex)
        {
            await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                "Error with command: " + typeof(TCommand).FullName, ex, command)).ConfigureAwait(false);
            throw new UserFriendlyException(CommonResource.Error_Generic); // Rethrow with a specific message
        }
    }
}

验证装饰器:

public class CommandHandlerValidatorDecorator<TCommand> : ICommandHandler<TCommand> where TCommand : ICommand
{
    private readonly ICommandHandler<TCommand> _commandHandler;
    private readonly IEnumerable<ICommandValidator<TCommand>> _validators;

    public CommandHandlerValidatorDecorator(
        ICommandHandler<TCommand> commandHandler,
        ICollection<ICommandValidator<TCommand>> validators)
    {
        Guard.IsNotNull(commandHandler, nameof(commandHandler));
        Guard.IsNotNull(validators, nameof(validators));

        _commandHandler = commandHandler;
        _validators = validators;
    }

    public async Task ExecuteAsync(TCommand command, CancellationToken token = default(CancellationToken))
    {
        var brokenRules = (await Task.WhenAll(_validators.AsParallel()
                .Select(a => a.ValidateCommandAsync(command, token)))
            .ConfigureAwait(false)).SelectMany(a => a).ToList();

        if (brokenRules.Any())
        {
            throw new BrokenRuleException(brokenRules);
        }

        await _commandHandler.ExecuteAsync(command, token).ConfigureAwait(false);
    }
}

存在其他装饰器,但对这个问题并不重要。

命令处理程序验证器示例:(每个规则都在其自己的线程上运行)

public class CreateCommandValidator : CommandValidatorBase<CreateCommand>
{
    private readonly IDigimonWorld2ContextFactory _contextFactory;

    public CreateCommandValidator(IDigimonWorld2ContextFactory contextFactory)
    {
        _contextFactory = contextFactory;
    }

    protected override void CreateRules(CancellationToken token = default(CancellationToken))
    {
        AddRule(() => Validate.If(string.IsNullOrEmpty(Command.Name))
            ?.CreateRequiredBrokenRule(DigimonResources.Digipedia_CreateCommnad_Name, nameof(Command.Name)));
        AddRule(() => Validate.If(Command.DigimonTypeId == 0)
            ?.CreateRequiredBrokenRule(DigimonResources.Digipedia_CreateCommnad_DigimonTypeId,
                nameof(Command.DigimonTypeId)));
        AddRule(() => Validate.If(Command.RankId == 0)
            ?.CreateRequiredBrokenRule(DigimonResources.Digipedia_CreateCommnad_RankId, nameof(Command.RankId)));

        AddRule(async () =>
        {
            using (var context = _contextFactory.Create(false))
            {
                return Validate.If(
                        !string.IsNullOrEmpty(Command.Name) &&
                        await context.Digimons
                            .AnyAsync(a => a.Name == Command.Name, token)
                            .ConfigureAwait(false))
                    ?.CreateAlreadyInUseBrokenRule(DigimonResources.Digipedia_CreateCommnad_Name, Command.Name,
                        nameof(Command.Name));
            }
        });
    }
}

实际的命令处理程序:

public class CreateCommandValidatorHandler : ICommandHandler<CreateCommand>
{
    private const int ExpectedChangesCount = 1;

    private readonly IDigimonWorld2ContextFactory _contextFactory;
    private readonly IMapper<CreateCommand, DigimonEntity> _mapper;

    public CreateCommandValidatorHandler(
        IDigimonWorld2ContextFactory contextFactory, 
        IMapper<CreateCommand, DigimonEntity> mapper)
    {
        _contextFactory = contextFactory;
        _mapper = mapper;
    }

    public async Task ExecuteAsync(CreateCommand command, CancellationToken token = default(CancellationToken))
    {
        using (var context = _contextFactory.Create())
        {
            var entity = _mapper.Map(command);
            context.Digimons.Add(entity);
            await context.SaveChangesAsync(ExpectedChangesCount, token).ConfigureAwait(false);
        }
    }
}

当因验证规则被破坏而引发异常时,正常流程将被破坏。每个步骤都假定上一步成功。这使得代码非常干净,因为我们不关心实际实现过程中的失败。所有命令最终都经过相同的逻辑,因此我们只需要编写一次。在 MVC 的最顶端,我像这样处理 BrokenRuleException:(我进行 AJAX 调用,而不是整页帖子)

internal static class ErrorConfiguration
{
    public static void Configure(
        IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, IConfigurationRoot configuration)
    {
        loggerFactory.AddConsole(configuration.GetSection("Logging"));
        loggerFactory.AddDebug();

        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
            app.UseBrowserLink();
        }
        else
        {
            app.UseExceptionHandler("/Home/Error");
        }

        app.UseExceptionHandler(errorApp =>
        {
            errorApp.Run(async context =>
            {
                var error = context.Features.Get<IExceptionHandlerFeature>()?.Error;

                context.Response.StatusCode = GetErrorStatus(error);
                context.Response.ContentType = "application/json";

                var message = GetErrorData(error);
                await context.Response.WriteAsync(message, Encoding.UTF8);
            });
        });
    }

    private static string GetErrorData(Exception ex)
    {
        if (ex is BrokenRuleException brokenRules)
        {
            return JsonConvert.SerializeObject(new
            {
                BrokenRules = brokenRules.BrokenRules
            });
        }

        if (ex is UserFriendlyException userFriendly)
        {
            return JsonConvert.SerializeObject(new
            {
                Message = userFriendly.Message
            });
        }

        return JsonConvert.SerializeObject(new
        {
            Message = MetalKid.Common.CommonResource.Error_Generic
        });
    }

    private static int GetErrorStatus(Exception ex)
    {
        if (ex is BrokenRuleException || ex is UserFriendlyException)
        {
            return (int)HttpStatusCode.BadRequest;
        }
        return (int)HttpStatusCode.InternalServerError;
    }
}

BrokenRule 类具有消息和关系字段。这种关系允许 UI 将消息与页面上的某些内容(即 div 或表单标签等)联系起来,以在正确的位置显示消息

public class BrokenRule
{      
    public string RuleMessage { get; set; }
    public string Relation { get; set; }

    public BrokenRule() { }

    public BrokenRule(string ruleMessage, string relation = "")
    {
        Guard.IsNotNullOrWhiteSpace(ruleMessage, nameof(ruleMessage));

        RuleMessage = ruleMessage;
        Relation = relation;
    }
}

如果我不这样做,控制器必须首先调用验证类,查看结果,然后将其作为 400 并返回正确响应。最有可能的是,您必须调用一个辅助类来正确转换它。但是,控制器最终会看起来像这样或类似的东西:

[Route(ApiConstants.ROOT_API_URL_VERSION_1 + "DigimonWorld2Admin/Digimon/Create")]
public class CreateCommandController : MetalKidApiControllerBase
{
    private readonly IMediator _mediator;
    private readonly ICreateCommandValidator _validator;

    public CreateCommandController(IMediator mediator, ICreateCommandValidator validator) 
    {
        _mediator = mediator;
        _validator = validator
    }

    [HttpPost]
    public async Task<IHttpResult> Post([FromBody]CreateCommand command)
    {
        var validationResult = _validator.Validate(command);
        if (validationResult.Errors.Count > 0) 
        {
           return ValidationHelper.Response(validationResult);
        }
        await _mediator.ExecuteAsync(command);
        return Ok();
    }
}

需要对每个命令重复此验证检查。如果忘记了,后果会很严重。使用异常样式,代码保持紧凑,开发人员不必担心每次都添加冗余代码。

我真的很想得到大家的反馈。谢谢!

* 编辑 * 另一种可能的选择是为响应本身设置另一个“中介”,它可以先直接运行验证,然后继续:

[Route(ApiConstants.ROOT_API_URL_VERSION_1 + "DigimonWorld2Admin/Digimon/Create")]
public class CreateCommandController : MetalKidApiControllerBase
{
    private readonly IResultMediator _mediator;

    public CreateCommandController(IResultMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task<IHttpAction> Post([FromBody]CreateCommand command) => 
        await _mediator.ExecuteAsync(command);
}

在这个新的 ResultMediator 类中,它会查找 CommandValidator,如果有任何验证错误,它会简单地返回 BadRequest(new { BrokenRules = brokenRules}) 并称其为好。这是每个 UI 都必须创建和处理的东西吗?但是,如果在此调用期间出现异常,我们必须直接在此调解器中处理。想法?

编辑 2: 也许我应该很快解释一下装饰器。例如,我有这个 CreateCommand(在这种情况下具有特定的命名空间)。有一个处理此命令的 CommandHandler 定义为 ICommandHandler。这个接口有一个方法定义为:

Task ExecuteAsync(TCommand, CancellationToken token);

每个装饰器也实现了相同的接口。 Simple Injector 允许您使用相同的接口定义这些新类,例如 CommandHandlerExceptionDecorator 和 CommandHandlerValidationDecorator。当顶部的代码想要使用该 CreateCommand 调用 CreateCommandHandler 时,SimpleInjector 将首先调用最后定义的装饰器(在本例中为 ExceptionDecorator)。这个装饰器处理所有异常并为所有命令记录它们,因为它是通用定义的。我只需要编写一次该代码。然后它将调用转发给下一个装饰器。在这种情况下,它可能是 ValidationDecorator。这将验证 CreateCommand 以确保它有效。如果是,它会将其转发到创建实体的实际命令。如果没有,它会引发异常,因为我无法返回任何东西。 CQS 声明命令必须是无效的。不过,Task 没问题,因为它只是为了实现 async/await 样式。它实际上没有返回任何东西。由于我无法在那里返回违反的规则,因此我抛出了一个异常。我只是想知道这种方法是否可行,因为它使所有不同级别的所有代码都特定于任务(SRP),我现在和将来只需在所有命令中编写一次。任何 UI 都可以简单地捕获任何出现的 BrokenRuleException 并知道如何处理该数据以显示它。这可以通用地编写,因此我们也可以显示任何命令的任何错误(由于规则上的 Relation 属性)。这样,我们就写一次就完成了。然而,问题是我一直看到用户验证不是“异常”的,所以我们不应该抛出异常。这样做的问题是,如果我真正遵循这条路径,它将使我的代码更加复杂且难以维护,因为每个命令调用者都必须编写相同的代码才能做到这一点。如果我只为任何验证错误抛出一个 BrokenRuleException,那还可以吗?

【问题讨论】:

    标签: c# asp.net-mvc validation exception


    【解决方案1】:

    我的命令使用非常相似的模式,基于 Jimmy Bogard 的 MediatR(使用管道功能在我的处理程序周围添加多个装饰器),并使用 Fluent Validation 作为验证器。

    我经历了与您类似的思考过程 - 我的验证器抛出异常(在 MVC 的顶部以与您类似的方式捕获),但有很多人会告诉您这不应该完成 - 尤其是我最喜欢的技术预言机Martin Fowler

    一些想法:

    • 我对我们作为开发人员遇到的一些“你不应该...”公理有点警惕,并相信继续朝着命令和验证器干净、干燥的模式前进比遵循这一点更重要规则。
    • 以类似的方式,“您不应从命令中返回任何内容”在我看来可能会被颠覆,而且似乎是某些 debate 的主题,允许您使用上面链接的通知模式。李>
    • 最后,您面向用户的应用程序是否进行任何客户端验证?也许有人会争辩说,如果客户端应用程序应该阻止命令处于无效状态,那么服务器端的异常无论如何都会是异常的,问题就消失了。

    希望能在一些小的方面有所帮助。我将对这方面的任何其他观点感兴趣。

    【讨论】:

    • 允许命令返回的问题是,现在查询和命令之间没有任何区别。我会有不同的界面,但是你可以通过命令做所有的事情并摆脱它,所以我试图遵循这个公理。另一个问题是,如果我返回东西,调用者仍然可以忽略它,我也试图避免这个问题。 :) 但其他一切都很重要!
    • 哦,有趣的是你提到了那个辩论链接。我是那里的cmets的最后一个帖子。 :) 我在互联网上到处寻找 cqs 和验证页面。我什至去喜欢谷歌搜索结果中的第 4 页! :)
    【解决方案2】:

    经过几个月的来回折腾,我崩溃了,最终从所有命令/查询中返回了 IResult 或 IResult。 IResult 如下所示:

    public interface IResult
    {
        bool IsSuccessful { get; }
        ICollection<BrokenRule> BrokenRules { get; }
        bool HasNoPermissionError { get; }
        bool HasNoDataFoundError { get; }
        bool HasConcurrencyError { get; }
        string ErrorMessage { get; }
    }
    
    public interface IResult<T> : IResult
    {
        T Data { get; }
    }
    

    在我的逻辑中有一些特定的场景,我可以很容易地不抛出异常,并且让上面的层只检查那些布尔标志来确定向最终用户显示什么。如果发生真正的异常,我可以将它放在 ErrorMessage 属性上并从那里拉出来。

    查看 CQS,我意识到为命令返回带有 this 的 IResult 是可以的,因为它没有返回有关实际进程的任何信息。它要么成功(IsSuccessful = true)要么发生了一些不好的事情,这意味着我需要向最终用户展示发生了一些不好的事情,并且该命令无论如何都不会运行。

    我创建了一些辅助方法来创建结果,因此编码人员并不关心。唯一添加到主要实现的是:

    ResultHelper.Successful();
    

    ResultHelper.Successful(data); (returns IResult<T>)
    

    这样,其余场景由其他装饰器处理,因此返回 IResult 不会变得很麻烦。

    在 UI 级别,我创建了一个 ResponseMediator,而不是返回 IActionResult 项。这将处理 IResult 并返回适当的数据/状态代码。即(ICqsMediator 是以前的 IMediator)

    public class ResponseMediator : IResponseMediator
    {
        private readonly ICqsMediator _mediator;
    
        public ResponseMediator(ICqsMediator mediator)
        {
            Guard.IsNotNull(mediator, nameof(mediator));
    
            _mediator = mediator;
        }
    
        public async Task<IActionResult> ExecuteAsync(
            ICommand command, CancellationToken token = default(CancellationToken)) =>
            HandleResult(await _mediator.ExecuteAsync(command, token).ConfigureAwait(false));
    
        public async Task<IActionResult> ExecuteAsync<TResponse>(
            ICommandQuery<TResponse> commandQuery, CancellationToken token = default(CancellationToken)) =>
            HandleResult(await _mediator.ExecuteAsync(commandQuery, token).ConfigureAwait(false));
    
        public async Task<IActionResult> ExecuteAsync<TResponse>(
            IQuery<TResponse> query, CancellationToken token = default(CancellationToken)) =>
            HandleResult(await _mediator.ExecuteAsync(query, token).ConfigureAwait(false));
    
        private IActionResult HandleResult<T>(IResult<T> result)
        {
            if (result.IsSuccessful)
            {
                return new OkObjectResult(result.Data);
            }
            return HandleResult(result);
        }
    
        private IActionResult HandleResult(IResult result)
        {
            if (result.IsSuccessful)
            {
                return new OkResult();
            }
            if (result.BrokenRules?.Any() == true)
            {
                return new BadRequestObjectResult(new {result.BrokenRules});
            }
            if (result.HasConcurrencyError)
            {
                return new BadRequestObjectResult(new {Message = CommonResource.Error_Concurrency});
            }
            if (result.HasNoPermissionError)
            {
                return new UnauthorizedResult();
            }
            if (result.HasNoDataFoundError)
            {
                return new NotFoundResult();
            }
            if (!string.IsNullOrEmpty(result.ErrorMessage))
            {
                return new BadRequestObjectResult(new {Message = result.ErrorMessage});
            }
            return new BadRequestObjectResult(new {Message = CommonResource.Error_Generic});
        }
    }
    

    这样,我不必处理任何异常来更改代码流,除非发生真正异常的事情。这在顶级异常装饰器处理程序中是这样捕获的:

     public async Task<IResult> ExecuteAsync(TCommand command,
            CancellationToken token = default(CancellationToken))
        {
            try
            {
                return await _commandHandler.ExecuteAsync(command, token).ConfigureAwait(false);
            }
            catch (UserFriendlyException ex)
            {
                await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                        "Friendly exception with command: " + typeof(TCommand).FullName, ex, command), token)
                    .ConfigureAwait(false);
                return ResultHelper.Error(ex.Message);
            }
            catch (DataNotFoundException ex)
            {
                await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                        "Data Not Found exception with command: " + typeof(TCommand).FullName, ex, command), token)
                    .ConfigureAwait(false);
                return ResultHelper.NoDataFoundError();
            }
            catch (ConcurrencyException ex)
            {
                await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                        "Concurrency error with command: " + typeof(TCommand).FullName, ex, command), token)
                    .ConfigureAwait(false);
                return ResultHelper.ConcurrencyError();
            }
            catch (Exception ex)
            {
                await _logger.LogAsync(new LogEntry(LogTypeEnum.Error, _userContext,
                    "Error with command: " + typeof(TCommand).FullName, ex, command), token).ConfigureAwait(false);
                return ResultHelper.Error(CommonResource.Error_Generic);
            }
        }
    

    【讨论】:

    • 这种风格用了一段时间后,其实还是蛮喜欢的。每个控制器都会以完全相同的方式处理这个问题,并通过一个 ResponseMediator。如果我需要任何特殊的逻辑,我可以在需要时将它添加到全局的一个地方。如果我需要一张新的奇怪支票,我可以添加一次,他们都会得到它。在处理程序中,所有代码都非常干净且有意义,并且上面的所有内容都已经为我处理了。
    【解决方案3】:

    我并没有真正理解这个问题,但我相信抛出这个异常是可以的。问题是程序将在该部分停止工作,并可能冻结或其他什么。您应该有一个弹出警告警报或至少让用户知道发生了什么的东西。给他们一个错误的总结。您可以在 WPF 中使用 MessageBox.Show 轻松完成此操作。

    【讨论】:

    • 哦,这是 asp.net,所以它最终会返回 400,错误为 JSON。在 wpf 中,我会直接处理损坏的规则异常并在屏幕上显示一些内容。
    • @DanielLorenz 在这种情况下可以例外。希望我的回答有帮助
    猜你喜欢
    • 1970-01-01
    • 2013-03-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-11-03
    • 1970-01-01
    • 2021-01-10
    • 2023-04-08
    相关资源
    最近更新 更多