niedziela, 8 grudnia 2024

CloudWatch Insights - monitoring async workers traffic distribution

Symfony Framework Messenger allows to run parts of your system's processes asynchronously. To gain a better understanding of how your application really works, you can use the following queries. 

These queries filter all logs that comes from Symfony Messenger Workers and aggregate them to provide broader perspective. These also prepare a summary of all asynchronous execution units in your system, giving you greater awareness of the overall complexity and the building blocks that make up the whole.

So, at the very beginning let me explain the data from the logs in my application. This way you can apply these queries to your system, keeping in mind that my log structures might differ partially or entirely.

  • message - with value MESSENGER_WORKER_MESSAGE_FINISH indicates Symfony Messenger Message that was successfully handled, 
  • extra.messengerWorker.class - event/command class that was handled by Worker
  • extra.messengerWorker.executionId - represents each message that was handled by Messenger Worker 
  • extra.requestDuration - whole Worker duration, 
  • context.messengerWorker.processingDuration - execution of a single Message duration
  • context.messengerWorker.memoryUsageMb - whole Worker memory usage

📊 List all unique Workers 

Thanks to that query you can easily check how many different async-workers do you have in your system. dedup function helps a lot with removing duplicated message class names.    

fields @timestamp, @message, @logStream, @log
| filter message = "MESSENGER_WORKER_MESSAGE_FINISH"
| display extra.messengerWorker.class 
| dedup extra.messengerWorker.class 

All unique Messenger events/commands that are handel asynchronously.
This summary gathers all async execution units (that have worked in a given period of time)
of you application in one place. 

📊 Reveal traffic distribution per Worker 

That query shows the busiest workers in your system.     

fields @timestamp, @message, @logStream, @log
| filter message = "MESSENGER_WORKER_MESSAGE_FINISH"
| stats count(*) as executionCount by extra.messengerWorker.class  
| sort by executionCount desc

It's a simple list which execution count for each individual worker that was handled in a given period of time.

The wider time period you'll set for the query the more (probably) unique worker type you might get as a results. 

Changing the time period of the query to only one day would reveal less worker types
but returned metrics might be more useful. 

📊 More detailed Workers data 

Each row symbolizes a single worker per instances, how many messages they handled, when the first and last messages were handled, and, of course, the message type they handled. Take look at interesting usage of  latest(fieldName) function which allows to present not aggregated data in this summary.      

fields @timestamp, @message, @logStream, @log
| parse @logStream "/php/*" as log_stream_id
| filter message = "MESSENGER_WORKER_MESSAGE_FINISH"
| stats latest(substr(log_stream_id, 0, 6)) as instance,
        count(extra.messengerWorker.executionId) as executions, 
        min(@timestamp) as start,
        max(@timestamp) as end,
        max(extra.requestDuration / 60) as duration_in_minutes,
        latest(extra.messengerWorker.class) as message_class
  by extra.executionId as worker_exectuion_id

You can use that query when you need to check a specific worker performance.

📊 The longest Workers executions

To identify the longest workers execution in you application just use the query bellow. These spots could be the first candidates for optimization or potential bottlenecks that slow down your application.       

fields @timestamp, @message, @logStream, @log
| filter message like /(?i)(MESSENGER_WORKER_MESSAGE_FINISH)/
| display datetime, 
          context.messengerWorker.processingDuration / 60,
          context.messengerWorker.memoryUsageMb,
          extra.messengerWorker.class
| sort context.messengerWorker.processingDuration desc

niedziela, 1 grudnia 2024

CloudWatch Insights - revealing endpoint's clients

I'll show you how to list all the clients of your's application HTTP endpoints, how large is the traffic they generate and how it's exactly distributed in a given period of time. That few queries below could easily reveal the knowledge about application dependencies (with a minimal effort on your end) that you might be not aware of.

At the very beginning I'm gonna show you how the log structure looks like in my case. It may differ how it would look like in your application but what's matter is to highlighting a values that you need to achieve our goal to find out who is calling your application endpoints. 


JSON structure

  • User Agent of the client - in my case it's passed in request headers as key user-agent which needs to be parsed first because even that key is auto-discovered by CloudWatch it contains a dash (-) in a key name so cannot be used in a query,
  • value that distinguish Http Request Logmessage,
  • Http Request Method - context.method,
  • Http Request Namecontext.route - we cannot use exact URI that was called by client because the same URIs may differ when endpoint has param inside endpoint path e.g. {{id}} of the resource it refers to - which makes logs aggregation not possible,  
  • Caller IPcontext.clientIp,  

📊 List all the Clients per endpoint

fields @timestamp, @message, @logStream, @log
| parse @message '"user-agent":["*"]' as userAgent
| filter message = "HTTP_REQUEST" 
| display context.method, 
          context.route, 
          userAgent, 
          context.clientIp
| sort context.method, 
       context.route, 
       userAgent asc
| dedup context.route, 
        context.clientIp


📊 Count all requests done by each Client

fields @timestamp, @message, @logStream, @log
| parse @message '"user-agent":["*"]' as userAgent
| filter message = "HTTP_REQUEST" and
         ispresent(userAgent)
| stats count(userAgent) as endpointCallByExternalService by userAgent
| sort endpointCallByExternalService desc


📊 Traffic distribution in a time per demand Client

Based on results above you need to manually parse each Client that you want to see in this chart and add it in the stats section to finally display it. 
fields @timestamp, @message, @logStream, @log
| parse @message '"user-agent":["*"]' as userAgent
| parse userAgent /(?<isGo>Go-http-client?)/
| parse userAgent /(?<isSymfony>Symfony?)/
| parse userAgent /(?<isPostman>PostmanRuntime?)/
| parse userAgent /(?<isJava>Java?)/
| parse userAgent /(?<isPython>python-requests?)/
| parse userAgent /(?<isRuby>Ruby?)/
| filter message = "HTTP_REQUEST" and
         ispresent(userAgent)
| stats count(isGo),
        count(isSymfony),
        count(isPostman),
        count(isJava),
        count(isPython),
        count(isRuby)
  by bin(1h)
        


Conclusion


Since you got the new perspective and knowledge of the system that you're maintain, you can start asking questions about the things that are still unrevealed:

  • does one User Agent is represented by two or more systems (e.g. two application implemented in Java)?
  • which of the User Agents are internal system and which one are external? 
  • why these are needing date from your application?
  • why traffic distribution looks exactly like this?    

sobota, 30 listopada 2024

CloudWatch Insights - most used HTTP endpoints


Having a standard PHP Symfony Framework log structure like one below you can measure whole application HTTP traffic using CloudWatch Insights.  

{
   "message":"Matched route \"some_endpoint_name\",
   "context":{
      "route":"some_endpoint_name",
      "request_uri":"http://some.domain.com/some/endpoint/91b5602d-f098-471a-aa05-92937fea3636/name",
      "method":"POST"
   },
   "level":200,
   "level_name":"INFO",
   "channel":"request",
   "datetime":"2024-11-30T10:26:08.594527+00:00",
}

As you can see context.request_uri  key it's hard to aggregate since each request for the same endpoint will differ due to passed in URI params like UUID of the resource. To avoid mentioned problem we should use rather a context.route value which is declared in Symfony Controller class as one of the value Action Attribute.  

Here is example of the CloudWatch Insights query which show in descending order all of the most used POST/PATCH/PUT/DELETE (these are changing state of the system) endpoints in your application: 

fields @timestamp, @message, @logStream, @log
| filter channel = "request" and context.method != "GET" 
| stats count(*) as endpointsCallsCount by context.route, context.method
| sort endpointsCallsCount desc


The standard output of the query, it's descending order easily shows the most used endpoints in your application

You can also see results as chart so you can compare all endpoints share in total HTTP traffic volume  

For monitor all GET endpoints worth to filter non functional endpoints call that only checks an availability of the system:

fields @timestamp, @message, @logStream, @log
| filter channel = "request" and context.method = "GET" and context.route not in ["health_check","ping"]
| stats count(*) as endpointsCallsCount by context.route, context.method
| sort endpointsCallsCount desc

piątek, 8 listopada 2024

Observability

Tradycyjne metody monitoringu przestały być wystarczające w dobie złożonych systemów - wymagane jest bardziej wnikliwe zrozumienie systemów oraz przyśpieszanie rozwiązywania incydentów.

Sama implementacja systemów obserwowalnych i ich utrzymanie rodzi nowe problemy. Zewnętrzne systemy służą do obserwowania aplikacji; kwestionowanie w celu poznania wewnętrznej pracy systemu i stanu systemu.

Jeżeli mamy możliwość pozyskania informacji na temat stanu aplikacji w każdym jej aspekcie nawet z którymi byliśmy nie zaznajomieni jeszcze jakiś czas temu, których nie przewidzieliśmy - a przyszły nam one dopiero teraz i mamy możliwość zweryfikowania tychże danych. Oznacza to, że wskaźnik Observability systemu jest wysoki.

Stale musimy usprawniać proces zwiększania wskaźnika Observability; dzięki wysokiemu Observability możemy wypatrywać niecodziennych/podejrzanych wzorców i zachowań; pozwala na analizę interakcji użytkownika z systemem; dzięki Observability mamy wgląd w dynamikę komunikacji między mikro serwisami/kontenerami; taka analiza powinna być standardowym elementem pracy programistów (development life-cycle). Observability daje możliwość wglądu w zachowanie systemu, dzięki tym informacją deweloperzy mogą poprawiać niezawodność/wydajność systemu; analiza logów, metryk, trace’ów pozwala na zidentyfikowanie bottleneck’ów wydajnościowych;

Kluczowe koncepty:

  • Root cause analysis
  • Highly observable system (has intricate details/critical insights)
  • Realtime monitoring/alerting
  • resource utilization
  • error rates
  • Synthetic Journeys
  • performance metrics
  • deviations from normal patterns
  • APM (Application Performance Monitoring)
  • application dependencies,
  • Distributed tracing - złożone systemy gdzie pojedynczy request oddziałuje na wiele mikro serwisów w różnych data centers - taki trace ma swój ID
  • Telemetry Instrumentations (Open Telemetry Standard) event wysyłany do central location (tracking user journey; troubleshooting errors)
  • Site Reliability Engineering (SRE)
  • feature flagging
  • incident analysis
  • blue-green deployment
  • chaos engineering; “pytania które zostaną postawione bez wcześniejszej wiedzy”
  • alert ➡️ wartość progowa predefiniowanej metryki została przekroczona; remediations (środki zaradcze)
  • podejście reaktywne: zidentyfikowanie i rozwiązanie problemu po tym jak wystąpi
  • podejście proaktywne
Observability pomaga zrozumieć wewnętrzne zachowanie systemu co może wyłonić potencjalne problemy które będą mieć miejsce w przyszłości.

Alert musi mieć dane dot. powodu jego wystąpienia (moje doświadczenie: miejsce wystąpienia, dane kontekstowe - zasobu którego dotyczy).

Tradycyjny monitoring i dashboardy polegają na wiedzy Seniora (dependency on human expertise) - czyli metodologia (tradycyjny monitoring) polegająca na objawach aniżeli na actual Root Cause - to nie może być dłużej stosowane gdy złożoność i skala jest duża; “Information to debug issues in details”, “ask open questions”, “trace the system to find real cause of problems (deeply hidden)”; organizacja nie polega na wiedzy eksperta i na subiektywnym zgadywaniu, a prowadzi do bardziej obiektywnej analizy; Złożone interakcje między systemami rozproszonymi; metrics, events, logs, traces, telemetry data - unforeseen issues. Szybkie rozwiązanie problemu to ograniczenie down-time; identify & solve potential issue before they affect users - w przeciwieństwie do reagowania na problem; Problemy z Observability: przechowywanie tych danych, przesyłanie tych danych po sieci; zmiana sposobu myślenia z re na pro; observability kosztuje; security & privacy; 



Źródło 

sobota, 14 stycznia 2023

Kontekst pierwotny, a reużywalność obiektu

By oszczędzić sobie czasu i korzystać z przychylności programowania obiektowego – kod który napiszemy raz, możemy ponownie wykorzystać w nowych przypadkach. Myślę że zasada DRY, zawsze siedzi nam z tyłu głowy i zna ją każdy zawodowy programista. Niestety w programowaniu nic nie jest czarno-białe i po latach pracy nad różnymi projektami pojawiło się kilka wątpliwości co do ślepego stosowania DRY.

Co robi metoda i dlaczego?

Rola obiektu jest właściwie kontekstem jego wywołania. Przeanalizujmy przypadek w którym posiadamy Encje/WriteModel Product z jeszcze nienazwaną metodą:

namespace App\Offer\Domain\Entity;

final class Product 
{
	/* ... */
	
	public function ____________(): array 
	{
		return [
			'name' => $this->name,
			'description' => $this->description,
			'price' => $this->price,
			'currency' => $this->currency,
			'created_at' => $this->createdAt->format('Y-m-d H:i:s')	
		];
	}
} 

Co możemy odczytać z tego kodu?

    • metoda zwraca
array’a który jest strukturą danych. Dodanie do niej @docBlock - /** @return array<string, mixed> */ nie wiele wniesie do klientów bo i tak by nie iterowali po zwróconej przez metodę tablicy,
    •
array będący strukturą danych budowany jest na podstawie wewnętrznego stanu obiektu. Na przykładzie tego nie widać, ale załużmy, że serializacji podlega każde pole klasy,
    • Jest to metody typu Query (CQS) – niemodyfikująca wewnętrznego stanu obiektu czyli pozbawiona efektów ubocznych.

Kolejne wnioski możemy wyciągnąć patrząc na (jak dotąd) jedynego klienta korzystającego z tej metody:

namespace App\Offer\Infrastructure\Repository;

use App\Offer\Domain\Repository\ProductRepository;
use App\Offer\Domain\Entity\Product;
use Doctrine\DBAL\Connection;

final class ProductMySQLRepository implements ProductRepository
{
	public function __construct(private Connection $connection) {}
	
	public function save(Product $product): void 
	{
		$this->connection->executeStatement(
			'INSERT INTO product ...',
			$product->____________()
		);
	}
}


    • nienazwana metoda wywoływana jest w Warstwie Infrastruktury,
    • zmapowany stan Write Modelu reprezentował będzie jeden wiersz w tabeli product,

Biorąc pod uwagę fakt, że klasa
ProductMySQLRepository jest jedynym klientem metody Product::__________ - możemy założyć, że powstała ona specjalnie by być wykorzystana do utworzenia wiersza w tabeli. Programista stanął przed problemem – „jak mogę utrwalić Encję Product – nie tworząc dla niej całej armi getterów?”. Załóżmy, że zdecydował się utworzyć metodę mapującą stan na dane oczekiwane przez drugi parametr Doctrine’owego Connection::executeStatement. Czy nie przewidując dla niej innego wykorzystania (w tym punkcie czasu) w przyszłości, wywołanie jej w innym kontekście – całkowicie innym niż INSERT bazodanowy – będzie akceptowalne?

Jaka nazwa jest najodpowiedniejsza?

Do wyboru mamy:
    1.
toArray
    2.
asArray
    3.
serialize
    4.
jsonSerialize
    5.
map

Takie podejście sprawia, że w nazwie metody nie ma śladu po kontekście jej użycia, a który jest w całej historii powstania tej metody bardzo istotny. Bezkontekstowa nazwa metody sprawia złudne wrażenie, że wykorzystanie jej ponownie w zupełnie innym kontekście, w zupełnie innej roli jest całkowicie usprawiedliwione.

Powinniśmy więc odpowiedzieć sobie na jedno pytanie: „Czy nazwa metody powinna określać intencje jej pierwotnego użycia?” - moim zdaniem nie. Niezmienia to jednak wspomnianej już wielokrotnie istotności kontekstu dla którego została wprowadzona, na którego musimy zwracać uwagę podczas ponownego użycia metody.
 
Na potrzeby omawianego przykładu możemy ustalić, że
asArray będzie prawdziwą nazwą Product::__________ metody. Dlaczego akturat ta nazwa została wybrana spośród innych? - można powiedzieć, że chodzi o konwencje w projekcie. Często musimy serializować obiekty do tabel, powody tego są różne. Tak jak w opisanym przykładzie, raz potrzebujemy przesłać dane do tabeli bazodanowej, innym razem wysłać je poza aplikację. Musimy przyjąć jedną konwencję nazewniczą dla tego typu metod – na co kolwiek się ostatecznie zdecydujemy, nie będzie miało ostatecznego wpływu na jakość projektu.   

Jeden obiekt – dwa konteksty użycia

Zapisywanie produktu do bazy danych zostało wdrożone na produkcję miesiące temu. Biznes jednak nie pozostawił tematu na zawsze i postanowił wzbogacić moduł oferty o nowy feature. Na każde dodanie nowego produktu miał być wysyłany email do zewnętrznego systemu z szablonem umożliwiającym dynamiczne dodanie danych produktu. Podczas tworzenia templatki, można by korzystać z zestawu kluczy, których wartości zawsze byłyby dostępne wraz z wysyłanym emailem. By jeszcze skomplikować sprawę, za te czynności (wysyłanie emaili i zarządzanie templatkami) odpowiedzialny by był zewnętrzny system.   


Spoglądajć na kod odpowiedzialny za zapisywanie produktu sprawa wygląda dość prosto bo... 

namespace App\Offer\Application\Handler;

use App\Offer\Domain\Entity\Product;
use App\Offer\Domain\Repository\ProductRepository;

final class CreateProductHandler 
{
	public function __construct(
		private ProductRepository $productRepository;
	) {}

	public function __invoke(/*...*/): void 
	{
		/*...*/
		$this->productRepository->save($product);
	}
}

...jedyne co musimy zrobić to wstrzyknąć serwis odpowiedzialny za komunikację z zewnętrznym serwisem wysyłającym emaile. Jako że mamy obiekt $product pod ręką, wraz z możliwością jego łatwej serializacji do struktury tablicowej – możemy pokusić się o implementację z jego wykorzystaniem. Metoda __invoke potrafiąca zlecać wysyłkę email’a wyglada nastepująco:

public function __invoke(/*...*/): void 
{
	/*...*/
	$this->productRepository->save($product);
	$this->externalEmailService->send($adminEmail, $product->asArray());
}

W tym momencie struktura tablicowa zwracana przez metodę Product::asArray jest wykorzystywana dwuktornie w różnych  kontekstach:
    1. zapisu wiersza tabeli bazy MySQL,
    2. templatce emaila znajdującego się w zewnętrznym systemie  

Jak pokazuje poniższy kod w zewnętrznym systemie znalazły się „pojęcia” związane z wewnątrz-systemową reprezentacją zapisu encji z innego systemu.

Utworzono produkt {{name}}

{{created_at}}

{{description}}

Rozważmy plusy i minusy

✅ nie musimy robić nic więcej niż wywołanie metody która została już wcześniej napisana. Nazwa metody serializującej nie zawiera w sobie żadnego kontekstu w którym została utworzona dlatego korzystamy z niej z czystym sumieniem,

❌ powiązaliśmy ze sobą niejawnie dwa konteksty: zmiana klucza w zserializowanym produkcie wprowadzona na potrzeby kontekstu bazodanowego, będzie miała też swoje konsekwencje w kontekście zewnętrznego serwisu do wysyłki emaili. Ewentualne zmiany, które muszą być wprowadzone tylko w jednym z kontekstów będą wymagały wprowadzenia małych hacków,
❌ do zewnętrznego serwisu wysyłającego emaile mogą trafić nadmiarowe/poufne dane, być może o nieodpowiednich nazwach kluczy. Możemy tego uniknąć stosując drobne hacki – przemapowanie tablicy, unsetowanie,
❌ korzystamy z metody utworzonej w celu utrwalaniu Write Modelu – w sposób jakiego nie zakładał jej twórca. Traktując zwracany array jako View Model,
❌ Gdybyśmy zdecydowali się na wysyłkę emaila w Event Subscriberze nie mielibyśmy takiego łatwego dostępu do encji $product jak wcześniej. Musielibyśmy albo pozyskać ją z repozytorium  i w dalszym ciągu, z uporem maniaka korzystać z niej jak z Read Modelu.
 

Z pozoru prostrze rozwiązanie może powodować więcej problemów w przyszłości niż rozwiązuje teraz.

View Model w nowym kontekście

By uchronić się przed silnym couplingiem pomiędzy dwoma wspomnianymi kontekstami, nie możemy cały czas polegać na jednej tablicowej strukturze danych. Musimy wprowadzić nowy byt, a z racji tego, że zserializowana encja Product była początkowo wykorzystywana w kontekście zapisu danych do tabeli bazodanowej – tą część pozostawimy w pierotnej postaci. W kontekście wysyłki maili zostanie utworzona nowa klasa typu View Model.

Obiekty tego typu:
    • stanowią warstwę buforową pomiędzy rdzeniem naszej aplikacji, a światem zewnętrznym,
    •  są niemutowalne, służą jedynie jako struktura danych bez żadnych zachowań,
    • powinny posiadać odpowiednio sformatowane dane przeznaczone dla klienta.

Przykładowa implementacja mogłaby prezentować się następująco:
namespace App\Offer\Application\ViewModel;

final class ProductEmail 
{
	public function __construct(
		private string $name,
		private string $description,
		private float $price,
		private string $currency,
		private DateTimeImmutable $createdAt
	) {}
	
	public function asArray(): array 
	{
		return [
			'name' => $this->name,
			'description' => $this->description,
			'price' => number_format($this->price) . ' ' . strtoupper($this->currency),
			'createdAt' => $this->createdAt->format('Y-m-d H:i:s')	
		];
	}
}

Jak widać klucze jak i formatowanie udostepnianych danych są specjalnie dostosowane pod wymagania bytów ze świata zewnętrznego. Nie jesteśmy teraz od niczego zależni, dlatego na nowe wymagania biznesowe możemy dowolnie zmieniać serializację View Modelu – dodawać nowe klucze, modyfikować nazwy już istniejących jak i zmieniać foramtowanie samych wartości. Problemem może okazać się samo instancjonowanie tego obiektu, najpewniej w metodzie repozytorium powiązanym z Encją Product, niemniej jednak jest to niewielki koszt za cenę dobrego designe’u.

Koszt jaki musimy ponieść to:

💰 Utworzenie nowej klasy View Model’u
💰 Instancjonowanie VM, najpewniej w nowej metodzie repozytorium
💰 dodając do Encji
Product nowe pole, nie doda się ono automatycznie do View

Modelu co miałoby miejsce w pierwszej inkarnacji „Wysyłki emaila na utworzenie produktu”. Niewątpliwie rozpatrujemy to jako zaletę, ale również jako koszt – rzeczy o której należy pamiętać i potęcjalnie wykonać.

Złożoność

Możemy odnieść wrażenie, że wraz z dodawaniem kolejnych klas/metod rośnie złożoność projektu. W tym przypadku jest to złudne wrażenie. Pomyślmy, korzystając z jednej struktury tworzymy silny coupling pomiędzy modułami, które nie powinny być ze sobą powiązane na tym poziomie:

Dodajemy nowe pole do Encji Product ➡️ pojawia się nowy klucz możliwy do wykorzystania w templatce emaila.


lub:

Usuwamy pole z Encji Product ➡️ Zewnętrzny System przestaje wysyłać emaile o utworzeniu produktu.


I z szerszej perspektywy:

Modyfikujemy Domenę w Module Offer ➡️ Zewnętrzny System do emaili przestaje działać.


Nowy programista w projekcie, mógłby przeoczyć ten fakt i nie być nawet świadom takiego efektu ubocznego podczas dodawania nowego atrybutu Encji. Tego typu nieprzewidziane efekty uboczne są przyczyną powstania trudnych do wytropienia błędów, sprawiając, że złożoność oprogramowania rośnie.

Czas zweryfikował: rola jest jedna

Osobne byty dla innych kontekstów dają niesamowitą swobodę, ale jeżeli początkowo są identyczne i za każdym razem trzeba nanosić takie same zmiany do dwóch klas – znaczy, że nie powinniśmy w ogóle wprowadzać takiego rozdzielenia, bo pierwotny obiekt odgrywa taką samą rolę w dwóch kontekstach. 

Podsumowanie

Tak jak zostało wykazane w opisywanym przypadku, by usunąć złożoność i ukryty coupling, musiały być utworzone dodatkowe byty. W konsekwencji tego, mogą pojawiać się głosy innych członków team’u:

Czasami trudno jest przewidzieć konsekwencje niektórych decyzji podczas implementacji. Dlatego warto czasami zatrzymać się i przemyśleć niektóre kwestie, jakie będą ich konsekwencję. Z pozoru „skomplikowanie” projektu dodaniem kilki nowych klas może okazać się zabawienne w czasie jego dalszego życia. Zachęcam więc do przemyśleń w tej kwestji.

 

sobota, 7 stycznia 2023

Weryfikacja stanu obiektu gdy stan nie jest ujawniony

 

Spotkałem się ostatnio z ciekawym przypadkiem weryfikowania wyniku testu jednostkowego. Testowaniu została poddana void'owska metoda Agregatu i nijak nie można było wyciągnąć na zewnątrz stanu obiektu, który powinien ulec zmianie w wyniku wywołania metody under test, by można było go zweryfikować z oczekiwaniami. Agregat nie posiadał powiązanych z tym stanem getter'ów, wygenerowane event'y nie mogły zostać jawnie zwrócone poza obiektu (\Prooph\EventSourcing\AggregateRoot tego nie udostępniał) i żadna inna metoda jakkolwiek nie wyrzucała stanu na zewnątrz. Dodanie takich funkcji na siłę, tylko na potrzeby wykonania testu jednostkowego wydawało się nadużyciem więc też takie rozwiązanie nie wchodziło w grę.

Przez powyższe problemy, trzeba było wykazać się pomysłowością by napisać test - kolega z zespołu wpadła na takie oto rozwiązanie:

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');

    // When
    // ...

    Closure::fromCallable(function () {
        TestCase::assertSame('Jan', $this->firstName);
        TestCase::assertSame('Kowalski', $this->lastName);
    })->bindTo($objectUnderTest, Person::class)();
}

Gdy pierwszy raz zobaczyłem takie podejście byłem zaskoczony że tak się da, zaraz potem uznałem że tak nie powinno się robić. Tyle raz powtarzano, że testowaniu powinno podlegać jedynie publiczne API obiektu, a jego wewnętrzny stan powinien zostać w ukryciu... i jest to mądre podejście. Dzięki temu mamy wolną rękę w przeprowadzaniu refaktoru - raz napisany test, daje nam możliwość weryfikowania czy ulepszanie wewnętrznej struktury obiektu po zmianach działa dalej tak jak tego oczekujemy.   

To rozwiązanie łamię zasadę, wiążąc test z ukrytymi przed resztą kodu produkcyjnego wnętrznościami Agregatu. Potem jednak zacząłem się zastanawiać na ile realnie problematyczny będzie on w utrzymaniu i czy czasem od dawna nie mierzymy się z tego typu problemami.

🔒 Kod produkcyjny dalej nie wie o stanie Agregatu

Niewątpliwą zaletą zastosowania metody Closure::bindTo jest fakt, że nie wpływa ona w żadnym stopniu na kod produkcyjny. Nie utworzyliśmy specjalnych getter'ów wykorzystanych tylko na potrzeby testów co w przyszłości mogłoby skłonić innych developerów do skorzystania z nich. W pewnym stopniu getter jest formą udostępniania wartości 1:1 z tym co jest wewnętrznym stanem obiektu. Dodanie getter'a wygląda jak rozszerzenie publicznego API, ale tylko na pozór. Bo jeżeli zmienimy wewnętrzny stan to będziemy musieli zmienić owego getter'a. Można by zastanowić się nad innymi realnymi problemami z tym związanymi, ale jest to temat na inny wpis.   

W każdym razie wydaje mi się, że problem tworzenia takich getter'ów istnieje w światku PHP, jest stosunkowo często stosowany i tylko sprawia wrażenie niegroźnego - dlatego powinniśmy zwracać na to uwagę. 

Stosowanie Closure::bindTo zamias dedykowanych getterów chroni kod produkcyjny.

🇬🇧 Szkoła Londyńska

Pisząc test jednostkowy klasy typu serwis (np. Event Subscriber'a, Command Handler'a) według paradygmatów tz. Szkoły Londyńskiej musimy najpierw zamockować jego zależności. Można to zrobić za pomocą customowych implementacji InMemoryRepository bądź przy użyciu narzędzi takich jak Prophecy/MockObject.

I tutaj właśnie zacząłem dostrzegać podobieństwo względem opisywanego przykładu. Dokładnie wiemy jak w teście jednostkowym należy zbudować zależność wymaganą przez object under test oraz jak będzie wyglądała interakcja pomiędzy zależnością, a testowanym obiektem.

Musimy jawnie oprogramować to związanie by test mógł przejść na zielono. Test oczywiście weryfikuje publiczne API serwisu, ale wie też coś o jego wewnętrznej pracy. Zmieniając zależności serwisu będziemy musieli zaktualizować testy - dokładnie tak samo jak w opisywanym przypadku wykorzystania Closure::bindTo.

Jest jednak subtelna różnica, w przypadku Closure::bindTo funkcja anonimowa musi dokładnie wiedzieć w jakim polu znajduje się wartość czyli pożądana przez nas zmiana stanu. W przypadku weryfikowania mock'ów zaś, ta informacja w dalszym ciągu jest przed nami ukryta, ale niewątpliwie w jednym i drugim przypadku odwołujemy się podczas wykonywania asercji do wewnętrznego stanu obiektu.   

Jak widać ten problem towarzyszył w projekcie w którym pracuje praktycznie od samego początku jego powstania, a mimo to dało się z nim żyć - co więcej - nikt nie uznawał tego za problematyczne. W opisywanym przypadku pojawił się jedynie w nieco innej formie (inny typ testowanego obiektu), ale to dalej nic nowego z czym wcześniej się nie borykaliśmy.  

🗃 Dla jakich typów obiektów?

Na samym początku muszę zauważyć, że chodzi o zmianę stanu obiektu dlatego z tego rozwiazania należałoby korzystać tylko w przypadku testowania metod void'owskich. Takich metod nie posiadają obiekty typu:

❌ Event
❌ DTO
❌ Value Object

Serwisy z metodami void'owskimi też weryfikujemy w inny sposób - sprawdzając ich z'mock'owane zależności, dlatego kolejno odpadają nam:

❌ Event Subscriber
❌ Command Handler
❌ Serwis Aplikacyjny/Domenowy

To co właściwie pozostało to:

✅ Encja
✅ Agregat 

Lecz tylko w przypadku gdy takowe już nie udostępniają swojego stanu dla innego kodu produkcyjnego!

🩻 Inny sposób udostępniania stanu

Taki sam efekt możnaby osiągnąć stosując refleksje:

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');

    // When
    // ...

    $reflection = new ReflectionClass(Person::class);
    $firstNameProperty = $reflection->getProperty('firstName');
    $firstNameProperty->setAccessible(true);
    $lastNameProperty = $reflection->getProperty('lastName');
    $lastNameProperty->setAccessible(true);

    self::assertSame('Jan', $firstNameProperty->getValue($objectUnderTest));
    self::assertSame('Kowalski', $lastNameProperty->getValue($objectUnderTest));
}

W porównaniu z zastosowaniem Closure::bindTo wypada podobnie jeżeli chodzi o podejście czy konsekwencje dla potencjalnego refaktoru. Więcej linii kodu negatywnie wpływa na czytelność i w wątpliwość możemy poddawać szybkość działania, co nie powinno stanowić problemu gdy ten kod tak czy inaczej nie będzie działał produkcyjnie. 

♻️ Warstwa abstrakcji na asercję

Można by dodatkowo schować taką implementację przed testem w customowej klasie Assert. Jeżeli z biegiem czasu pojawi się jakiś sposób na lepsze udostępnianie stanu niż za pomocą Closure::bindTo lub refleksji to sama klasa testowa nie będzie wymagała modyfikacji. Dodatkowo wprowadzenie specjalnej klasy asercji wpłynie na poprawę czytelności samego testu.

public function test(): void
{
    $objectUnderTest = new Person('Jan', 'Kowalski');
    
    // When
    // ...
    
    PersonAssert::sameName('Jan', 'Kowalski', $objectUnderTest);
} 

📝 Podsumowanie

Koniec końców uważam, że zastosowanie Closure::bindTo było dobrym pomysłem. Pomimo tego, że udostępniamy prywatny stan na zewnątrz, napisany test jednostkowy przynosi wartość. Co prawda jesteśmy mniej odporni na zmiany klasy poddanej testom, ale z drugiej strony nie mieliśmy zbytniego wyboru. Moglibyśmy użyć testów integracyjnych lecz nie są one tak precyzyjne jak testy jednostkowe. Niekiedy nie dysponujemy takim rozwiązaniem w projekcie co uniemożliwiłoby całkowicie otestowanie takiej klasy. Korzystanie z Closure::bindTo powinno być ograniczone tylko do absolutnej konieczności gdy nie mamy innego wyboru, jest ono obarczone pewnym obciążeniem, ale na tyle małym by w dalszym ciągu utrzymać wszystko w ryzach. 

Jedyne nad czym trzeba się zastanowić to czy weryfikacja działania obiektu jest więcej warta niż poniższe efekty uboczne:

⚠️ zmiana wewnętrznego stanu wymaga naprawienia testu
⚠️ zmiana nazwy pola sprawi że komunikat nieprzechodzącego testu nie będzie do końca jasny (UndefinedProperty)
⚠️ narzędzia do analizy statycznej mogą zgłaszać błąd w związku z nieznanym polem

    

 

sobota, 22 października 2022

Komunikacja między Modułami Monolitu: zlecanie prac


Realizacja nowej funkcjonalności biznesowej często wykracza poza ramy jednego Modułu Monolitu. Jako że muszą się ze sobą komunikować m.in. w celu oddelegowania zadań dalej - warto przeanalizować sobie rodzaje komunikacji jakie możemy zaimplementować.

 

🔌 Wariant #1: Fasada

Moduł B jest całkowicie zależny od modułu A

W tym przypadku moduł bieżący (B) w którym piszemy zleconą nam funkcjonalność musi oddelegować część prac do innego zewnętrznego modułu (A). Delegowanie pracy w praktyce polega na wywołaniu void'owskiej metody (Command) Fasady leżącej w zewnętrznym module

W opisywanym przypadku moduł bieżący jest tym znajdującym się w dole strumienia. Całkowicie musi się on poddać kontraktowi komunikacji ustanowionego przez upstream'owy moduł zewnętrzny. Moduł A wie jakich składników będzie potrzebował do wykonania swojego zadania, dlatego stanowią one parametry metody Fasady. W gestii modułu downstream'owego leży to by takowe parametry dostarczyć - warto się zastanowić czy posiadanie przez niego takich informacji jest w ogóle poprawne. 

Warto podkreślić, że moduł A nie wie nic o swoich klientach - udostepnia API (Interfejs Fasady) do komunikacji z innymi modułami i nie dba o to czy jest ich pięciu czy nie ma ani jednego. W tym przypadku to moduł zewnętrzny A jest niezależny od innych modułów. Idąc tym tropem: odpowiedzmy sobie najpierw na pytanie dlaczego moduł bieżący B do wykonania w pełni swojej funkcjonalności musi oddelegować część pracy do zewnętrznego modułu - czy granice zostały poprawnie wytyczone?

Moduł B z kolei wie dokładnie o istnieniu innego modułu A i jak się z nim komunikować (API Fasady). Jest to więc jawna deklaracja komunikacji jednostronnej: 

  • wiem co chcę zrobić,
  • wiem kto to zrobi,
  • wiem dokładnie jak zmusić tego kogoś do wykonania tej czynności.

 

📨 Wariant #2: Event'y

W tym wypadku moduł bieżący (A) w którym dodajemy funkcjonalność, informuje inne bliżej nieokreślone moduły o zaistnieniu pewnego zdarzenia wewnątrz swoich granic. Robi to emitując Event Aplikacyjny (Sync/Async) - nie troszcząc się o to kiedy i przez kogo zostanie on obsłużony.  

Moduły B i C są całkowicie zależne od Modułu A

Zaimplementowanie Event'u w Warstwie Aplikacji wychodzącego poza granicę bieżącego modułu jest swego rodzaju zdefiniowaniem kontraktu komunikacji

Moduł bieżący (A) nie dba o to czy Event'y zostaną obsłużone, istotne jest to tylko dla zewnętrznych modułów (downstream'owych) które decydują się na nasłuchiwanie na tego typu zdarzenia.

Downstream'owe moduły B i C muszą dostosować się do kontraktu ustalonego przez moduł A. Muszą one być w stanie wykonać swoją pracę na postawie danych znajdujących się w Event'cie. 

W tym wypadku, nie możemy powiedzieć że moduł A chce oddelegować część pracy do innych modułów. Jedynie daje im znać, że coś się u niego zdarzyło. 

Jest to więc całkowicie inna sytuacja niż gdyby miał on skorzystać z Fasad modułów B & C wywołując ich metody void'owskie. 

Możnaby ponownie wypisać stwierdzenia jak w przypadku poprzedniej sekcji - Stan bieżącego modułu uległ zmianie:

  • nie wiem kogo to interesuje,
  • nie wiem czy będzie miało to jakiekolwiek konsekwencje.

 

🔎 Porównanie

W przypadku korzystania z Fasady niejawnie zakładamy, że coś musi się zadziać i nie możemy obejść się bez tej funkcjonalności z zewnętrznego modułu. Wydaje się, że wybierając takie rozwiązanie funkcjonalność wykonywana za Fasadą (upstream) jest naprawdę ważna. 

W przypadku emitowania Event'ów przez moduł bieżący - nie dbamy o to jaki klient/klienty obsłużą to zdarzenie. Zastanawiam się czy z takim podejściem, obsługujące to zdarzenie moduły downstreamo'we wykonują funkcjonalności drugorzędne... tak - ale tylko z perspektywy bieżącego modułu.

Bieżący moduł komunikacja z Fasadą emitowanie Event’ów
Hierarchia downstream upstream
Czy tworzy kontrakt nie tak
Czy wie kto będzie wykonywał czynność tak nie
Czy mamy pewność wykonania zleconych prac tak nie
Czy konsekwencje prac są znane tak nie
Sposób komunikacji Sync Sync/Async