37

現在、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バリデーターに渡されるオブジェクトには、他に 2 つのエンティティも必要です(この例では、PO に 1 つの部分しかないPartSupplier仮定します)。

ユーザーによって提供された詳細が、バリデーターが例外をスローする必要があるデータベース内のエンティティーに対応していない場合、 オブジェクトPartとオブジェクトの両方が null になる可能性があります。Supplier

私が抱えている問題は、この段階でバリデーターがコンテキスト情報 (部品番号とサプライヤー名) を失っているため、正確なエラーをユーザーに報告できないことです。私が提供できる最良のエラーは、 「発注書には関連する部品が必要です」という行に沿ったものです。これは、部品番号を提供したため、ユーザーには意味がありません (データベースに存在しないだけです)。

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

これにより、はるかに優れた検証情報をユーザーに提供できますが、検証ロジックがサービス クラスに直接含まれていることになり、単一責任の原則に違反します (サービス クラス間でコードも複製されます)。

両方の長所を活かす方法はありますか? 同じレベルのエラー情報を提供しながら、サービス層を検証層から分離できますか?

4

1 に答える 1

65

簡潔な答え:

あなたは間違ったことを検証しています。

非常に長い答え:

を検証しようとしていますPurchaseOrderが、それは実装の詳細です。代わりに、検証する必要があるのは操作自体です。この場合はパラメーターpartNumbersupplierNameパラメーターです。

これら 2 つのパラメーターを単独で検証するのは面倒ですが、これは設計が原因であり、抽象化が欠けています。

要するに、問題はIPurchaseOrderServiceインターフェースにあります。2 つの文字列引数を取るのではなく、1 つの引数 ( Parameter Object ) を取るべきです。これを 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;

    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ので、既にそのような定義があります。システム内のすべてのビジネス オペレーションに対して 1 つの抽象化を定義できるようになりました。

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

この設計により、1 つの汎用デコレーターを定義して、システム内のすべてのビジネス オペレーションのすべての検証を処理できるようになりました。

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

コマンド メッセージはドメインの一部になることに注意してください。ユース ケースとコマンドの間には 1 対 1 のマッピングがあり、エンティティを検証する代わりに、それらのエンティティが実装の詳細になります。コマンドはコントラクトになり、検証されます。

コマンドにできるだけ多くの ID が含まれていると、作業がずっと楽になることに注意してください。したがって、システムは次のようにコマンドを定義することで利益を得ることができます:

public class CreatePurchaseOrder
{
    public int PartId;
    public int SupplierId;
}

これを行うと、指定された名前のパーツが存在するかどうかを確認する必要がなくなります。プレゼンテーション層 (または外部システム) が ID を渡したので、その部分の存在を検証する必要はもうありません。もちろん、その ID による部分がない場合、コマンド ハンドラーは失敗するはずですが、その場合、プログラミング エラーまたは同時実行の競合が発生します。いずれの場合も、表現力豊かなユーザーフレンドリーな検証エラーをクライアントに伝える必要はありません。

ただし、これにより、適切な ID を取得するという問題がプレゼンテーション層に移されます。プレゼンテーション層では、ユーザーはリストからパーツを選択して、そのパーツの ID を取得する必要があります。それでも、システムをより簡単かつスケーラブルにするためにこれを経験しました。

また、参照している記事のコメント セクションに記載されている問題のほとんどを解決します。

  • コマンドを簡単にシリアル化してモデルをバインドできるため、エンティティのシリアル化の問題は解消されます。
  • DataAnnotation 属性はコマンドに簡単に適用でき、これによりクライアント側 (Javascript) の検証が可能になります。
  • デコレーターは、データベース トランザクションで完全な操作をラップするすべてのコマンド ハンドラーに適用できます。
  • (コントローラーの ModelState を介して) コントローラーとサービス層の間の循環参照が削除され、コントローラーがサービス クラスを新規作成する必要がなくなります。

このタイプのデザインについて詳しく知りたい場合は、この記事を必ずチェックしてください。

于 2013-05-28T14:45:38.397 に答える