CQRS - identyfikacja zasobów, UUID post
2017-07-10

W celu wygenerowania duplikatu UUID w wersji 4 należy wygenerować 2,71 trylionów* razy, mimo to prawdopodobieństwo jednego duplikatu wynosić będzie zaledwie 50%.
trylion - 10 do potęgi osiemnastej (w krajach stosujących krótką skalę nazywany kwintylionem)
Jeżeli zastanawiałeś się kiedykolwiek czy w jest szansa aby w Twoim systemie UUID mogło się powtórzyć, ten krótki wstęp powinien rozwiać Twoje wątpliwości.
Dlaczego jednak UUID jest najlepszym wyborem dla CQRS?
Ponieważ UUID ma określony format i stosunkowo łatwo można wygenerować go w praktycznie każdym języku, co sprawia że identyfikator zasobu nie musi być nadawany przez bazę danych. Może być bezpiecznie przekazany z "góry" praktycznie bez ryzyka duplikacji.
Bardzo często osoby wdrażające CQRS po raz pierwszy popełniają bardzo podstawowy błąd jakim jest zwracania wartości bezpośrednio po przeprocesowaniu komendy ponieważ wydaje się im, że nie istnieje lepszy sposób na rozwiązanie ich problemu. Nie twierdzę, że takie podejście zawsze jest złe, być może istnieją miejsca, w których wykonanie komendy powinno skutkować zwróceniem jakiejś wartości. Ja jednak na taki przypadek jeszcze nigdy nie trafiłem.
Tworzenie zasobu
Załóżmy, że posiadamy w systemie komendę odpowiedzialną za tworzenie jakiegoś zasobu, niech to będzie Resource
.
Powiedzmy, że interfejsem naszego systemu jest API Restowe, udostępniające następujący endpoint:
api_resource_create:
url: /resource
methods: ["POST"]
action: ResourceController:createAction
Na potrzeby tego wpisu załóżmy, również że API procesuje requesty w sposób synchroniczny, otrzymuje request, przetwarza go i natychmiast zwraca response.
<?php
declare (strict_types=1);
namespace Example\UserInterface\Http\Symfony\AppBundle\Controller\Rest;
use Ramsey\Uuid\Uuid;
use Example\Application\Command\CreateResource;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ResourceController extends Controller
{
public function createAction(Request $request) : Response
{
$id = (string) Uuid::uuid4();
$this->get('command_bus')->handle(new CreateResource($id));
return new JsonResponse(['id' => $id]);
}
}
Identyfikator zasobu został przekazany do komendy CreateResource
, przez co system nie musiał się już "zastanawiać"
jak go utworzyć a co ważniejsze nie musiał polegać na infrastrukturze. Tak byłoby w klasycznym podejściu, w którym
zasób otrzymałby identyfikator wygenerowany przez bazę danych, wtedy bez zwracania wartości od razu po wykonaniu
komendy implementacja tego endpointu API byłaby niemożliwa.
Edycja zasobu
UUID przydaje się nie tylko podczas tworzenia nowych zasobów, o wiele lepiej sprawdza się w trakcie edycji.
<?php
declare (strict_types=1);
namespace Example\UserInterface\Http\Symfony\AppBundle\Controller\Rest;
use Ramsey\Uuid\Uuid;
use Example\Application\Command\UpdateResource;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ResourceController extends Controller
{
public function updateAction(Request $request) : Response
{
$id = $request->query->get('id');
$this->get('command_bus')->handle(new UpdateResource($id, 'some value'));
$resourceViewObject = $this->get('system.query.resource')->get($id);
return new JsonResponse([
'id' => $resourceViewObject->id(),
'field' => $resourceViewObject->field()
]);
}
}
Jak widać w powyższym przykładzie kodu najpierw wykonywany jest Command
, który nic nie zwraca.
Następnie system robi Query i zwraca wartość. Proste prawda?
A co jeżeli komenda nie może zostać przetworzona przez system? Powiedzmy, że zasób o konkretnym identyfikatorze nie istnieje?
W takim przypadku najlepiej najpierw wykonać Query i sprawdzić czy zasób istnieje, jeżeli nie zostanie znaleziony rzucamy exception z poziomu User Interface. Jest to wstępna walidacja. Gdyby jednak Query z jakiegoś powodu zwróciło informację, że zasób istnieje (powiedzmy read model się nie wygenerował jak należy) mechanizm obsługujący komendę powinien rzucić wyjątek.
Czy warto?
Część osób może teraz zastanawiać się po co tak kombinować, skoro Command może po prostu zwrócić wartość?
Oczywiście, to jak zaprojektowany będzie system zależy tylko od jego architekta. Jeżeli istnieje dobry powód aby wykonanie komendy skutkowało natychmiastowym zwróceniem wartości nie będę próbował nikogo przekonywać na siłę, że powinien robić inaczej. Nikt nie powinien, nie istnieje w końcu jedna słuszna architektura.
Podejście opisane wyżej ma jednak jedną bardzo istotną zaletę. Pozwala bardzo łatwo skalować system wraz z jego rozwojem.
W pewnym momencie przychodzi ten moment kiedy nasz startup zaczyna odwiedzać więcej osób niż CEO i jego rodzina. Jeżeli
mamy trochę szczęścia a nasz biznes dobry pomysł ta chwila przyjdzie szybciej niż nam się wydaje.
Trzymając się sztywno założenia, że Command
zmienia stan systemu a Query
go odczytuje będziemy mogli bardzo
sprawnie wprowadzić asynchroniczną obsługę Command
opartą o jakiś system kolejek. W systemie którego Command
ma
zwrócić natychmiastowo wartość nie będzie to takie łatwe, nie możemy po prostu zserializować Command
, wrzucić na kolejkę
i pozwolić workerowi go przeprocesować, trzeba w końcu jakoś zwrócić tą wartość.
Co więcej asynchroniczne procesowanie komend dotyczących zasobów operujących na UUID pozwala również zignorować kolejność wykonywania się komend.
Przykładowo można wgrać do filesystemu plik dla encji o konkretnym UUID nawet kiedy ta encja jeszcze nie istnieje ponieważ
komenda tworzenia encji wykona się później niż komenda wgrania pliku. Autoinkrementacja by na coś takiego nie pozwoliła.
Wydajność bazy danych
Nie jestem specjalistą od wydajności baz danych. Sam stanąłem kiedyś przed pytaniem czy UUID będzie wydajne. Moje pierwotne obawy okazały się bezpodstawne. W systemie który aktualnie projektuje i rozwijam dosłownie wszystko identyfikowane jest za pomocą UUID. Agregaty, eventy, encje, pliki w filesystemie, wszędzie korzystamy z UUID. PostgreSQL udostępnia typ UUID, wykorzystujemy go zarówno jako Primary Key jak również Foreign Key. Ostatnio procesowaliśmy w tle ponad 210 tysięcy komend, nie natrafiliśmy na żadne problemy wydajnościowe. Jedna z naszych tabel z PK o typie UUID posiada kilka milionów rekordów, tutaj również nie natrafiliśmy na żadne problemy wydajnościowe. Najwięcej problemów wydajnościowych sprawiają integrację z zewnętrznymi usługami, moim zdaniem takich problemów należy się obawiać. Bardzo dużą rolę odgrywa tu jednak odpowiedni Read Model. Kiedy dane stają się zbyt skomplikowane aby je odpowiednio przeszukiwać tworzymy zoptymalizowany read model, problemy wydajnościowe pomaga nam rozwiązywać architektura, nie narzędzia.
Value Object
Czy warto pod każdy zasób tworzyć nowy Value Object dla UUID? Raczej nie, próbowałem tego podejścia, każdy agregat w systemie
posiadał własny typ UUID. Finalnie takie rozwiązanie niczego nie dało, spowodowało tylko że pod każdy typ trzeba było
tworzyć osobny Doctrine Dbal Type
. Finalnie ujednoliciliśmy wszystko, posiadamy teraz jeden generyczny VO UUID
i korzystamy
z niego w całym modelu domeny, niezależnie od kontekstu. Tak zostanie przynajmniej do momentu w którym PHP do SPL (Standard PHP Library)
nie wprowadzi obiektu UUID. Ponieważ się na to jednak nie zapowiada polecam ramsey/uuid
oraz ramsey/uuid-doctrine.
Więcej na temat UUID: