REST API数据验证

原文链接

系列文章目录

一、简单的CQRS实现与原始SQL和DDD
二、使用EF的领域模型的封装和持久化透明(PI)
三、REST API数据验证
四、领域模型验证
五、如何发布和处理领域事件
六、处理领域事件:缺失的部分
七、发件箱模式(The Outbox Pattern)
八、.NET Core中的旁路缓存模式(Cache-Aside Pattern)

引言

这一次,我将描述如何保护REST API应用程序免受包含无效数据的请求(数据验证过程)的影响。然而,不幸的是,仅仅验证我们的请求是不够的。除了验证之外,我们还负责将相关消息和状态返回给API客户端。我想在这篇文章中处理这两件事。

数据验证

数据验证(Data validation)的定义

什么是数据验证?我发现最好的定义来自UNECE数据编辑组(UNECE Data Editing Group):

一种旨在验证数据项的值是否来自给定(有限或无限)的可接受值集的活动。

根据这个定义,我们应该验证从外部源进入应用程序的数据项,并检查它们的值是否可接受。我们怎么知道这个值是可以接受的呢?我们需要为系统中正在处理的每种类型的数据项定义数据验证规则。

数据与业务规则验证(Business Rules validation

我想强调的是,数据验证与业务规则验证是完全不同的概念。数据验证的重点是验证原子数据项。业务规则验证是一个更广泛的概念,更接近业务的工作和行为方式。所以它主要关注行为。当然,验证行为也依赖于数据,但范围更广。

数据验证的例子:

  • 产品订单数量不能为负或零
  • 产品的订货数量应该是一个数字
  • 订单货币应该是货币列表中的值

业务规则验证的示例:

  • 只有当客户年龄等于或大于产品最小年龄时,才能订购产品。
  • 客户一天只能下两份订单。

返回相关信息

如果我们承认在验证过程中规则被破坏了,我们必须停止处理并将相应的消息返回给客户端。我们应该遵循以下规则:

  • 我们应该尽可能快地将消息返回给客户端(快速失败[Fail-fast]原则)
  • 验证错误的原因应该很好地向客户解释和理解
  • 我们不应该出于安全原因而返回技术层面(we should not return technical aspects for security reasons)

HTTP APIs标准的问题细节

返回错误消息的问题非常普遍,因此创建了一个特殊的标准来描述如何处理这种情况。它被称为“HTTP APIs标准的问题细节”,他的官方描述可以在这个链接找到。以下是该标准的摘要:

本文档将“问题细节”定义为一种在HTTP响应中携带机器可读的(machine-readable)错误细节的方法,以避免为HTTP APIs定义新的错误响应格式。

问题细节标准引入了问题细节JSON对象(Problem Details JSON object),当验证错误发生时,它应该是响应的一部分。这是一个简单的规范模型,有5个成员:

  • 问题类型
  • 标题
  • HTTP状态码
  • 错误细节
  • 实例(指向特定事件的指针)

当然,我们可以(有时我们应该)通过添加新属性来扩展这个对象,但基类应该是相同的。正因为如此,我们的API更容易理解、学习和使用。有关标准的更详细信息,我推荐您阅读文档,其中有很好的描述。

数据验证本地化

对于标准应用程序,我们可以将数据验证逻辑放在三个地方:

  • GUI ——它是用户输入的入口点。数据在客户端进行验证,例如在Web应用程序中使用Javascript
  • 应用程序逻辑/服务层——在服务器端的特定应用程序服务或命令处理程序中验证数据
  • 数据库——这是请求处理的出口点,也是验证数据的最后时刻

image

在本文中,我省略了GUI和数据库组件,主要关注应用程序的服务器端。让我们看看如何在应用程序服务层实现数据验证。

实现数据验证

假设我们有一个AddCustomerOrderCommand命令:

public class AddCustomerOrderCommand : IRequest
{
    public Guid CustomerId { get; }

    public List<ProductDto> Products { get; }

    public AddCustomerOrderCommand(
        Guid customerId, 
        List<ProductDto> products)
    {
        this.CustomerId = customerId;
        this.Products = products;
    }
}

public class ProductDto
{
    public Guid Id { get; set; }

    public int Quantity { get; set; }

    public string Currency { get; set; }

    public string Name { get; set; }
}

假设我们要验证4件事:

  1. CustomerId不是空GUID。
  2. 产品列表不是空的
  3. 每个产品数量大于0
  4. 每个产品货币是美元或欧元

让我来展示这个问题的3个解决方案——从简单到最复杂。

1. 应用程序服务上的简单验证

首先想到的是命令处理程序本身的简单验证。在这个解决方案中,我们需要实现一个私有方法来验证我们的命令,并在验证错误发生时抛出异常。从清洁代码的角度来看,在单独的方法中关闭这种逻辑更好。

public class AddCustomerOrderCommandHandler : IRequestHandler<AddCustomerOrderCommand>
{
    private readonly ICustomerRepository _customerRepository;
    private readonly IProductRepository _productRepository;

    public AddCustomerOrderCommandHandler(
        ICustomerRepository customerRepository, 
        IProductRepository productRepository)
    {
        this._customerRepository = customerRepository;
        this._productRepository = productRepository;
    }

    public async Task<Unit> Handle(AddCustomerOrderCommand request, CancellationToken cancellationToken)
    {
        Validate(request);
        var customer = await this._customerRepository.GetByIdAsync(request.CustomerId);

        // logic..
    }

    private static void Validate(AddCustomerOrderCommand command)
    {
        var errors = new List<string>();

        if (command.CustomerId == Guid.Empty)
        {
            errors.Add("CustomerId is empty");
        }

        if (command.Products == null || !command.Products.Any())
        {
            errors.Add("Products list is empty");
        }
        else
        {
            if (command.Products.Any(x => x.Quantity < 1))
            {
                errors.Add("At least one product has invalid quantity");
            }

            if (command.Products.Any(x => x.Currency != "USD" && x.Currency != "EUR"))
            {
                errors.Add("At least one product has invalid currency");
            }
        }

        if (errors.Any())
        {
            var errorBuilder = new StringBuilder();

            errorBuilder.AppendLine("Invalid order, reason: ");

            foreach (var error in errors)
            {
                errorBuilder.AppendLine(error);
            }

            throw new Exception(errorBuilder.ToString());
        }
    }
}

无效命令执行的结果:

image

这不是很坏的方法,但有两个缺点。首先,它涉及到我们编写大量简单的样板代码——与空值,默认值,列表值等进行比较。其次,我们在这里失去了关注点分离的一部分,因为我们将验证逻辑与编排用例流混合在一起。让我们先看一下样板代码。

2. 使用FluentValidation库进行验证

我们不想重新发明轮子,所以最好的解决方案是使用库。幸运的是,在. Net世界中有一个很棒的验证库—— Fluent Validation。它有很好的API和很多特性。下面是我们如何使用它来验证我们的命令:

public class AddCustomerOrderCommandValidator : AbstractValidator<AddCustomerOrderCommand>
{
    public AddCustomerOrderCommandValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty().WithMessage("CustomerId is empty");
        RuleFor(x => x.Products).NotEmpty().WithMessage("Products list is empty");
        RuleForEach(x => x.Products).SetValidator(new ProductDtoValidator());
    }
}

public class ProductDtoValidator : AbstractValidator<ProductDto>
{
    public ProductDtoValidator()
    {
        this.RuleFor(x => x.Currency).Must(x => x == "USD" || x == "EUR")
            .WithMessage("At least one product has invalid currency");
        this.RuleFor(x => x.Quantity).GreaterThan(0)
            .WithMessage("At least one product has invalid quantity");
    }
}

现在,Validate方法看起来像下面这样:

private static void Validate(AddCustomerOrderCommand command)
{
    AddCustomerOrderCommandValidator validator = new AddCustomerOrderCommandValidator();

    var validationResult = validator.Validate(command);
    if (!validationResult.IsValid)
    {
        var errorBuilder = new StringBuilder();

        errorBuilder.AppendLine("Invalid order, reason: ");

        foreach (var error in validationResult.Errors)
        {
            errorBuilder.AppendLine(error.ErrorMessage);
        }

        throw new Exception(errorBuilder.ToString());
    }
}

验证的结果与前面相同,但是现在我们的验证逻辑更清晰了。最后要做的是将这个逻辑与命令处理程序完全解耦……

3. 使用管道模式(Pipeline Pattern)进行验证

为了解耦验证逻辑并在命令处理程序执行之前执行它,我们将命令处理过程安排在管道中(参见NServiceBus管道)。

对于管道实现,我们可以很容易地使用MediatR Behaviors。首先要做的是行为实现(behavior implementation):

public class CommandValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    private readonly IList<IValidator<TRequest>> _validators;

    public CommandValidationBehavior(IList<IValidator<TRequest>> validators)
    {
        this._validators = validators;
    }

    public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        var errors = _validators
            .Select(v => v.Validate(request))
            .SelectMany(result => result.Errors)
            .Where(error => error != null)
            .ToList();

        if (errors.Any())
        {
            var errorBuilder = new StringBuilder();

            errorBuilder.AppendLine("Invalid command, reason: ");

            foreach (var error in errors)
            {
                errorBuilder.AppendLine(error.ErrorMessage);
            }

            throw new Exception(errorBuilder.ToString());
        }

        return next();
    }
}

接下来要做的是在IoC容器中注册行为(以Autofac为例):

public class MediatorModule : Autofac.Module
{
    protected override void Load(ContainerBuilder builder)
    {
        builder.RegisterAssemblyTypes(typeof(IMediator).GetTypeInfo().Assembly).AsImplementedInterfaces();

        var mediatrOpenTypes = new[]
        {
            typeof(IRequestHandler<,>),
            typeof(INotificationHandler<>),
            typeof(IValidator<>),
        };

        foreach (var mediatrOpenType in mediatrOpenTypes)
        {
            builder
                .RegisterAssemblyTypes(typeof(GetCustomerOrdersQuery).GetTypeInfo().Assembly)
                .AsClosedTypesOf(mediatrOpenType)
                .AsImplementedInterfaces();
        }

        builder.RegisterGeneric(typeof(RequestPostProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>));
        builder.RegisterGeneric(typeof(RequestPreProcessorBehavior<,>)).As(typeof(IPipelineBehavior<,>));

        builder.Register<ServiceFactory>(ctx =>
        {
            var c = ctx.Resolve<IComponentContext>();
            return t => c.Resolve(t);
        });

        builder.RegisterGeneric(typeof(CommandValidationBehavior<,>)).As(typeof(IPipelineBehavior<,>));
    }
}

通过这种方式,我们实现了关注点分离和快速故障原则的实现。

但这还没有结束。最后,我们需要处理返回给客户端的消息。

实现问题细节标准

就像在验证逻辑实现的情况下一样,我们将使用专用的库——ProblemDetails。这个机构的原理很简单。首先,我们需要创建一个自定义异常:

public class InvalidCommandException : Exception
{
    public string Details { get; }
    public InvalidCommandException(string message, string details) : base(message)
    {
        this.Details = details;
    }
}

其次,我们必须创建自己的Problem Details类:

public class InvalidCommandProblemDetails : Microsoft.AspNetCore.Mvc.ProblemDetails
{
    public InvalidCommandProblemDetails(InvalidCommandException exception)
    {
        this.Title = exception.Message;
        this.Status = StatusCodes.Status400BadRequest;
        this.Detail = exception.Details;
        this.Type = "https://somedomain/validation-error";
    }
}

最后要做的是添加问题细节中间件,在启动时定义InvalidCommandExceptionInvalidCommandProblemDetails类之间的映射:

services.AddProblemDetails(x =>
{
    x.Map<InvalidCommandException>(ex => new InvalidCommandProblemDetails(ex));
});

....

app.UseProblemDetails();

在改变CommandValidationBehavior(抛出InvalidCommandException而不是Exception)之后,我们返回了与标准兼容的内容:

image

总结

在这篇文章中,我描述了:

  • 什么是数据验证以及数据验证的位置
  • HTTP APIs的问题细节是什么,如何实现
  • 在应用程序服务层实现数据验证的3种方法:不使用任何模式和工具,使用FluentValidation库,最后使用管道模式和MediatR Behaviors。

源代码

如果你想看到完整的工作示例——查看我的GitHub存储库

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值