sobota, 6 listopada 2021

Commandy Aplikacyjne niezależne od infrastruktury (cz. 1)

    Załóżmy, że posiadamy obiekt Data Transfer Object np. Command. Obiekt tego typu zajmuje się jedynie przechowywaniem danych i nie oferują żadnych zachowań poza udostępnianiem swojego stanu. Wykorzystujemy go do przekazywania parametrów do konkretnego Serwisu Aplikacyjnego realizującego funkcjonalność biznesową. W uproszczonej formie klasa typu Command przechowująca dane potrzebne do utworzenia użytkownika prezentowałaby się następująco:     


namespace Application;

final class CreateUserCommand 
{
	public function __construct(
		public readonly string $email,
		public readonly int $type,
		public readonly \DateTimeImmutable $birthDate
	) {} 
}

     

    Jako, że obiekty te tworzone są na podstawie danych z poza granic aplikacji, np. z Request'a HTTP, mogą posiadać Fabryczne Metody Pomocnicze które tworzą instancję na podstawie danych z m.in.:

  • JSON/XML z ciała Request'a HTTP
  • argumentów Command'a CLI
  • obiektu Symfony Form
  • danych z pliku CSV

 

Budowanie Command na podstawie zewnętrznych danych

    JSON na podstawie którego budujemy obiekty Command pochodzi z poza granic naszej aplikacji tzw. Outside World. Jest on wysłany po HTTP pomiędzy różnymi aplikacjami.

JSON z poza granic aplikacji. Obiekt Command jest budowany na jego podstawie.
 

    Jak wynika z diagramu, Command budowany jest na podstawie danych z obiektu JSON. Pomimo tego, że znajduje się on w Warstwie Aplikacji, jego instancjonowanie ma miejsce w Warstwie Infrastruktury - w tym przypadku Kontrolerze HTTP. 

Przyjrzyjmy się najpierw samemu obiektowy JSON

{
	"email": "john.doe@gmail.com",
	"user_type": 2,
	"birth_date": "1992-11-05"
}

Wiedza jaką powinna dysponować klasa która będzie go odczytywać to:

  • jakie posiada klucze

  • jakiego typu wartości dany klucz może przechowywać

Warto zaznaczyć, że te informacje pochodzą z wspomnianego Outside World. Oto przykład klasy odpowiadającej za mapowanie JSON'a do obiektu Command:

 

namespace Infrastructure;

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Application\CreateUserCommand;

final class UserController 
{
	public function createUserAction(Request $request): Response 
	{
		/** @var array $payload */
		$payload = $request->getParsedBody();
		
		$command = new CreateUserCommand(
			$payload['email'],
			$payload['user_type'],
			new \DateTimeImmutable($payload['birth_date'])
		);
	
		// ...
	}
}

 

    Jak widać Kontroler z Warstwy Infrastruktury posiada wiedzę o strukturze zewnętrznego obiektu, ale jest to nieco naiwna implementacja. Nie możemy ufać klientowi, co do tego czy dostarczył prawidłową strukturę/dane. JSON może 💣 nie zawierać danego klucza bądź 💣 przechowywać nieoczekiwane wartości. Moglibyśmy ograniczyć się do sprawdzenia czy dany klucz istnieje, w przeciwnym wypadku zadając NULL oczekując tym samym wyrzucenia TypeError (przechwycony gdzieś wyżej):

 

try {
	$command = new CreateUserCommand(
		$payload['email'] ?? null,
		$payload['user_type'] ?? null,
		isset($payload['birth_date']) 
        	? new \DateTimeImmutable($payload['birth_date'])
            : null
	);
	// ...
} catch (TypeError $e) {
	// ...
}

 

Bądź użyć asercji (również rzucających wyjątkiem InvalidArgumentException), które bardziej opisowo przedstawiają warunki które ma spełniać poprawny Request Payload:

try {
	Assert::notEmptyString($payload['email'] ?? null);
	Assert::integerish($payload['user_type']  ?? null);
	Assert::stringDateTime($payload['birth_date']  ?? null);

	$command = new CreateUserCommand(
		$payload['email'],
		(int) $payload['user_type'],
		new \DateTimeImmutable($payload['birth_date'])
	);
	// ...
} catch (InvalidArgumentException $e) {
	// ...
}

 

Warto zauważyć, że wiedza na temat struktury JSON'a pozostaje na poziomie Warstwy Infrastruktury i nie przenika do Warstwy Aplikacji. Jak widać Akcja Kontrolera stała się dość obszerna ponieważ w przeważającej części zajmuje się tworzeniem poprawnego obiektu Command. Warto zastanowić się nad czytelniejszym rozwiązaniem.

Command z Metodą Fabryczną

    Można by pokusić się o przeniesienie kodu odpowiadającego za interpretację JSON'a (ze świata zewnętrznego) do samej klasy Command:

final class CreateUserCommand 
{
	public function __construct(
		public readonly string $email,
		public readonly int $type,
		public readonly \DateTimeImmutable $birthDate
	) {} 
	
	/** 
	 * @param array<int|string, mixed> $payload 
	 * @throws InvalidArgumentException on invalid payload
	 */
	public static function createFromRequest(array $payload): self 
	{
		Assert::notEmptyString($payload['email'] ?? null);
		Assert::integerish($payload['user_type']  ?? null);
		Assert::stringDateTime($payload['birth_date']  ?? null);
		
		return new self(
			$payload['first_name'],
			(int) $payload['user_type'],
			new \DateTimeImmutable($payload['birth_date'])
		);
	}
}

 

    Znacznie uprościło by to implementację Kontrolera ponieważ utworznie obiektu Command sprowadzało by się tylko do jednej linii...

$command = CreateUserCommand::createFromRequest($payload);

...Ale

Warstwa Aplikacji byłaby zależna od JSON'a z poza granic aplikacji. Na każdą zmianę struktury JSON'a (Warstwa Infrastruktury) np. zmiany nazwy klucza z snake_case na camelCase, musielibyśmy modyfikować byt z rdzenia aplikacji w postaci Command'a Aplikacyjnego. Pomimo, że nazwa klucza może się wydawać tylko niewinnym string'iem to tak naprawdę jest to zależność, a w tym przypadku - błędnie ukierunkowana zależność.

Command Aplikacyjny jest bezpośrednio zależny od Warstwy Infrastruktury.

Command posiada od teraz dwie odpowiedzialności: przechowywanie danych & mapowanie obiektu JSON na swój stan. Dwie odpowiedzialności === dwa powody do zmian.  

❌ dodanie obsługi kolejnych mediów przekazu (np. CLI Command) sprawia, że klasa staje się coraz większa.

Pragmatyzm

    Jak wynika z analizy powyżej, umieszczanie kodu interpretującego JSON'a w Warstwie Aplikacji to rozwiązanie łamiące zasady czystego kodu. Z drugiej strony pozostawienie go w Kontrolerze HTTP też nie wydaje się słuszne. W nadchodzącym wpisie zostanie opisane jeszcze jedno rozwiązanie: wprowadzenie klasy Fabrycznej odpowiadającej za powiązanie JSON'a z Command'em. Można zastanowić się czy tworzenie nowej klasy dla tego typu zadania nie jest przerostem formy nad treścią. Jeżeli patrzymy tylko pod kątem czytelności, to dodanie Metody Fabrycznej do Command'a wydaje się zadowalające ponieważ w jednym miejscu mamy powiązane ze sobą zagadnienia. Dodatkowo programiści, jeżeli będą chcieli utworzyć nową instancję Command'a nie będą musieli szukać dodatkowych Klas Fabrycznych - wszystko mają pod ręką. 

    Z drugiej strony, gdy zależy nam na utrzymaniu odpowiedniego kierunku zależności - Metoda Fabryczna nie spełnia tego wymagania. Zależności do Warstwy Infrastrukturyniejawne i subtelne ponieważ opierają się jedynie na string'owym kluczu array'a otrzymanego jako parametr. Nie zmienia to jednak faktu, że jakakolwiek zmiana w obiekcie JSON będzie musiała być naniesiona w Warstwie Aplikacji. Wymienione wyże typy array i string sprawiają wrażenie, że nie niosą ze sobą obciążenia zależnościami. Gdyby były reprezentowane przez Value Object'y z Warstwy Infrastruktury, zależności stałby się jawne i od razu widoczne w sekcji use klasy CreateUserCommand.   

Brak komentarzy:

Prześlij komentarz