Contents

CQRS與MediatR(五)

CQRS與MediatR(五)

有些時候我們想在命令執行前後統一做額外的事情,例如每個命令執行前驗證資料欄位、紀錄請求者,或是命令執行後,統一執行資料庫交易等。若是使用自訂的Command,通常會使用裝飾者模式(Decorator Pattern)做處理,而 MediatR 則使用管線的概念協助。

MediatR管線:

  • IRequestPreProcessor<> 請求執行前的預處理
  • IRequestPostProcessor<,> 請求執行後的再處理
  • IPipelineBehavior<,> 自訂管線行為

以下是我們目前的程式碼,每次執行命令前,我們都會使用 FluentValidation,協助我們驗證資料欄位是否正確,並使用方法注入,更好的顯示程式碼意圖。

然而隨著時間流逝,相似的程式碼,會逐步在每個請求中重複出現,所以我們希望能透過 MediatR 管線,來協助我們自動執行驗證。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly IMediator _mediator;

    public WeatherForecastController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<IActionResult> CreateWeatherForecast(
        [FromBody] CreateWeatherForecastRequest request,
        [FromServices] IValidator<CreateWeatherForecastCommand> validator)
    {
        var command = new CreateWeatherForecastCommand
        {
            Nation = request.Nation,
            City = request.City,
            Date = request.Date,
            TemperatureC = request.TemperatureC,
            Summary = request.Summary,
        };

        var validate = await validator.ValidateAsync(command);
        if (validate.IsValid is false) return BadRequest(validate.ToDictionary());

        var result = await _mediator.Send(command);

        return result ? Ok() : BadRequest();
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> UpdateWeatherForecast(int id,
        [FromBody] UpdateWeatherForecastRequest request,
        [FromServices] IValidator<UpdateWeatherForecastCommand> validator)
    {
        if (id != request.WeatherForecastId) return BadRequest();

        var command = new UpdateWeatherForecastCommand
        {
            WeatherForecastId = request.WeatherForecastId,
            Nation = request.Nation,
            City = request.City,
            Date = request.Date,
            TemperatureC = request.TemperatureC,
            Summary = request.Summary,
        };

        var validate = await validator.ValidateAsync(command);
        if (validate.IsValid is false) return BadRequest(validate.ToDictionary());

        var result = await _mediator.Send(command);

        return result ? Ok() : BadRequest();
    }
}

新增ValidationBehavior並實作IPipelineBehavior

/static/CQRS與MediatR(五)_0fd590aad4044115b45d94e8f2f4eb3b/2023-10-12_17-22-29.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 1.只有當透過管道的請求是 Command 命令時,我們才允許執行此 IPipelineBehavior,所以我們使用where進行條件拘束。
public class ValidationBehavior<TRequest, TReponse> : IPipelineBehavior<TRequest, TReponse> where TRequest : ICommand<TReponse>
{
    // 2.查詢所有實作AbstractValidator類別
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TReponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TReponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);

        // 3.執行驗證,並只取出驗證錯誤的部分
        var errorsDictionary = _validators
            .Select(validator => validator.Validate(context))
            .Where(validationResult => !validationResult.IsValid)
            .SelectMany(validationResult => validationResult.Errors)
            .GroupBy(
                x => x.PropertyName,
                x => x.ErrorMessage,
                (propertyName, errorMessages) => new
                {
                    Key = propertyName,
                    Values = errorMessages.Distinct().ToArray()
                })
            .ToDictionary(x => x.Key, x => x.Values);

        // 4.如果有任何驗證錯誤,拋出ValidationException,阻止進一步執行
        if (errorsDictionary.Any())
        {
            throw new Exceptions.ValidationException(errorsDictionary);
        }

        return await next();
    }
}

新增ValidationException記錄錯誤

/static/CQRS與MediatR(五)_0fd590aad4044115b45d94e8f2f4eb3b/2023-10-12_17-24-09.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class ValidationException : Exception
{
    public IDictionary<string, string[]> Errros { get; }

    public ValidationException(IDictionary<string, string[]> errors)
    {
        Errros = errors;
    }
}

public record ValidationError(string PropertyName, string ErrorMessage);

新增ExceptionHandlingMiddleware處理驗證異常

/static/CQRS與MediatR(五)_0fd590aad4044115b45d94e8f2f4eb3b/2023-10-12_17-25-38.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class ExceptionHandlingMiddleware : IMiddleware
{
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

		public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception e)
        {
            _logger.LogError(e, e.Message);
            await HandleExceptionAsync(context, e);
        }
    }

    private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var exceptionDetails = GetExceptionDetails(exception);

        var problemDetails = new ProblemDetails
        {
            Status = exceptionDetails.StatusCode,
            Type = exceptionDetails.Type,
            Title = exceptionDetails.Title,
            Detail = exceptionDetails.Details,
        };

        if (exceptionDetails.Errors != null)
        {
            problemDetails.Extensions["errors"] = exceptionDetails.Errors;
        }

        context.Response.ContentType = "application/json";
        context.Response.StatusCode = exceptionDetails.StatusCode;
        await context.Response.WriteAsJsonAsync(problemDetails);
    }

    private static ExceptionDetails GetExceptionDetails(Exception exception)
    {
		// 驗證錯誤統一使用400錯誤,其他則使用500,顯示伺服器錯誤
        return exception switch
        {
            ValidationException validationException => new ExceptionDetails(
                StatusCodes.Status400BadRequest,
                "ValidationFailure",
                "Validation error",
                "One or more validation errors has occurred",
                validationException.Errros),
            _ => new ExceptionDetails(
                StatusCodes.Status500InternalServerError,
                "ServerError",
                "Server error",
                "An unexpected error has occured",
                null)
        };
    }
}

internal record ExceptionDetails(int StatusCode, string Type, string Title, string Details, IDictionary<string, string[]> Errors);

IServiceCollection註冊

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
builder.Services.AddMediatR(cf =>
{
    cf.RegisterServicesFromAssembly(applicationAssemblies);

    cf.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddTransient<ExceptionHandlingMiddleware>();

// ...
app.UseMiddleware<ExceptionHandlingMiddleware>();

Controller移除原本舊的驗證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private readonly IMediator _mediator;

    public WeatherForecastController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<IActionResult> CreateWeatherForecast(
        [FromBody] CreateWeatherForecastRequest request)
    {
        var command = new CreateWeatherForecastCommand
        {
            Nation = request.Nation,
            City = request.City,
            Date = request.Date,
            TemperatureC = request.TemperatureC,
            Summary = request.Summary,
        };

        var result = await _mediator.Send(command);

        return result ? Ok() : BadRequest();
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> UpdateWeatherForecast(int id, 
        [FromBody] UpdateWeatherForecastRequest request)
    {
        if (id != request.WeatherForecastId) return BadRequest();

        var command = new UpdateWeatherForecastCommand
        {
            WeatherForecastId = request.WeatherForecastId,
            Nation = request.Nation,
            City = request.City,
            Date = request.Date,
            TemperatureC = request.TemperatureC,
            Summary = request.Summary,
        };

        var result = await _mediator.Send(command);

        return result ? Ok() : BadRequest();
    }
}

結果

/static/CQRS與MediatR(五)_0fd590aad4044115b45d94e8f2f4eb3b/2023-10-12_17-34-17.png

參考

CQRS Validation with MediatR Pipeline and FluentValidation

https://www.milanjovanovic.tech/blog/cqrs-validation-with-mediatr-pipeline-and-fluentvalidation

CQRS Validation Pipeline with MediatR and FluentValidation

https://code-maze.com/cqrs-mediatr-fluentvalidation/