środa, 19 maja 2021

SOLID - zasada Open/Closed w praktyce

 

"Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"

 

    Analiza dotyczyła będzie rozbudowania kodu o nową funkcjonalność bez  modyfikowania istniejących klas. Spotkałem się z sytuacją gdzie klasa budująca model domenowy na podstawie danych wyciągniętych z bazy danych powinna też tworzyć całe kolekcje takich modeli. Można by:

  1.  zmodyfikować klasę budowniczego dodając nową metodę buildCollection(int ...$ids): array,

  2.  istniejącą metodę budowniczego build($id): Model wywołać w pętli w klasie klienta,

Wiele zapytań do bazy

    Drugie rozwiązanie z perspektywy zasady Open/Closed wydaje się lepsze bo nie wymaga od nas modyfikacji istniejącego kodu, ale wiąże się z nim pewien problem. W ciele metody Builder::build wykonywane jest zapytanie do bazy danych (RepositoryInterface::featchById) przez co im więcej obiektów do utworzenia tym gorzej dla optymalności rozwiązania. 

Jedno zapytanie do bazy

    Można zaimplementować jednak rozwiązanie nie ingerujące w istniejący kod wykonujące tylko jedno zapytanie do bazy. Dzięki temu, że klasa Builder posiada wiedzę tylko o interfejsie repozytorium możemy utworzyć kolejną implementację RepositoryInterface - InMemoryRepository.

InMemoryRepository posiada naprawdę prosta implementację i to na jej podstawie będzie tworzony obiekt Builder w klasie klienta zwracającego kolekcje modeli.

 
final class InMemoryRepository implements RepositoryInterface 
{
	/**
     * @param Row[] $rows
     */
	public function __construct(private array $rows) {}
	
	public function featchById($id): ?Row 
    {
		return $this->rows[$id] ?? null; 
	}
	
	/** 
     * @return Row[] 
     */
	public function featchByIds(int ...$ids): array 
    {
		return array_filter(
			$this->rows,
			static fn (Row $row): bool => in_array($row->id(), $ids);  
		);
	}
}

Pozostaje tylko napełnić instancję InMemoryRepository danymi z już istniejącego PDORepository

 

Query: GetModel (dotychczasowe) 

    Istniejąca funkcjonalność pobierania tylko jednego modelu zamknięta jest w klasie GetModel która realizuje tą funkcjonalność na podstawie obiektu Builder pobranego z kontenera zależności.

Odbywa się tu tylko jedna istotna czynność:

$this->builder->build($id);
 

Query: GetModelCollection (nowe)

W przeciwieństwie do poprzedniej klasy GetModel tutaj będziemy bezpośrednio operować na PDORepository (pod przykrywką RepositoryInterface). Dzieje się tutaj o wiele więcej niż w poprzednim przypadku:

  1. pobranie danych z bazy RepositoryInterface::featchByIds,
  2. utworzenie na ich podstawie instancji InMemoryRepository,
  3. utworzenie instancji Builder na podstawie InMemoryRepository,
  4. wywołanie metody Builder::build w pętli.
/** 
 * @return Model[]
 */
public function __invoke(int ...$ids): array
{
	$builder = new Builder(
		new InMemoryRepository(
			$this->repository->featchByIds(...$ids)
		)
	);
	
	return array_map([$builder, 'build'], $ids);
} 

Podsumowanie

  • ✅ Nie zmodyfikowaliśmy żadnej istniejącej klasy,
  • nowa funkcjonalność bazuje na działających już wcześniej klasach,
  • ❌ kolejne użycie słowa kluczowego "new" dla klasy Builder,
  • Repository::fetchByIds musi być idealnie zgrane z wywołaniem Builder::build() w pętli. Repository musi dawać gwarancję, że zawsze zwracany jest komplet danych, w przeciwnym razie powinien wyrzucać wyjątek @throws NotFoundAllIds

    Dzięki takiej implementacji nie ingerowaliśmy w działający koda, a do nowej funkcjonalności wykorzystaliśmy sprawdzone/przetestowane klasy. Dodatkowo rozwiązanie jest całkowicie odseparowane (nowa klasa Query) dzięki temu w całości zachowaliśmy funkcjonalność pobierania pojedynczego modelu.

    Takie podejście uwydatnia swoje zalety gdy proces budowania modelu w budowniczym jest skomplikowany a sam Builder posiada wiele zależności.