🐥note.

小鳥とMicrosoft <3 なエンジニアの技術Blog📚

MediatRのIPipelineBehaviorとFluentValidationでCQRSのQueryをValidationする

先日の記事の続きです。

MediatRのIPipelineBehavior<,>FluentValidationというライブラリを使用してQueryのValidationを行う方法です。

目次

サンプルコード

本エントリで作成したコードはGitHub上にアップロードしてあります。

github.com

元となるQueryの作成

元となるCQRSのQueryをMediatRライブラリで作成します。

Validation対象Query

.NET Coreの適当な汎用ホストProjectに下記コマンドでMediatRの汎用ホスト対応版ライブラリを追加します。

dotnet add package MediatR.Extensions.Microsoft.DependencyInjection

続いてCQRSのQuery一式を作成します。
今回はIdを指定してUserを取得するGetUserByIdQueryを作成します。

// Request Query
public class GetUserByIdQuery : IRequest<User>
{
    public Guid Id { get; set; }
}

// Response
public class User
{
    public Guid Id { get; set; }
    public string Name { get; set; }
}

// Query Handler
public class GetUserByIdQueryHandler : IRequestHandler<GetUserByIdQuery, User>
{
    private readonly ILogger<GetUserByIdQueryHandler> logger;
    public GetUserByIdQueryHandler(ILogger<GetUserByIdQueryHandler> logger)
    {
        this.logger = logger;
    }

    public Task<User> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
    {
        logger.LogInformation("Handling...");

        // 本来はUserの検索処理をここに記述するが割愛

        var user = new User { Id = request.Id, Name = "ほげほげ" };
        return Task.FromResult(user);
    }
}

QueryをIoC Containerへ登録

作成したQueryをIoC Containerに登録します。

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                // Mediator
                services.AddMediatR(typeof(Program).Assembly);
                // Service Worker
                services.AddHostedService<Worker>();
            });
}

WorkerでQueryを実行してみる

作成したQueryを実行してみます。

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;
    private readonly IMediator _mediator;

    public Worker(ILogger<Worker> logger, IMediator mediator)
    {
        _mediator = mediator;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Query
        var user = await _mediator.Send(new GetUserByIdQuery() { Id = Guid.NewGuid() });

        _logger.LogInformation($"Id : {user.Id.ToString()}");
        _logger.LogInformation($"Name : {user.Name?.ToString()}");
    }
}

f:id:piyo_esq:20191224215440p:plain
Query 実行結果

ちゃんと動いていますね。

IPipelineBehaviorによるValidationの追加

Validation機能を追加していきます。

IPipelineBehavior<,>の作成

まずはIPiepelineBehavior<,>を追加してみます。
MediatRの公式のドキュメントを参考にValidationBehavior.csを作成します。

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
    private readonly ILogger<ValidationBehavior<TRequest, TResponse>> logger;
    public ValidationBehavior(ILogger<ValidationBehavior<TRequest, TResponse>> logger)
    {
        this.logger = logger;
    }

    public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        // Pre Processor
        logger.LogInformation("Handle Start.");

        // Execute Handler
        var response = next();

        // Post Processor
        logger.LogInformation("Handle End.");
        
        return response;
    }

IPipelineBehaviorをIoC Containerに登録して実行

作成したValidationBehaviorIoC Containerに登録します。

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureServices((hostContext, services) =>
            {
                // Mediator
                services.AddMediatR(typeof(Program).Assembly);
                // IPipelineBehavior
                services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
                // Service Worker
                services.AddHostedService<Worker>();
            });
}

この状態でアプリを実行すると、こんな感じになります。

f:id:piyo_esq:20191224215521p:plain
IPipelineBehavior作成後 Query実行結果

ValidationBehavior(Pre Processor) → CreateUserQueryHandlerValidationBehavior(Post Processor)Workerの順に実行されていますね!
GoFDecoratorパターンのようなイメージでしょうか。

特定のQueryのみValidationする

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>だと全てのQueryで本PipelineBehaviorが実行されてしまいます。

特定の型のTRequestのみに絞りたい場合は以下のようにwhere TRequest : hogehogeを追記することでTRequestの型を指定することができます。
※ここら辺はC#Generic type Constraintの話ですね。

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : GetUserByIdQuery
{
    // 省略...   
}

ただ、where TRequest : hogehogeQueryでQueryのValidationを書いていくのは大変そうですね。

FluentValidationによる"流れるような検証"

定番のFluentValidationFluentValidation.DependencyInjectionExtensionsを使用して楽しましょう。

FluentValidationライブラリの追加

下記コマンドでProjectにライブラリを追加します。

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

Validationの実装

GetUserByIdQueryに対する検証処理を記述します。
検証を行うValidatorはAbstractValidator<T>を継承します。

public class GetUserByIdQueryValidator : AbstractValidator<GetUserByIdQuery>
{
    public GetUserByIdQueryValidator()
    {
        RuleFor(x => x.Id).NotEqual(_ => Guid.Empty);
    }
}

GetUserByIdQueryId != Guid.Emptyという条件を満たす必要があります。
逆に言うと、Id == Guid.Emptyが真の場合、FluentValidation.ValidationExceptionという例外が投げられます。

ValidatorをIPipelineBehavior<,>に組込む

先程作成したValidationBehaviorFluentValidationを使用して作成したGetUserByIdQueryValidatorを組込みます。

といっても、IEnumerable<IValidator<TRequest>>validatorとしてConstructor Injectionし、requestからValidationContextを作成してValidationContextに対応する検証処理を実行させます。

以下がそのコードです。

    public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    {
        private readonly ILogger<ValidationBehavior<TRequest, TResponse>> logger;
        private readonly IEnumerable<IValidator<TRequest>> validator;

        public ValidationBehavior(ILogger<ValidationBehavior<TRequest, TResponse>> logger,
                                  IEnumerable<IValidator<TRequest>> validator)
        {
            this.logger = logger;
            this.validator = validator;
        }

        public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
        {
            // Pre Proseccor
            logger.LogInformation("Handle Start.");

            // Validation
            var context = new ValidationContext(request);
            var failures = validator
                            .Select(s => s.Validate(context))
                            .SelectMany(s => s.Errors)
                            .Where(w => w != null)
                            .ToList();

            if (failures.Any())
            {
                throw new ValidationException(failures);
            }

            // Execute Handler
            var response = next();

            // Post Proseccor
            logger.LogInformation("Handle End.");

            return response;
        }
    }

ValidatorをIoC Containerに登録

services.AddValidatorsFromAssemblyでValidatorをIoC Containerに登録します。
Assembly Scanしてくれるのが便利ですね!

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureServices((hostContext, services) =>
        {
            services.AddHostedService<Worker>();
            services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
            services.AddValidatorsFromAssembly(typeof(Program).Assembly);
            services.AddMediatR(typeof(Program).Assembly);
        });

FluentValidationによるValidation結果

Validationに引っかかるように、IdGuid.Emptyを与えQueryを実行します。

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    // Query
    var response = await _mediator.Send(new GetUserByIdQuery() { Id = Guid.Empty });

    _logger.LogInformation($"Id : {response.Id.ToString()}");
    _logger.LogInformation($"Name : {response.Name?.ToString()}");
}

実行結果は以下の通りです。

f:id:piyo_esq:20191224215659p:plain
FluentValidationによるValidation結果

PS C:\Users\piyoe\work\dotnet\CQRSSample\src> dotnet run info: CQRSSample.Services.Validatior.ValidationBehavior[0] Handle Start. Unhandled exception. FluentValidation.ValidationException: Validation failed: -- Id: 'Id' は '00000000-0000-0000-0000-000000000000' と等しくなってはなりません。 at CQRSSample.Services.Validatior.ValidationBehavior2.Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate1 next) in C:\Users\piyoe\work\dotnet\CQRSSample\src\Services\Validatior\ValidationBehavior.cs:line 39 at MediatR.Internal.RequestHandlerWrapperImpl2.<>c__DisplayClass0_1.<Handle>b__2() at MediatR.Internal.RequestHandlerWrapperImpl2.Handle(IRequest1 request, CancellationToken cancellationToken, ServiceFactory serviceFactory) at MediatR.Mediator.Send[TResponse](IRequest1 request, CancellationToken cancellationToken) at CQRSSample.Worker.ExecuteAsync(CancellationToken stoppingToken) in C:\Users\piyoe\work\dotnet\CQRSSample\src\Worker.cs:line 28
at Microsoft.Extensions.Hosting.Internal.Host.StartAsync(CancellationToken cancellationToken) at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token) at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token) at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host) at CQRSSample.Program.Main(String[] args) in C:\Users\piyoe\work\dotnet\CQRSSample\src\Program.cs:line 13 PS C:\Users\piyoe\work\dotnet\CQRSSample\src>

Validatorが動いてくれました!

備考: QueryのPreProcessorBehaviorとPostProcessorBehavior

IPipelineBehavior<,>はPre/Post両方いっぺんに書けますが、Pre/Postをそれぞれ分離して記述することも可能なようです。

Pre ProcessorIRequestPreProcessor<T>
Post ProcessorIRequestPostProcessor<T>というInterfaceが用意されています。

以下にサンプルを掲載します。

// Pre Processor
public class ValidationRequestPreProcessorBehavior : IRequestPreProcessor<GetUserByIdQuery>
{
    public Task Process(GetUserByIdQuery request, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}

// Post Processotr
public class ValidationRequestPostProcessorBehavior : IRequestPostProcessor<GetUserByIdQuery, User>
{
    public Task Process(GetUserByIdQuery request, User response, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}

おわり

IPipelineBehavior<,>の基本的な使い方でした。

Validation用途だけでなくQueryのPre Processorにキャッシュ処理を追加したり、Query毎にLogを吐くといった横断的関心事を吸収したり、ちょっとした用途に使えるのが好みです。

例外発生時に日本語でエラーが出るのもいい感じなポイントですね。

以上