验证引发异常的CQS系统

时间:2017-09-11 13:41:26

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

我一直在读,异常只应该用于某些特殊情况"而不是用来控制程序的流程。然而,使用CQS实现,除非我开始破解实现来处理它,否则这似乎是不可能的。我想展示一下我是如何实现它的,看看这是不是真的很糟糕。我使用装饰器,因此命令不能返回任何内容(除了异步的Task),因此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();
    }
}

需要在每个命令上重复此验证检查。如果它被遗忘,将会产生重大影响。使用异常样式,代码仍然紧凑,开发人员不必担心每次都添加冗余代码。

我真的很想得到每个人的反馈。谢谢!

*编辑* 另一种可能的选择是让另一个&#34;调解员&#34;响应本身可以先直接运行验证,然后继续:

[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声明命令必须无效。但是,任务没问题,因为它只是实现了async / await风格。它实际上没有任何回报。由于我无法在那里返回破坏的规则,因此我抛出异常。我只想知道这种方法是否合适,因为它使所有代码都在特定于任务(SRP)的所有不同级别,并且我只需要在现在和将来的所有命令中编写一次。任何UI都可以简单地捕获任何出现的BrokenRuleException,并知道如何处理该数据以显示它。这可以一般编写,因此我们也可以显示任何命令的任何错误(由于规则上的Relation属性)。这样,我们写了一次并完成了。然而,问题是我一直看到用户验证不是特殊情况,所以我们不应该抛出异常。问题是,如果我真的遵循该路径,它将使我的代码更加复杂和更难维护,因为每个命令调用者必须编写相同的代码来执行此操作。如果我只针对任何验证错误抛出一个BrokenRuleException,那还可以吗?

3 个答案:

答案 0 :(得分:1)

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

我对你进行了类似的思考过程 - 我的验证器会抛出异常(在你的MVC顶部以类似的方式捕获它们),但是有很多人会告诉你这不应该完成 - 尤其是我最喜欢的技术oracle Martin Fowler

一些想法:

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

希望在某种程度上有所帮助。我对此有任何其他看法感兴趣。

答案 1 :(得分:0)

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

答案 2 :(得分:0)

经过几个月的来回,我崩溃了,最后归还了IResult或者IResult&lt; 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);
        }
    }