Xây dựng Microservices bằng ASP.NET Core – Định hình kiến trúc Microservices với CQRS và MediatR

Xây dựng Microservices bằng ASP.NET Core – Định hình kiến trúc Microservices với CQRS và MediatR

Danh sách các bài viết:

  • Phần 1: Lập kế hoạch.
  • Phần 2: Định hình kiến ​​trúc Microservices với CQRS và MediatR (bài viết này).
  • Phần 3: Khám phá dịch vụ với Eureka.
  • Phần 4: Xây dựng API Gateway với Ocelot.
  • Phần 5: Thao tác với database PostgreSQL bằng Marten.
  • Phần 6: Giao tiếp máy chủ thời gian thực với SignalR và RabbitMQ.
  • Phần 7: Transaction outbox với RabbitMQ.

Trong bài viết đầu tiên của loạt bài về xây dựng microservices bằng .NET Core, chúng ta đã tập trung vào kiến ​​trúc bên trong của một microservices điển hình. Có nhiều tùy chọn để xem xét tùy thuộc vào loại microservices. Một số dịch vụ trong hệ thống của bạn sẽ là CRUD vì vậy không có gì phải bàn cãi về thiết kế của chúng (trừ khi chúng quan trọng từ góc độ hiệu suất và khả năng mở rộng).

Xây dựng Microservices bằng ASP.NET Core – Lập kế hoạch
Trong loạt bài viết này, chúng tôi sẽ giới thiệu cho các bạn các tác vụ điển hình cần thiết để xây dựng giải pháp dựa trên microservices bằng ASP.NET Core.

Trong bài viết này, chúng ta sẽ thiết kế kiến ​​trúc bên trong của microservices, chịu trách nhiệm quản lý trạng thái dữ liệu của nó và hiển thị nó với thế giới bên ngoài. Về cơ bản, microservices của chúng ta sẽ chịu trách nhiệm tạo và sửa đổi dữ liệu của nó và cũng sẽ hiển thị API cho phép các dịch vụ và ứng dụng khác truy vấn dữ liệu này.

Mã nguồn cho giải pháp hoàn chỉnh có thể được tìm thấy trên Github của chúng tôi.

Tổng quan về CQRS

Hãy thử tưởng tượng lớp ProductService thực hiện tất cả các hoạt động mà chúng ta có thể làm với các sản phẩm. Nó là sản phẩm bảo hiểm trong ví dụ của tôi nhưng trong bối cảnh này thì nó không quan trọng. Mọi thay đổi trong mã cần được kiểm tra xem nó hoạt động như thế nào và tác dụng phụ có thể có là gì. Nó làm cho mã phát triển, trở nên khó xử lý và mất thời gian để bắt đầu.

Trong nhiều ứng dụng, có rất nhiều lớp khổng lồ thực hiện logic và chứa mọi thứ có thể được thực hiện với đối tượng của kiểu đã cho. Làm cách nào để cấu trúc lại chúng để tách mã và chia sẻ chức năng?

Đơn giản hóa, chúng ta có thể tách biệt hai phép toán dữ liệu chính. Các thao tác có thể thay đổi dữ liệu hoặc đọc dữ liệu. Vì vậy, cách tự nhiên là tách chúng theo hai thao tác này. Các thao tác thay đổi dữ liệu (command) có thể được phân biệt với các thao tác chỉ đọc dữ liệu (query).

Trong hầu hết các hệ thống, sự khác biệt giữa đọc và ghi là rất cần thiết. Khi bạn đang đọc, bạn không thực hiện bất kỳ xác nhận hoặc logic nghiệp vụ nào. Nhưng bạn thường sử dụng bộ nhớ đệm. Các mô hình cho các thao tác đọc và ghi (hoặc cần phải có) hầu hết cũng khác nhau.

CQRS – là mẫu thiết kế tách mã và các mô hình thực hiện logic truy vấn (query) ra khỏi mã và các mô hình thực hiện lệnh (command).

Quay lại ví dụ của chúng ta – lớp ProductService chia sẻ theo các quy tắc ở trên bây giờ trở thành:

  • FindAllProductsQuery trả về IEnumerable<ProductDto>(cũng có thể được triển khai dưới dạng một mô hình khác – FindAllProductsResult với tập hợp của ProductDto)
  • FindProductByCodeQuery trả về ProductDto.
  • CreateProductDraftHandler với đầu vào là ProductDraftDto và thêm sản phẩm vào hệ thống của chúng ta.

Chúng ta có một mô hình được chia sẻ bởi các truy vấn ở trên nhưng trong trường hợp cần có dữ liệu khác nhau trong kết quả, các mô hình nên được tách biệt (và nó thường như vậy).

Vì vậy, chúng ta có hai phần bây giờ: lớp lệnh hoặc truy vấn và lớp kết quả.

Làm thế nào để kết nối chúng? Làm thế nào để biết loại nào là đầu vào / đầu ra của mỗi truy vấn / lệnh? Đã đến lúc giới thiệu một người hòa giải . Công việc mà người hòa giải làm trong tình huống đó là gắn các mảnh này lại với nhau thành một yêu cầu duy nhất.

.NET Core và MediatR

Chúng ta sử dụng thư viện MediatRđể triển khai mẫu CQRS trong lớp ProductService của chúng ta. MediatR là một loại ‘memory bus’ – giao diện để giao tiếp giữa các phần khác nhau của ứng dụng của chúng ta.

Chúng ta có thể sử dụng Package Manager Console để thêm MediatR vào project bằng lệnh sau:

Install-Package MediatR

Tiếp theo, chúng ta đăng ký nó trong DI container chỉ bằng cách thêm đoạn mã sau vào phương thức ConfigureServices của lớp Startup.

services.AddMediatR();

Để tạo truy vấn với MediatR, chúng ta cần thêm lớp triển khai giao diện IRequest và chỉ định kiểu dữ liệu mà lớp truy vấn của chúng ta sẽ trả về:

public class FindProductByCodeQuery : IRequest<ProductDto>
{
    public string ProductCode { get; set; }
}

Làm thế nào để xác định model dữ liệu đầu vào? Đây là các tham số của phương thức hành động trong controller:

private readonly IMediator _mediator;

public ProductsController(IMediator mediator)
{
    _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}

// GET api/products/{code}
[HttpGet("{code}")]
public async Task<ActionResult> GetByCode([FromRoute]string code)
{
     var result = await _mediator.Send(new FindProductByCodeQuery { ProductCode = code });
     return new JsonResult(result);
 }

Bây giờ chúng ta có thể gửi truy vấn của mình với MediatR. Controller khá đơn giản, không có logic nào ở đây. Trách nhiệm duy nhất của nó là gửi truy vấn tới phương thức Send của đối tượng IMediator (được inject từ DI Container – xem bên dưới) và gửi lại phản hồi JSON cho client.

Tiếp theo chúng ta cần định nghĩa phần xử lý các yêu cầu của giải pháp CQRS. Và một lần nữa MediatR giúp bạn dễ dàng thực hiện điều này như sau:

public class FindProductByCodeHandler : IRequestHandler<FindProductByCodeQuery, ProductDto>
{
    private readonly IProductRepository productRepository;

    public FindProductByCodeHandler(IProductRepository productRepository)
    {
        this.productRepository = productRepository ?? throw new ArgumentNullException(nameof(productRepository));
    }
}

Như chúng ta có thể thấy trình xử lý yêu cầu triển khai interface IRequestHandler với định nghĩa các kiểu đầu vào và đầu ra:

public interface IRequestHandler<in TRequest, TResponse> where TRequest : IRequest<TResponse>
{
    Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken);
}

Trong lớp FindProductByCodeHandler cung cấp cho chúng ta thông tin rằng nó ‘biết’ cách phản hồi các yêu cầu của FindProductByCodeQuery và trả về đối tượng ProductDto.

Bây giờ chúng ta cần xác định cách xử lý yêu cầu. Interface IRequestHandler định nghĩa phương thức Handle mà chúng ta sẽ triển khai. Chúng ta sẽ inject interface IProductRepository và truy xuất đối tượng được yêu cầu:

public async Task<ProductDto> Handle(FindProductByCodeQuery request, CancellationToken cancellationToken)
{
    var result = await productRepository.FindOne(request.ProductCode);

    return result != null ? new ProductDto
    {
        Code = result.Code,
        Name = result.Name,
        Description = result.Description,
        Image = result.Image,
        MaxNumberOfInsured = result.MaxNumberOfInsured,
        Questions = result.Questions != null ? ProductMapper.ToQuestionDtoList(result.Questions) : null,
        Covers = result.Covers != null ? ProductMapper.ToCoverDtoList(result.Covers) : null
    } : null;
}

Ánh xạ tới kiểu kết quả cũng được thực hiện trong lớp trình xử lý. Nếu cần, chúng ta có thể sử dụng AutoMapper hoặc triển khai một số trình ánh xạ tùy chỉnh. Chúng ta cũng có thể thêm bộ nhớ đệm ở đây và bất kỳ logic nào khác cần thiết để chuẩn bị phản hồi.

Thử nghiệm với xUnit

Bây giờ chúng ta đã có chức năng lấy thông tin sản phẩm theo mã sản phẩm. Hãy thử nghiệm nó. Chúng ta sử dụng xUnit để kiểm tra ứng dụng .NET Core của chúng ta.

Việc kiểm tra khi sử dụng MediatRCQRS khá đơn giản. Chúng ta đã tạo trong ProductsControllerTest một phương thức ngắn để kiểm tra controller bằng cách sử dụng bus:

[Fact]
public async Task GetAll_ReturnsJsonResult_WithListOfProducts()
{
    var client = factory.CreateClient();

    var response = await client.DoGetAsync<List>("/api/Products");
    
    True(response.Count > 1);
}

Chúng ta cũng nên kiểm tra trình xử lý của mình, một trong những thử nghiệm trong FindProductsHandlersTest:

[Fact]
public async Task FindProductByCodeHandler_ReturnsOneProduct()
{
    var findProductByCodeHandler = new FindProductByCodeHandler(productRepository.Object);
    
    var result = await findProductByCodeHandler.Handle(new Api.Queries.FindProductByCodeQuery { ProductCode = TestProductFactory.Travel().Code}, new System.Threading.CancellationToken());

    Assert.NotNull(result);            
}

Đối tượng productRepository là giả lập của interface IProductRepository và nó được định nghĩa theo cách sau:

private Mock productRepository;        

private List products = new List
{
    TestProductFactory.Travel(),
    TestProductFactory.House()
};

public FindProductsHandlersTest()
{
    productRepository = new Mock();
               
    
    productRepository.Setup(x => x.FindAll()).Returns(Task.FromResult(products));
    productRepository.Setup(x => x.FindOne(It.Is(s => products.Select(p => p.Code).Contains(s)))).Returns(Task.FromResult(products.First()));
    productRepository.Setup(x => x.FindOne(It.Is(s => !products.Select(p => p.Code).Contains(s)))).Returns(Task.FromResult(null));
}

Việc thực hiện các lệnh (command) chắc chắn giống nhau. Ở đây không có nơi nào để hiển thị ví dụ nhưng hãy truy cập mã nguồn đầy đủ trên GitHub, nơi bạn có thể xem lại tất cả mã, tổ chức của dự án, v.v.

Tóm lược

Tôi thực sự khuyên bạn nên thử làm việc với thư viện MediatR. Nó giúp chúng ta dễ dàng thiết lập, cho phép chúng ta có thể bắt đầu nhanh và khám phá những gì mà thư viện hỗ trợ và đặc biệt là mẫu CQRS cung cấp cho chúng ta.

Tôi hy vọng bài viết này cho thấy nó giữ cho tất cả mọi thứ được tách biệt, mỗi lớp có trách nhiệm riêng, các mô hình đầu vào và đầu ra phù hợp và controller càng sạch càng tốt.

Nếu chúng ta tạo các yêu cầu và trình xử lý khác nhau, riêng lẻ thay vì một interface lớn, chúng ta có thể thay đổi bất kỳ phần nào của chức năng dịch vụ mà không có tác dụng phụ. Chúng ta có thể dễ dàng thay đổi hành vi của các trình xử lý (logic) trong khi nó vẫn trả về đúng kiểu dữ liệu – nó sẽ không có tác động đến controller.

Chúng ta có thể tạo chức năng mới bằng cách thêm cặp trình xử lý yêu cầu mới. Hoặc loại bỏ chúng bằng cách xóa cặp trình xử lý yêu cầu. Nếu chúng ta là người mới trong hệ thống được phát triển lâu năm – chúng ta chỉ cần kiểm tra một phần nhỏ của nó – chỉ những nơi cần bảo trì của chúng ta.

CQRS cũng có thể được triển khai trong kiến ​​trúc microservices – lệnh truy vấn và / hoặc trình xử lý lệnh có thể được triển khai dưới dạng các microservices riêng biệt. Chúng ta cũng có thể triển khai hàng đợi lệnh. Các mô hình khác nhau có thể được sử dụng để đọc và ghi và các microservices có thể sử dụng các mô hình dữ liệu khác nhau. Các hoạt động có thể được mở rộng bằng cách chạy số lượng trình xử lý khác nhau của loại lệnh hoặc truy vấn.

Tất nhiên CQRS không giải quyết được tất cả các vấn đề. Tôi nghĩ rằng việc định nghĩa hàng nghìn sự kiện không giúp hệ thống của chúng ta dễ bảo trì. Và nếu nó không đáp ứng với thách thức phát triển của bạn, đừng sử dụng nó.

Bài viết này được dịch từ bài viết gốc ở đây.

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *