一个CQS系统抛出异常的验证

8
我是一名有用的助手,可以为您翻译文本。
我一直在阅读关于异常只应该用于“异常”情况而不应用于控制程序流程的文章。然而,在使用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,使用相同的接口。当顶部的代码想要调用CreateCommandHandler并使用CreateCommand时,SimpleInjector将首先调用最后定义的装饰器(在本例中是ExceptionDecorator)。该装饰器处理所有异常并记录所有命令的日志,因为它是通用定义的。我只需编写一次该代码。然后它将调用下一个装饰器。在这种情况下,它可以是ValidationDecorator。这将验证CreateCommand以确保它是有效的。如果是有效的,它将转发到实际的命令,其中执行实体的创建。如果不是,它会抛出异常,因为我无法返回任何内容。CQS规定命令必须为空。但Task类型可以,因为它只是实现异步/等待风格。它实际上没有返回任何东西。由于我没有办法返回损坏的规则,因此我抛出异常。我只是想知道这种方法是否可行,因为它使得所有不同级别的代码都特定于任务(SRP),而且我现在只需要在所有命令上编写一次。任何UI都可以简单地捕获任何BrokenRuleException并知道如何处理该数据以显示它。这可以通用编写,因此我们也可以显示任何命令的任何错误(由规则上的Relation属性确定)。这样,我们只需编写一次就完成了。然而,问题在于我不断地看到用户验证并不是“异常”,因此我们不应该引发异常。如果我只为任何验证错误抛出一个BrokenRuleException,那是否仍然可以?
3个回答

3
我使用与Jimmy Bogard的MediatR类似的模式来编写命令(使用管道功能在处理程序周围添加多个装饰器),并使用Fluent Validation进行验证。
我和你有着相似的思考过程 - 我的验证器会抛出异常(这些异常以类似于你的方式在MVC顶部被捕获),但是有很多人会告诉你不应该这样做,尤其是我的最喜欢的技术先知Martin Fowler
以下是一些想法:
  • 我对我们作为开发者遇到的一些“不可...”的格言有些警惕,并认为继续朝着命令和验证器的干净、DRY模式的进展比遵循那个规则更重要。
  • 同样地,“不可从命令中返回任何内容”在我看来也可以被颠覆,并且似乎是一些辩论的主题,使您能够利用上面链接的通知模式。
  • 最后,你的用户界面应用程序是否进行了任何客户端验证?也许可以争论,如果客户端应用程序应该防止命令处于无效状态,那么服务器端的异常将真正是异常的,问题就解决了。

希望这在某种程度上有所帮助。我会对任何其他观点感兴趣。


允许返回结果的命令的问题在于现在查询和命令之间不会有任何区别。我本来想使用不同的接口,但是你可以通过命令完成所有操作,因此我试图遵循这个原则。另一个问题是如果我返回结果,调用者仍然可以忽略它,我也试图避免这个问题。:) 但其他方面都是很好的观点! - Daniel Lorenz
哦,你提到那个辩论链接真有趣。我是那里评论区的最后一篇帖子。:) 我在互联网上搜寻了很多关于CQS和验证页面的资料,甚至翻到了谷歌搜索结果的第四页!:) - Daniel Lorenz

2

经过数月的反复考虑,我最终决定将所有的命令/查询都返回IResult或IResult< T >。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,我意识到对于命令返回一个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进行处理。如果我需要任何特殊逻辑,我可以在一个全局位置添加它。如果我需要一个新的奇怪检查,我只需添加一次即可,它们都将得到它。在处理程序中,所有代码都非常干净,有意义,而且上面的所有内容都已经为我处理好了。 - Daniel Lorenz

-1

我并没有真正理解这个问题,但我相信抛出这个异常是可以的。问题在于程序将在那个部分停止工作,可能会冻结或其他什么情况。你应该有一个弹出式警告提示或其他东西,至少让用户知道发生了什么。给他们一个错误摘要。你可以在 WPF 中轻松使用 MessageBox.Show 来实现这一点。


哦,这是asp.net,所以最终会返回一个带有错误信息的JSON 400。在wpf中,我会直接处理brokenruleexception并在屏幕上显示一些内容。 - Daniel Lorenz
在这种情况下,拥有异常是可以的。希望我的回答有所帮助。 - Rohan Shetty

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接