系列文章目录
一、简单的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
- 应用程序逻辑/服务层——在服务器端的特定应用程序服务或命令处理程序中验证数据
- 数据库——这是请求处理的出口点,也是验证数据的最后时刻
在本文中,我省略了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件事:
- CustomerId不是空GUID。
- 产品列表不是空的
- 每个产品数量大于0
- 每个产品货币是美元或欧元
让我来展示这个问题的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());
}
}
}
无效命令执行的结果:
这不是很坏的方法,但有两个缺点。首先,它涉及到我们编写大量简单的样板代码——与空值,默认值,列表值等进行比较。其次,我们在这里失去了关注点分离的一部分,因为我们将验证逻辑与编排用例流混合在一起。让我们先看一下样板代码。
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";
}
}
最后要做的是添加问题细节中间件,在启动时定义InvalidCommandException和InvalidCommandProblemDetails类之间的映射:
services.AddProblemDetails(x =>
{
x.Map<InvalidCommandException>(ex => new InvalidCommandProblemDetails(ex));
});
....
app.UseProblemDetails();
在改变CommandValidationBehavior(抛出InvalidCommandException而不是Exception)之后,我们返回了与标准兼容的内容:
总结
在这篇文章中,我描述了:
- 什么是数据验证以及数据验证的位置
- HTTP APIs的问题细节是什么,如何实现
- 在应用程序服务层实现数据验证的3种方法:①不使用任何模式和工具,②使用FluentValidation库,最后③使用管道模式和MediatR Behaviors。
源代码
如果你想看到完整的工作示例——查看我的GitHub存储库