CQRS 패턴의 개념에 대해선 위 포스트를 참고해주세요 😄
CQRS 패턴을 적용한 MediatR과 FluentValidation
기본 예제 코드
MediatR
은 IRequest
인터페이스를 사용하여 명령과 쿼리를 모두 나타낸다. 사용 사례에서는 명령과 쿼리에 대해 별도의 추상화를 생성한다.
- 먼저
ICommand
인터페이스의 정의는 다음과 같다.
using MediatR;
namespace Application.Abstractions.Messaging
{
public interface ICommand<out TResponse> : IRequest<TResponse>
{
}
}
IQuery
인터페이스의 정의는 다음과 같다.
using MediatR;
namespace Application.Abstractions.Messaging
{
public interface IQuery<out TResponse> : IRequest<TResponse>
{
}
}
공변적임을 알리는 TResponse
키워드를 타입으로 선언한다.
이로 인해 일반 매개변수로 지정하는 것 보다 더 다양한 타입을 사용할 수 있다.
또한 완전성을 위해 명령/쿼리 처리기에 대한 별도의 추상화가 필요하다.
- Command 처리기의 정의
using MediatR;
namespace Application.Abstractions.Messaging
{
public interface ICommandHandler<in TCommand, TResponse> : RequestHandler<TCommand, TResponse>
where TCommand : ICommand<TResponse>
{
}
}
- Query 처리기의 정의
using MediatR;
namespace Application.Abstractions.Messaging
{
public interface IQueryHandler<in TQuery, TResponse> : IRequestHandler<TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
}
}
다음은 간단한 샘플 코드다. 모든 명령이 멱등성을 가져야한다. 멱등성이란, 한 번만 실행할 수 있음을 의미한다.
- ICommand 인터페이스를 확장해서 새로운
IIdempotentCommand
인터페이스를 정의한다.public interface IIdempotentCommand<out TResponse> : ICommand<TResponse> { Guid RequestId { get; set; } }
그 다음, 멱등성을 보장하기 위해 몇 가지 논리를 구현 해야한다.
또한, 명령 및 쿼리에 대한 추가 추상화를 사용하면 MediatR 파이프라인 내에서 필터링을 수행하는 기능을 제공하는데 이 두가지에 대해서는 이 포스트에서는 다루지 않는다.
FluentValidation
적용
FluentValidation
라이브러리는 클래스에 대한 사용자 정의 유효성 검사를 쉽게 정의할 수 있다. CQRS 패턴을 적용하기로 했으므로 명령에 대한 유효성 검사를 정의하는게 가장 합리적이다.
쿼리에 대한 유효성 검사는 정의하지 않아도 된다. 쿼리에는 데이터를 가져오기만 할뿐 어떤 동작도 포함되어 있지 않기 때문이다.
- 다음
UpdateUserCommand
코드를 살펴보자.public sealed record UpdateUserCommand(int UserId, string FirstName, string LastName) : ICommand<Unit>;<br>
이 명령을 사용해서 기존 사용자의 성과 이름을 업데이트 할 수 있다.
자세한 내용은 CQRS and MediatR in ASP.NET Core
UpdateUserCommand
에 대한 유효성 검사기를 구현하는 샘플 코드
public sealed class UpdateUserCommandValidator : AbstractValidator<UpdateUserCommand>
{
public UpdateUserCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
RuleFor(x => x.LastName).NotEmpty().MaximumLength(100);
}
}
UpdateUserCommandValidator
에서 명령 인수가 비어있지 않은지, 성과 이름의 최대 길이보다 작은지 확인한다.
자세한 설명에 대해선 링크에서 확인할 수 있다.
FluentValidation in ASP.NET Core
MediatR PipelineBehavior를 사용하여 데코레이터 만들기
CQRS 패턴은 명령과 쿼리를 사용해서 정보를 전달하고 응답을 받는다.
본질적으로 Request-Response 파이프라인을 나타낸다.
이를 통해 원래 요청을 실제로 수정하지 않고도 파이프라인을 통과하는 각 요청들에 추가 동작을 쉽게 도입할 수 있게 된다.
데코레이터 패턴이라는 이름의 이 기술은 흔히 사용되고 있다. 데코레이터 패턴을 사용하는 또 다른 예는 ASP.NET Core Middleware
개념이다.
MediatR은
미들웨어와 비슷한 개념으로, 다음과 같이IPipelineBehavior
로 정의할 수 있다.public interface IPipelineBehavior<in TRequest, TResponse> where TRequest : notnull { Task<TResponse> Handle( TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next); }
파이프라인 동작은 요청 인스턴스를 둘러싼 래퍼이며 구현 방법에 대한 유연성을 제공한다. 파이프라인 동작은 애플리케이션 교차 문제(cross-cutting concerns
)에 적합하다. 교차 문제로 좋은 예는 로깅, 캐싱, 유효성 검사가 있다.
유효성 검사 PipelineBehavior 만들기
CQRS 파이프라인에서 유효성 검사를 구현하기 위해 방금 언급한 MediatR
의 IPipelineBehavior
과 FluentValidation를
사용한다.
- 다음은
ValidationBehavior
코드의 내용이다.
public sealed class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : class, ICommand<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(
IEnumerable<IValidator<TRequest>> validators)
=> _validators = validators;
public async Task<TResponse> Handle(
TRequest request,
CancellationToken cancellationToken,
RequestHandlerDelegate<TResponse> next
)
{
if (!_validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var errorsDictionary = _validators
.Select(x => x.Validate(context))
.SelectMany(x => x.Errors)
.Where(x => x != null)
.GroupBy(
x => x.PropertyName,
x => x.ErrorMessage,
(propertyName, errorMessages) => new
{
Key = propertyName,
Values = errorMessages.Distinct().ToArray()
})
.ToDictionary(x => x.Key, x => x.Values);
if (errorsDictionary.Any())
{
throw new ValidationException(errorsDictionary);
}
return await next();
}
}
ValidationBehavior
코드의 설명
ICommand
인터페이스를 상속받은IPipelineBehavior
를 구현하기 위해where
절을 어떻게 사용하고 있는지 주목해보자.기본적으로 파이프라인을 통과하는 요청이명령
인 경우에만 이 실행을 허용한다.- 생성자에
IValidator
를 주입한다. FluentValidation 라이브러리는 프로젝트에서 특정 유형에 대한 모든AbstractValidator
를 검색해서 런타임에 인스턴스를 제공한다. 이게 프로젝트에서 구현한 실제 유효성 검사기를 적용하는 방법이다. - 유효성 검사 오류가 있는 경우 사전에 정의한
ValidationException
을 발생시킨다. 유효성 검사 오류로 인해 예외가 발생하면 파이프라인이 종료되어 더 이상 실행되지 않는다.
이 코드에서 누락된 것은 상위 레이어에서 예외를 처리하고 유저에게 의미있는 응답을 제공하는코드 뿐이다. 이에 대해 다음 파트에서 바로 알아보도록 한다.
유효성 검사 예외 처리
유효성 검사 오류가 발생할때 정의한 ValidationException
를 처리하기 위해 ASP.NET Core IMiddleware
인터페이스를 사용할 수 있다.
- 전역 예외 처리기를 구현해보기로 하자.
internal sealed 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 httpContext, Exception exception)
{
var statusCode = GetStatusCode(exception);
var response = new
{
title = GetTitle(exception),
status = statusCode,
detail = exception.Message,
errors = GetErrors(exception)
};
httpContext.Response.ContentType = "application/json";
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsync(
JsonSerializer.Serialize(response));
}
private static int GetStatusCode(Exception exception) =>
exception switch
{
BadRequestException => StatusCodes.Status400BadRequest,
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status422UnprocessableEnttity,
_ => StatusCodes.Status500InternalServerError
};
private static string GetTitle(Exception exception) =>
exception switch
{
ApplicationException applicationException => applicationException.Title,
_ => "Server Error"
};
private static IReadOnlyDictionary<string, string[]> GetErrors(Exception exception)
{
IReadOnlyDictionary<string, string[]> errors = null;
if (exception is ValidationException validationException)
{
errors = validationException.ErrorsDictionary;
}
return errors;
}
}
전역 에러 처리기에 대해선 Global Error Handling in ASP.NET Core Web API
의존성 주입 설정
애플리케이션을 실행하기 전에 모든 서비스를 DI 컨테이너에 등록했는지 확인해야한다.
builder.Services.AddMediatR(cfg
=> cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
MediatR에 명령과 쿼리를 등록하는 방법.
- .NET 6 미만 버전
Startup.cs
의ConfigureServices
다음 줄에 아래 코드를 추가한다.
services.AddMediatR(typeof(Application.AssemblyReference).Assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
- .NET 6 이상 버전 에서는
Program.cs
를 수정해야한다.
builder.Services.AddMediatR(typeof(Application.AssemblyReference).Assembly);
builder.Services.AddTransient(typeof(IPipelineBehavior<,>),
typeof(ValidationBehavior<,>));
첫번째 메서드 호출은 Application
어셈블리를 모든 명령, 쿼리, 핸들러를 DI 컨테이너에 추가한다.
두번째 메서드 호출은 ValidationBehavior
등록이다. 이 코드가 없으면 유효성 검사 파이프라인이 실행되지 않는다.
다음으로 FluentValidation
를 이용한 유효성 검사 추가 부분이다.
마지막으로 ConfigureServices
에 ExceptionHandlingMiddleware
를 추가한다.
services.AddTransient<ExceptionHandlingMiddleware>();
//or in .NET 6 and above
builder.Services.AddTransient<ExceptionHandlingMiddleware>();
.NET 6 이상 버전 Configure 메서드 혹은 파이프라인 등록 부분은 다음과 같다
app.UseMiddleware<ExceptionHandlingMiddleware>();
유효성 검사 파이프라인 샘플 코드
/// <summary>
/// 유저 컨트롤러
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class UsersController : ControllerBase
{
private readonly ISender _sender;
/// <summary>
/// <see cref="UsersController"/> 클래스의 새 인스턴스를 초기화
/// </summary>
/// <param name="sender"></param>
public UsersController(ISender sender) => _sender = sender;
/// <summary>
/// 지정된 요청이 있는 경우 지정된 요청을 기반으로 지정된 식별자로 사용자를 업데이트
/// </summary>
/// <param name="userId">유저Id</param>
/// <param name="request">업데이트 사용자 요청</param>
/// <param name="cancellationToken">취소 토큰</param>
/// <returns>No content.</returns>
[HttpPut("{userId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateUser(
int userId,
[FromBody] UpdateUserRequest request,
CancellationToken cancellationToken
)
{
var command = request.Adapt<UpdateUserCommand>() with
{
UserId = userId
};
await _sender.Send(command, cancellationToken);
return NoContent();
}
}
Presentation
프로젝트의 Controller
폴더에서 UserController
를 찾아 볼 수 있다.
이는 전체 코드가 아닌 유저명 업데이트에 대한 API 엔드포인트에 중점을 둔 코드다.
UpdateUser
작업은 매우 심플하다. 경로에서 사용자를 식별하고 request body에서 성과 이름을 수집한다. 그리고 UpdateUserCommand
인스턴스를 생성한다. 그 다음 파이프라인을 통해 명령을 요청한다. 결과는 204 No Content
응답을 반환하게 된다.
하지만 우리의 현재 관심사는 ValidationBehavior
의 동작이다.
존재하지 않는 사용자의 정보로 잘못된 요청을 요청한다면 404 Not Found
를 반환한다.
/pages/architecturedesignpattern
'Backend > C# .NET' 카테고리의 다른 글
C#의 recode 레코드 왜 쓰나요? 샘플 코드 예제 / 명명 규칙 (0) | 2023.12.04 |
---|---|
Entity Framework Core 개념 / 장단점 / 코드 예제 / 사용 방법 / 데이터 가져오기, 수정, 삭제 / 샘플 코드 (0) | 2023.11.26 |
[C#] JWS 생성 (JSON Web Signature) / 검증 / RS256 (0) | 2023.08.08 |
[ASP.NET] Entity Framework Core (0) | 2021.12.13 |
[ASP.NET] 개요 / MVC 패턴 / 의존성 주입 패턴 (0) | 2021.12.07 |