使用DDD模式实现简单API(8/3)

REST API数据验证

介绍

这一次我将描述如何保护我们的应用程序,验证包含无效数据的请求,并返回相关的信息和状态到客户端。在这篇文章中,我将介绍如何实现这两点。

数据验证

定义

什么是真正的验证呢?我在 UNECE Data Editing Group上找到了最好的定义:

An activity aimed at verifying whether the value of a data item comes from the given (finite or infinite) set of acceptable values.

根据定义,我们需要对从外部资源发送到我们应用的数据项进行验证并检查其是否有效。我们需要对系统内执行的每个数据项定义校验规则。

数据验证与业务验证

我想要强调,数据验证和业务验证是完全不同的概念,数据验证集中于一个原子数据项。业务验证则是一个更加宽广的概念,更接近于验证业务是如何运作和行动的。所以说业务验证更重视行为,当然行为的验证也依赖于数据,但是是在一个更大的范围内。

数据验证的例子:

  • 产品订单的数量不能是负数或0
  • 产品订单的数量应该是一个数字
  • 订单的汇率应该是规定汇率中的一个

业务验证的例子:

  • 下单者的年龄不能小于产品规定的最小购买年龄
  • 一个顾客一天下单不能超过两次

返回相关信息

如果我们在验证过程中发现请求的数据违背了某些规则,我们必须停止进程并返回相应的信息到客户端,我们应该遵循下面的原则:

  • 我们应该尽可能快的返回信息
  • 验证错误的原因应该解释清楚,便于客户端理解
  • 为了安全因素,不应该返回具体的技术错误

Problem Detail的应用

由于错误消息返回的格式非常统一,一种特殊的标准被创造出来描述这种情况。下面是官方的描述:

This document defines a “problem detail” as a way to carry machine-readable details of errors in an HTTP response to avoid the need to define new error response formats for HTTP APIs.

这个标准介绍了Problem Details的JSON对象,当返回验证错误信息时,将被包括在返回信息中。这个经典的对象包含5个参数:

  • problem type
  • title
  • Http status code
  • details of error
  • instance(pointer to specific occurrence)

当然我们可以自己添加属性,但是基本属性应该保持一样。使用Problem Details可以使API易于理解和使用,对于进一步的介绍,可以直接阅读官方文档。

数据验证的位置

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

  • UI层,这个用户输入的入口,数据将在客户端被验证,比如Web应用使用JS进行验证
  • 应用的逻辑、服务层,数据在服务端的特殊服务或者命令处理器验证
  • 数据库,这是请求执行的终点,最后进行数据验证的地方

在这篇文章里我忽略了UI层和数据库层,重点讲如何在应用服务层对数据进行验证。

实现数据验证

假设我们有一个命令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件事情:

  • CustomerId不能为空
  • Products list不能为空
  • 每个产品的数量大于0
  • 每个产品的货币必须是USD或EUR

下面展示实现这个功能的三种方法,从易到难:

  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());
        }
    }
}

下图是无效执行时的异常结果:

这个方法有两个缺点:

  • 首先,它使得我们写了很多简单、模板化的代码-判空、默认值判断、与list对比值等。
  • 第二,我们违背了概念分离原则,因为我们将数据的验证和执行逻辑混在了一起。我们先看重复代码问题。
  1. 使用FluentValidation库进行验证

我们不需要自己重造轮子,所以最好的办法是使用第三方库。在.NET中,FluentValidation是一个非常好的库,有良好的API和许多特性,下面是如何使用FluentValidation验证命令:

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");
    }
}

验证方法如下:

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());
    }
}

验证的结果和之前一样,但是现在我们的验证逻辑更加干净。最后我们要将验证逻辑与命令处理器完全解耦。

  1. 使用管道模式验证

为了将验证逻辑与执行逻辑解耦,且先于执行逻辑运行,我们采用管道模式,这里我们使用MediatR库的MediatR Behaviors概念,代码实现如下:

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();
    }
}

接下来是将MediatR Behavior在IOC容器中注册:

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<,>));
    }
}

这样我们使用优雅的方式实现了概念分离和快速响应的数据验证。下面在消息返回到客户端之前,我们还需要对消息进行处理。

实现Problem Details标准

和实现验证逻辑一样,这里我们也使用了一个第三方库-Problem Details。它的原理很简单,首先,创建一个自定义的异常:

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";
    }
}

最后,将Problem Details中间件添加到StartUp,并将上述的自定义异常和类匹配:

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

....

app.UseProblemDetails();

修改CommandValidationBehavior中的代码,将抛出的异常改为我们创建的自定义异常,最后返回的异常数据如下:

总结

在这篇文章里描述了:

  1. 什么是数据验证和它的位置
  2. 什么是Problem Details,以及如何实现它
  3. 三种实现数据验证的方法
posted @ 2019-11-15 17:50  东方未  阅读(186)  评论(0)    收藏  举报