Zachciało mi się… nauczyć czegoś przydatnego i noweg. Padło na separację operacji pobierania i zmieniania danych. W tym celu pokusiłem się o własną implementację CQRS. Wszystkie komponenty składowe ładowane przez Autofaca w odseparowanym module.
Funkcjonalność w połączeniu z MVVM funkcjonuje dobrze, a i ja się czegoś nowego nauczyłem.
Tym samym dzielę się ze światem, informacjami na tema rozwoju PictOgr-a.
Zważywszy, iż mam duży bagaż doświadczeń (nie koniecznie dobrych), i zmiana podejścia do budowania aplikacji z wykluczeniem code-behind jest dla mnie bardzo ekscytująca. W końcu od ponad 20 lat uwielbiam się rozwijać w IT, a to kolejna okazja.
Struktura w projekcie
Struktura mojego CQRSa w projekcie wygląda tak jak na przedstawionym obrazku.
Rozdzieliłem bazę do budowania komend i zapytań w podfolderze CQRS wraz z odpowiednimi nazwami:
- Bus - implementqacja szynu dla komend i zapytań zawierająca interfejs szyny komend (ICommandBus) i zapytań(IQueryBus) w raz z ich implementacją (CommandBus, QueryBus),
- Command - kontrakty komend w skład których wchodzi interfejs: ICommand i ICommandHandler,
- Query - kontrakty dla zapytań i tu wyodrębnić można: IQuery, IQueryHandler.
Wszystkie komponenty bazowe CQRS-a tworzone są w module CQRSModule.
Komendy i szyna komend
Pora nieco omówić implementację komend, do budowy zostały wykorzystane następujace kontrakty:
- ICommand - bazowy interfejs dla każdej komendy nie posiada żadnego szkieletu, poprzez implementację określamy dane jakie należy przezkazać do handler-a komendy, Interfejs komend.
1
2
3
4
5
6
7
namespace PictOgr.Core.CQRS.Command
{
public interface ICommand
{
}
}
- ICommandHandler - interfejs jaki należy zaimplementować w klasie handler-a, tego typu komenda nie otrzymuje żadnych danych,
- ICommandHandler<ICommand> - tworząc implementację na bazie tego kontraktu, przekazać należy dane, które to następnie zostaną przetworzone przez handlera.
Interfejs handlerów komend.
1
2
3
4
5
6
7
8
9
10
11
12
13
namespace PictOgr.Core.CQRS.Command
{
public interface ICommandHandler
{
}
public interface ICommandHandler<in TCommand> : ICommandHandler
where TCommand : ICommand
{
void Handle(TCommand command);
}
}
- ICommandBus to kontrakt dotyczący szyny komend, na którą będą wrzucane komendy i przetwarzane.
Interfejs szyny komend.
1
2
3
4
5
6
7
8
9
10
using PictOgr.Core.CQRS.Command;
namespace PictOgr.Core.CQRS.Bus
{
public interface ICommandBus
{
void SendCommand<TCommand>(TCommand command)
where TCommand : ICommand;
}
}
Implementacja szyny komend CommandBus.
Szyna komend.
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
using System;
using Autofac;
using Autofac.Extras.NLog;
using PictOgr.Core.CQRS.Command;
namespace PictOgr.Core.CQRS.Bus
{
public class CommandBus : ICommandBus
{
private readonly ILifetimeScope container;
private readonly ILogger logger;
public CommandBus(ILifetimeScope container, ILogger logger)
{
this.container = container;
this.logger = logger;
}
public void SendCommand<TCommand>(TCommand command)
where TCommand : ICommand
{
if (command == null)
{
throw new ArgumentNullException(nameof(command));
}
var commandHandler = container
.ResolveOptional<ICommandHandler<TCommand>>();
if (commandHandler == null)
{
throw new Exception(
$"Not found handler for Command: '{command.GetType().FullName}'");
}
try
{
commandHandler.Handle(command);
}
catch (Exception e)
{
logger.Error(e);
}
}
}
}
Proces przetwarzania komendy można przedstawić w następujący sposób:
- Przekazanie komendy do szyny przy pomocy wywołania metody SendCommand.
- Jeżeli przekazana komenda jest pusta, to rzuca wyjątkiem ArgumentNullException,
- Wyciągnięcie z kontenera (Autofac) handlera dla przekazanej komendy.
- Jeżeli handler jest pusty to rzuca wyjątek Exception.
- Wywołanie komendy poprzez metodę Handle w bloku try…catch.
- Jeżeli wywołanie się nie powiedzie to zostanie zapisany log błędu.
Pierwsza komenda
Pierwsza komenda jaka została zaimplementowana ExitApplication dotyczy zamykanai aplikacji.
Dane jakie zostają przekazane to kod wyjścia.
Komenda zamykania aplikacji.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using PictOgr.Core.CQRS.Command;
namespace PictOgr.Core.Commands
{
public class ExitApplication : ICommand
{
public int ExitCode { get; private set; }
public ExitApplication(int exitCode)
{
ExitCode = exitCode;
}
}
}
Kod błędu wykorzystany jest przez handlera ExitApplicationHandler, jest on wymagany przez metodę System.Environment.Exit(ExitCode).
Dodatkowo zapisywany jest log informujący. iż aplikacja została zamknięta.
Handler zamykania aplikacji.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using Autofac.Extras.NLog;
using PictOgr.Core.CQRS.Command;
namespace PictOgr.Core.Commands
{
public class ExitApplicationHandler : ICommandHandler<ExitApplication>
{
private readonly ILogger logger;
public ExitApplicationHandler(ILogger logger)
{
this.logger = logger;
}
public void Handle(ExitApplication command)
{
logger.Info("Exit application.");
System.Environment.Exit(command.ExitCode);
}
}
}
Kod poniżej przedstawia, użycie komendy zamykania aplikacji, w komendach wywoływanych przez widok MVVM.
Do obiektu ExitApplicationCommand została wstrzyknięta szyna komend.
Użycie komendy ExitApplication.
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
using System;
using PictOgr.Core.Commands;
using PictOgr.Core.CQRS.Bus;
using ICommand = System.Windows.Input.ICommand;
namespace PictOgr.SplashScreen.Commands
{
public class ExitApplicationCommand : ICommand
{
private readonly ICommandBus commandBus;
public ExitApplicationCommand(ICommandBus commandBus)
{
this.commandBus = commandBus;
}
public bool CanExecute(object parameter)
{
return true;
}
public void Execute(object parameter)
{
commandBus.SendCommand<ExitApplication>(new ExitApplication(0));
}
public event EventHandler CanExecuteChanged;
}
}
Uzycie komendy ExitApplication, sprowadza sie do 1 linijki kodu *commandBus.SendCommand
Testy
Do przetestowania działania komend w ujęciu testów jednostkowych wymagane jest przysłonięcie metody Handle.
W tym celu napisałem specjalną klasę matkę z mockiem (zaznaczone linie 18-27 w kodzie).
Ponieważ w projekcie został zastosowany mechanizm automatycznego rejstrowania obiektów w Autofacku, należy podmienić (ponownie zarejestrować) komendę jaką chcemy przetestować.
Dodatkowo stosuję tutaj typ generyczny, tak by użyc klasy bazowej dla większej ilości testów.
Baza do testowania komend.
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
namespace PictOgr.Tests.Core.CQRS.Commands
{
using System;
using Autofac;
using FakeItEasy;
using PictOgr.Core.AutoFac;
using PictOgr.Core.CQRS.Bus;
using PictOgr.Core.CQRS.Command;
public class CommandBaseTests<T> where T : ICommand
{
protected ICommandBus commandBus;
protected Action<ICommand> handleMethod;
public CommandBaseTests()
{
var builder = Container.CreateBuilder();
var fakeHandler = A.Fake<ICommandHandler<T>>();
A.CallTo(() => fakeHandler.Handle(A<T>._))
.Invokes((T command) =>
{
handleMethod?.Invoke(command);
});
builder.Register(c => fakeHandler).AsImplementedInterfaces();
var container = builder.Build();
commandBus = container.Resolve<ICommandBus>();
}
}
}
Metoda handleMethod jest to delegat jaki musi być przypisany w teście.
Powyższa implementacja jest rozszeżana we właściwym kodzie testu ExitApplicationCommandTest.
Przesłnięcie metody handleMethod, pozwala na pobranie kodu wyjścia z wywoływanej komendy.
Kod następnie jest przepisany do zmiennej lokalnej, i wówczas możemy już wykonać asercję: exitCode.ShouldBe(expectedValue);
Testowanie komendy Exit.
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
namespace PictOgr.Tests.Core.CQRS.Commands
{
using PictOgr.Core.Commands;
using SplashScreen.Commands;
using Shouldly;
using Xunit;
public class ExitApplicationCommandTest : CommandBaseTests<ExitApplication>
{
private readonly int expectedValue = 123;
private int exitCode;
public ExitApplicationCommandTest()
{
handleMethod = command =>
{
var exitApplication = command as ExitApplication;
exitCode = exitApplication.ExitCode;
};
}
[Fact]
public void exit_application_command_should_be_handled_by_command_bus()
{
commandBus.SendCommand<ExitApplication>(new ExitApplication(expectedValue));
exitCode.ShouldBe(expectedValue);
}
[Fact]
public void window_command_exit_application_should_be_handle_by_command_bus()
{
var exitApplicationCommand = new ExitApplicationCommand(commandBus);
exitApplicationCommand.Execute(null);
exitCode.ShouldBe(0);
}
}
}
Drugi test jaki został zaimplementowany to sprawdzenie działania komendy wywołanej z widoku w celu zamknięcia aplikacji.
W tym przypadku dane jakie zostają przekazane do komendy ustalane są w klasie ExitApplicationCommand, i kod prawidłowego wyjścia jest równy 0.
Dlatego asercję należy wykonać do wartości 0.
To tyle jeśli chodzi o zaimplementowany przeze mnie fragment CQRS dotyczący komend, w następnej części przedstawię implementację zapytań.
Zastanawiam się także nad wykorzystaniem ES w projekcie, oraz reorganizacją projektu w celu wyodrębnienie CQRSa do osobnej biblioteki.
Jeżeli ktoś wytrzymał do końca to dziękuję za uwagę i zapraszam do dalszego śledzenia bloga.
Jest to post przygotowany na potrzeby konkursu „Daj Się Poznać 2017” organizowanym przez Macieja Aniserowicza.
Blog | https://mrdev.pl |
Projekt | https://mrdev.pl/pictogr-pomysl |
GitHub | github.com/krzysztofowsiany/pictogr |
Snapchat | www.snapchat.com/add/gocom7 |
www.facebook.com/PictOgr-1729700930654225 | |
twitter.com/gemu_gocom | |
RSS | http://mrdev.pl/category/daj-sie-poznac-2017/feed |