Paweł Łukasiewicz
2022-02-10
Paweł Łukasiewicz
2022-02-10
Udostępnij Udostępnij Kontakt
Wprowadzenie

CQRS to skrót od Command and Query Responsibility Segragation, który jest wzorcem używanym do oddzielenia logiki między poleceniami i zapytaniami. Jeżeli jesteście przyzwyczajeni do tworzenia interfejsu API HTTP to taki podział najłatwiej zrozumieć na poniższym przykładzie:

  • Zapytania - metody GET;
  • Polecenia - metody POST/PUT/DELETE

Przekładając to na najprostszy przykład. Zapytań używamy do pobierania danych z bazy danych a poleceń do operacji takich jak wstawianie/aktualizacja/kasowanie. Spójrzcie na poniższy diagram: Diagram przepływu dla CQRS

Jakie korzyści płyną z wykorzystania CQRS? Główna z nich to wykorzystanie zasady pojedynczej odpowiedzialności (Single Responsibility Principle) w projektowanej aplikacji dzięki czemu możemy zaimplementować luźno powiązane komponenty. Architektura taka daje nam wiele korzyści:

  • Przejrzysty model odczytu z listą zapytań i obiektów domeny, których możemy użyć;
  • Izolacja każdego polecenia wewnątrz modelu zapisu;
  • Prostą defincję każdego zapytania i polecenia;
  • Gotowość do optymalizacji kodu modelu zapisu lub modelu poleceń w dowolnym momencie (biorąc pod uwagę ograniczenia związane ze schematem utworzonej bazy danych);
  • Implementacja nowych zapytań/poleceń bez wprowadzania tzw. breaking changes w naszym projekcie.

Czym jest wzorzec MediatR?

Mediator to behawioralny wzorzec projektowy, który pozwala zredukować chaotyczne zależności pomiędzy obiektami. Wzorzec ogranicza bezpośrednią komunikację między obiektami i zmusza je do współpracy tylko za pośrednictwem obiektu mediatora. Zapewnia łatwiejszą obsługę kodu przez luźne połączenia pomiędzy komponentami. W celu łatwiejszej interpretacji spójrzcie na poniższy przykład w którym jeden z obiektów chce wysłać wiadomość do drugiego obiektu – za komunikację pomiędzy nimi odpowiada obiekt mediatora: Diagram przepływu dla CQRS

Implementacja w ASP.NET Core WebAPI 5.0

W pierwszym kroku tworzymy nową aplikację opartą o szablon zgodny z ASP.NET Core WebAPI 5.0. Następnie dodajemy paczki dla MediatR: CQRS oraz wzorzec mediatora Nie możemy również zapomnieć o paczkach związanych z EntityFramework ponieważ wykorzystamy podejście code-first do stworzenia bazy danych: CQRS oraz wzorzec mediatora

Wraz z poprawną instalacją paczek możemy przejść do konfiguracji projektu – przechodzimy do pliku Startup.cs dodając w metodzie ConfigureService usługę mediatora:

public void ConfigureServices(IServiceCollection services)
{
	services.AddControllers();
	services.AddSwaggerGen(c =>
	{
		c.SwaggerDoc("v1", new OpenApiInfo { Title = "CQRSwithMediatR", Version = "v1" });
	});

	services.AddMediatR(Assembly.GetExecutingAssembly());
}

Kolejnym krokiem jest dodanie do naszego projektu folderu Models i pierwszej klasy:

namespace CQRSwithMediatR.Models
{
	public class Book
	{
		public int Id { get; set; }

		public string Name { get; set; }

		public string Author { get; set; }

		public string Description { get; set; }
	}
}

Następnie tworzymy folder Context w którym tworzymy interfejs IApplicationContext:

using CQRSwithMediatR.Models;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Context
{
	public interface IApplicationContext
	{
		DbSet<Book> Books { get;set; }

		Task<int> SaveChangesAsync();
	}
}
oraz ApplicationContext dla modelu Book. Wykorzystamy teraz podejście code-first w celu utworzenia bazy danej z tabelą na bazie przygotowanego modelu (o tym za chwilkę):
using CQRSwithMediatR.Models;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Context
{
	public class ApplicationContext : DbContext, IApplicationContext
	{
		public DbSet<Book> Books { get; set; }

		public ApplicationContext(DbContextOptions<ApplicationContext> options): base(options)
		{

		}

		public async Task<int> SaveChangesAsync()
		{
			return await base.SaveChangesAsync();
		}
	}
}

Jeżeli nie korzystacie z podejścia code-first lub chcecie dowiedzieć się więcej na ten temat odsyłam do artykułu, który opublikowałem jakiś czas temu: EF Core - podejście code-first

Zanim przejdziemy do wykonania migracji musimy jeszcze dokonać drobnych zmian w konfiguracji naszego projektu – brakuje nam zdefiniowanego connection stringa. W tym celu dokonamy modyfikacji naszej klasy kontekstowej:

using CQRSwithMediatR.Models;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Context
{
	public class ApplicationContext : DbContext, IApplicationContext
	{
		public DbSet<Book> Books { get; set; }

		public ApplicationContext(DbContextOptions<ApplicationContext> options): base(options)
		{

		}

		// W naszym przykładowym API trzymamy się konwencji
		// ze zdefiniowaniem connection-string w metodzie OnConfiguring
		protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
		{
			if (!optionsBuilder.IsConfigured)
			{
				optionsBuilder.UseSqlServer(@"Server=DESKTOP-8LKDKGM;database=CQRSwithMediator;Integrated Security=true");
			}
		}

		public async Task<int> SaveChangesAsync()
		{
			return await base.SaveChangesAsync();
		}
	}
}

Jesteśmy gotowi do utworzenia migracji i zaktualizowania schematu bazy danych. Pamiętacie jednak o instalacji paczki EntityFramework.Core.Tools o której wspomniałem we wpisie EF Core - instalacja:

add-migration initial
update-database

Musicie również dokonać drobnych zmian w konfiguracji projektu:

public void ConfigureServices(IServiceCollection services)
{
    // Rejestrujemy klasę kontekstową
	services.AddDbContext<ApplicationContext>();

	services.AddControllers();
	services.AddSwaggerGen(c =>
	{
		c.SwaggerDoc("v1", new OpenApiInfo { Title = "CQRSwithMediatR", Version = "v1" });
	});

    // Spójrzcie na implementację Poleceń/Zapytań żeby rozwiać wszelkie wątpliwości
	services.AddScoped<IApplicationContext>(provider => provider.GetService<ApplicationContext>());

	services.AddMediatR(Assembly.GetExecutingAssembly());
}

O sposobach implementacji CQRS możemy przeczytać wiele artykułów. Jednym ze sposób jest przygotowanie dwóch osobych API, jedno dla poleceń a drugie dla zapytań. Na potrzebny tego artykułu wszystko tworzymy w ramach jednego projektu a rozdzielenie nastąpi na poziomie struktury folderów i odpowiednich klas. Tworzymy zatem folder Features(nie wiem czy to najlepsza nazwa) w którym utworzymy dwa foldery dla klas zdefiniowanych w ramach poleceń/zapytań: CQRS oraz wzorzec mediatora

Dla każdej z klas musimy przygotować prostą implementację poszczególnych metod. Dodatkowo, w każdej z nich użyjemy interfejsów IRequest oraz IRequestHandler z biblioteki mediatora w celu stworzenia luźno powiązanego kodu. Zaczynamy od klasy GetAllBooksQuery:

using CQRSwithMediatR.Context;
using CQRSwithMediatR.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Features.Queries
{
	public class GetAllBooksQuery : IRequest<IEnumerable<Book>>
	{
		public class GetAllBooksQueryHandler: IRequestHandler<GetAllBooksQuery, IEnumerable<Book>>
		{
			private readonly IApplicationContext _context;

			public GetAllBooksQueryHandler(IApplicationContext context)
			{
				_context = context;
			}

			public async Task<IEnumerable<Book>> Handle(GetAllBooksQuery request, CancellationToken cancelationToken)
			{
				var bookList = await _context.Books.ToListAsync();

				if(bookList == null)
				{
					return null;
				}

				return bookList.AsReadOnly();
			}
		}
	}
}

Implementacja dla klasy GetBookByIdQuery:

using CQRSwithMediatR.Context;
using CQRSwithMediatR.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Features.Queries
{
	public class GetBookByIdQuery : IRequest<Book>
	{
		public int Id { get; set; }

		public class GetBookByIdQueryHandler : IRequestHandler<GetBookByIdQuery, Book>
		{
			private readonly IApplicationContext _context;

			public GetBookByIdQueryHandler(IApplicationContext context)
			{
				_context = context;
			}

			public Task<Book> Handle(GetBookByIdQuery request, CancellationToken cancellationToken)
			{
				var book = _context.Books.Where(a => a.Id == request.Id).FirstOrDefaultAsync();

				if(book == null)
				{
					return null;
				}

				return book;
			}
		}
	}
}

Implementacja dla klasy CreateBookCommand:

using CQRSwithMediatR.Context;
using CQRSwithMediatR.Models;
using MediatR;
using System.Threading;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Features.Commands
{
	public class CreateBookCommand : IRequest<int>
	{
		public int Id { get; set; }
		public string Name { get; set; }
		public string Author { get; set; }
		public string Description { get; set; }

		public class CreateBookCommandHandler : IRequestHandler<CreateBookCommand, int>
		{
			private readonly IApplicationContext _context;

			public CreateBookCommandHandler(IApplicationContext context)
			{
				_context = context;
			}
			public async Task<int> Handle(CreateBookCommand request, CancellationToken cancellationToken)
			{
				var book = new Book();
				book.Author = request.Author;
				book.Description = request.Description;
				book.Name = request.Name;
				_context.Books.Add(book);

				int result = await _context.SaveChangesAsync();

				return result;
			}
		}
	}
}

Implementacja dla klasy UpdateBookCommand:

using CQRSwithMediatR.Context;
using MediatR;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Features.Commands
{
	public class UpdateBookCommand : IRequest<int>
	{
		public int Id { get; set; }
		public string Name { get; set; }
		public string Author { get; set; }
		public string Description { get; set; }

		public class UpdateBookCommandHandler : IRequestHandler<UpdateBookCommand, int>
		{
			private readonly IApplicationContext _context;
			public UpdateBookCommandHandler(IApplicationContext context)
			{
				_context = context;
			}
			public async Task<int> Handle(UpdateBookCommand request, CancellationToken cancellationToken)
			{
				var book = _context.Books.Where(a => a.Id == request.Id).FirstOrDefault();
				if (book == null)
				{
					return default;
				}
				else
				{
					book.Author = request.Author;
					book.Description = request.Description;
					book.Name = request.Name;

					int result = await _context.SaveChangesAsync();
					return result;
				}
			}
		}
	}
}

Implementacja dla klasy DeleteBookCommand:

using CQRSwithMediatR.Context;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Features.Commands
{
	public class DeleteBookCommand : IRequest<int>
	{
		public int Id { get; set; }
		public class DeleteBookCommandHandler : IRequestHandler<DeleteBookCommand, int>
		{
			private readonly IApplicationContext _context;

			public DeleteBookCommandHandler(IApplicationContext context)
			{
				_context = context;
			}

			public async Task<int> Handle(DeleteBookCommand request, CancellationToken cancellationToken)
			{
				var book = await _context.Books.Where(a => a.Id == request.Id).FirstOrDefaultAsync();
				
				if(book == null)
				{
					return default;
				}
				_context.Books.Remove(book);

				int result = await _context.SaveChangesAsync();
				return result;
			}
		}
	}
}

Żmudna część za nami – przygotowaliśmy implementacje dla zapytań i poleceń. Teraz możemy zobaczyć jak w praktyce wygląda użycie mediatora tworząc kontroler w którym mediator będzie odpowiedzialny za przekazanie obsługi z metody API do konkretnego Handlera. Tworzymy nowy kontroler BookControler, który może przyjąć poniższą implementację:

using CQRSwithMediatR.Features.Commands;
using CQRSwithMediatR.Features.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;

namespace CQRSwithMediatR.Controllers
{
	[Route("api/[controller]")]
	[ApiController]
	public class BookController : ControllerBase
	{
		private readonly IMediator _mediator;

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

		// GET: api/<BookController>
		[HttpGet]
		public async Task<IActionResult> Get()
		{
			return Ok(await _mediator.Send(new GetAllBooksQuery()));
		}

		// GET:api/<BookController>/1
		[HttpGet("{id}")]
		public async Task<IActionResult> GetBookById(int id)
		{
			return Ok(await _mediator.Send(new GetBookByIdQuery() { Id = id }));
		}

		// POST: api/<BookController>/1
		[HttpPost]
		public async Task<IActionResult> UpdateBook(CreateBookCommand command)
		{
			return Ok(await _mediator.Send(command));
		}

		// PUT: api/<BookController>/1
		[HttpPut("{id}")]
		public async Task<IActionResult> UpdateBookByUd(int id, UpdateBookCommand command)
		{
			if (id != command.Id)
			{
				return BadRequest();
			}
			return Ok(await _mediator.Send(command));
		}

		// DELETE: api/<BookController>/1
		[HttpDelete]
		public async Task<IActionResult> Delete(int id)
		{
			return Ok(await _mediator.Send(new DeleteBookCommand { Id = id }));
		}
	}
}

Wszystko jest już gotowe. Możecie teraz dodać kilka elementów korzystając ze Swaggera a następnie wywołać metodę GET celem sprawdzenia czy implementacja jest poprawna a rekordy zostały dodane do bazy danych: CQRS z wzorcem mediatora

Podsumowanie

W powyższym wpisie dokonaliśmy prostej implementacji CQRS używając dodatkowo wzorca mediatora. Wykorzystaliśmy również podejście code-first bazując na poprzedniej serii wpisów a całość wykonaliśmy w oparciu o projekt ASP.NET Core Web API 5.0.