将服务层与验证层分离

39
我目前有一个基于ASP.NET网站的文章使用服务层进行验证的服务层。
根据这个答案,这是一种不好的方法,因为服务逻辑与验证逻辑混合在一起,违反了单一职责原则。
我真的很喜欢提供的替代方案,但在重构我的代码时,我遇到了一个无法解决的问题。
考虑以下服务接口:
interface IPurchaseOrderService
{
    void CreatePurchaseOrder(string partNumber, string supplierName);
}

根据链接的答案,以下是具体的实现:

public class PurchaseOrderService : IPurchaseOrderService
{
    public void CreatePurchaseOrder(string partNumber, string supplierName)
    {
        var po = new PurchaseOrder
        {
            Part = PartsRepository.FirstOrDefault(p => p.Number == partNumber),
            Supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName),
            // Other properties omitted for brevity...
        };

        validationProvider.Validate(po);
        purchaseOrderRepository.Add(po);
        unitOfWork.Savechanges();
    }
}

传递给验证器的PurchaseOrder对象还需要两个其他实体:PartSupplier(在此示例中,假设PO仅具有单个零件)。
如果用户提供的详细信息不对应于数据库中的实体,则PartSupplier对象都可以为null,这将要求验证器抛出异常。
我遇到的问题是,在此阶段,验证器已经失去了上下文信息(零件号和供应商名称),因此无法向用户报告准确的错误。我能提供的最佳错误类似于“购买订单必须有关联的零件”,但这对用户来说没有意义,因为他们确实提供了零件号(只是不存在于数据库中)。
使用ASP.NET文章中的服务类,我正在执行以下操作:
public void CreatePurchaseOrder(string partNumber, string supplierName)
{
    var part = PartsRepository.FirstOrDefault(p => p.Number == partNumber);
    if (part == null)
    {
        validationDictionary.AddError("", 
            string.Format("Part number {0} does not exist.", partNumber);
    }

    var supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName);
    if (supplier == null)
    {
        validationDictionary.AddError("",
            string.Format("Supplier named {0} does not exist.", supplierName);
    }

    var po = new PurchaseOrder
    {
        Part = part,
        Supplier = supplier,
    };

    purchaseOrderRepository.Add(po);
    unitOfWork.Savechanges();
}

这使我能够向用户提供更好的验证信息,但意味着验证逻辑直接包含在服务类中,违反了单一责任原则(代码也在服务类之间重复)。
有没有办法做到两者兼顾?我能否将服务层与验证层分离,同时仍提供相同级别的错误信息?
1个回答

69

简短回答:

你正在验证错误的东西。

非常长的回答:

你试图验证一个PurchaseOrder,但那是一个实现细节。相反,你应该验证的是操作本身,也就是partNumbersupplierName参数。

仅仅验证这两个参数会很尴尬,但这是由于你的设计问题——你缺少了一个抽象。

长话短说,问题出在你的IPurchaseOrderService接口上。它不应该接受两个字符串参数,而是一个单一的参数(一个Parameter Object)。让我们称这个参数对象为CreatePurchaseOrder

public class CreatePurchaseOrder
{
    public string PartNumber;
    public string SupplierName;
}

通过修改的IPurchaseOrderService接口:
interface IPurchaseOrderService
{
    void CreatePurchaseOrder(CreatePurchaseOrder command);
}
CreatePurchaseOrder 参数对象包装了原始参数。该参数对象是描述创建采购订单意图的消息。换句话说,它是一个命令。
使用这个命令,您可以创建一个 IValidator<CreatePurchaseOrder> 实现,可以进行所有正确的验证,包括检查适当的零件供应商的存在并报告用户友好的错误消息。
但是为什么 IPurchaseOrderService 负责验证?验证是一个横切关注点,您应该避免将其与业务逻辑混合在一起。相反,您可以定义一个装饰器来处理这个问题:
public class ValidationPurchaseOrderServiceDecorator : IPurchaseOrderService
{
    private readonly IValidator<CreatePurchaseOrder> validator;
    private readonly IPurchaseOrderService decoratee;

    public ValidationPurchaseOrderServiceDecorator(
        IValidator<CreatePurchaseOrder> validator,
        IPurchaseOrderService decoratee)
    {
        this.validator = validator;
        this.decoratee = decoratee;
    }

    public void CreatePurchaseOrder(CreatePurchaseOrder command)
    {
        this.validator.Validate(command);
        this.decoratee.CreatePurchaseOrder(command);
    }
}

这样,您只需简单地将真正的PurchaseOrderService包装起来,就可以添加验证功能。
var service =
    new ValidationPurchaseOrderServiceDecorator(
        new CreatePurchaseOrderValidator(),
        new PurchaseOrderService());

问题,当然,这种方法的问题是为系统中的每个服务定义这样的装饰器类会非常尴尬。这将导致严重的代码发布。
但问题是由另一个缺点引起的。为每个特定服务(如IPurchaseOrderService)定义一个接口通常是有问题的。您已经定义了CreatePurchaseOrder,因此已经有了这样的定义。现在,您可以为系统中的所有业务操作定义一个单一的抽象:
public interface ICommandHandler<TCommand>
{
    void Handle(TCommand command);
}

通过这种抽象,您现在可以将PurchaseOrderService重构为以下形式:
public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
    public void Handle(CreatePurchaseOrder command)
    {
        var po = new PurchaseOrder
        {
            Part = ...,
            Supplier = ...,
        };

        unitOfWork.Savechanges();
    }
}

使用这个设计,您现在可以定义一个单一的通用装饰器来处理系统中的每个业务操作的所有验证。
public class ValidationCommandHandlerDecorator<T> : ICommandHandler<T>
{
    private readonly IValidator<T> validator;
    private readonly ICommandHandler<T> decoratee;

    ValidationCommandHandlerDecorator(
        IValidator<T> validator, ICommandHandler<T> decoratee)
    {
        this.validator = validator;
        this.decoratee = decoratee;
    }

    void Handle(T command)
    {
        var errors = this.validator.Validate(command).ToArray();
        
        if (errors.Any())
        {
            throw new ValidationException(errors);
        }
        
        this.decoratee.Handle(command);
    }
}

请注意,这个装饰器几乎与之前定义的ValidationPurchaseOrderServiceDecorator相同,但现在是一个通用类。这个装饰器可以包裹在你的新服务类周围。
var service =
    new ValidationCommandHandlerDecorator<PurchaseOrderCommand>(
        new CreatePurchaseOrderValidator(),
        new CreatePurchaseOrderHandler());

但由于这个装饰器是通用的,您可以将其包装在系统中的每个命令处理程序周围。哇!这就是DRY的好处?

这种设计还使得以后添加横切关注点变得非常容易。例如,您的服务当前似乎负责在工作单元上调用SaveChanges。这也可以被视为横切关注点,并且很容易提取到装饰器中。这样,您的服务类变得更简单,剩下较少的代码需要测试。

CreatePurchaseOrder验证器可能如下所示:

public sealed class CreatePurchaseOrderValidator : IValidator<CreatePurchaseOrder>
{
    private readonly IRepository<Part> partsRepository;
    private readonly IRepository<Supplier> supplierRepository;

    public CreatePurchaseOrderValidator(
        IRepository<Part> partsRepository,
        IRepository<Supplier> supplierRepository)
    {
        this.partsRepository = partsRepository;
        this.supplierRepository = supplierRepository;
    }

    protected override IEnumerable<ValidationResult> Validate(
        CreatePurchaseOrder command)
    {
        var part = this.partsRepository.GetByNumber(command.PartNumber);
        
        if (part == null)
        {
            yield return new ValidationResult("Part Number", 
                $"Part number {command.PartNumber} does not exist.");
        }

        var supplier = this.supplierRepository.GetByName(command.SupplierName);
        
        if (supplier == null)
        {
            yield return new ValidationResult("Supplier Name", 
                $"Supplier named {command.SupplierName} does not exist.");
        }
    }
}

你的命令处理程序应该像这样:
public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
    private readonly IUnitOfWork uow;

    public CreatePurchaseOrderHandler(IUnitOfWork uow)
    {
        this.uow = uow;
    }

    public void Handle(CreatePurchaseOrder command)
    {
        var order = new PurchaseOrder
        {
            Part = this.uow.Parts.Get(p => p.Number == partNumber),
            Supplier = this.uow.Suppliers.Get(p => p.Name == supplierName),
            // Other properties omitted for brevity...
        };

        this.uow.PurchaseOrders.Add(order);
    }
}

请注意,命令消息将成为您的领域的一部分。用例和命令之间存在一对一的映射关系,而不是验证实体,这些实体将成为实现细节。命令成为合同并进行验证。
请注意,如果您的命令包含尽可能多的ID,那么您的生活可能会变得更加轻松。因此,您的系统可以从以下方式定义一个命令来获益:
public class CreatePurchaseOrder
{
    public int PartId;
    public int SupplierId;
}

当你这样做时,就不需要检查给定名称的部件是否存在。表示层(或外部系统)传递给您一个ID,因此您不再需要验证该部件的存在性。当然,如果没有该ID对应的部件,则命令处理程序应该失败,但在这种情况下,要么是编程错误,要么是并发冲突。无论哪种情况,都不需要向客户端返回表达性友好的验证错误信息。
然而,这将问题转移到了表示层获取正确的ID上。在表示层中,用户必须从列表中选择一个部件,以便我们获取该部件的ID。但我还是觉得这样做可以使系统更加简单和可扩展。
它还解决了您所提到的文章评论部分中提到的大部分问题,例如:
- 实体序列化的问题消失了,因为命令可以很容易地进行序列化和模型绑定。 - DataAnnotation属性可以轻松应用于命令,从而实现了客户端(Javascript)验证。 - 可以对所有命令处理程序应用装饰器,将整个操作包装在数据库事务中。 - 它消除了控制器和服务层之间的循环引用(通过控制器的ModelState),不再需要控制器创建服务类的实例。
如果你想更多了解这种设计类型,绝对应该查看this article

1
+1 谢谢,非常感谢。我需要离开一下,评估这些信息,因为有很多需要消化的内容。顺便说一句,我目前正在考虑从 Ninject 切换到 Simple Injector。我已经读到了关于性能的好评,但是让我决定的是 Simple Injector 的文档要好得多。 - Benjamin Gale
@Steven:我认为在你的代码的第六部分,"CreatePurchaseOrder" 方法名应该改成 "Handle",我是正确的吗? - Masoud
@Masoud:你说得对。感谢你的注意。我已经更新了我的答案。 - Steven
@Steven:如果一个命令未通过验证,CommandHandler会抛出异常吗?之后返回的验证结果是否可访问?我们受到了ICommandHandler<T>模式的启发,但我们有一个单独的验证类,它返回IEnumerable<IErrorMessage>(然后具有用于在视图中显示的MessageFormatters)。我们在每次调用命令处理程序之前运行此操作。我们假设如果这个验证成功,则命令处理程序中的异常很少需要担心。无法弄清如何使用装饰器来包装CommandHandler,但仍然可以访问验证结果。 - GraemeMiller
如果任何逻辑片段不能实现其承诺,它应该抛出异常。命令处理程序不应该悄悄地失败,因此您绝对应该抛出异常。装饰器必须返回与命令相同的数据(这是相同的契约),因此应使用异常返回验证结果。看一下DataAnnotations的“ValidationException”;它采用相同的方法。 - Steven
显示剩余7条评论

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