piątek, 3 kwietnia 2020

Oddelegowanie funkcji anonimowej do odrębnej klasy


Przypadek użycia


    Korzystamy z zewnętrznej biblioteki, gdzie parametrem metody, jednej z należącej do niej klas użytkowych jest zmienna, która może być wywołana jako funkcja. Zwykle w miejscu parametru tworzymy funkcję anonimową. Załóżmy, że sytuacja dzieje się w akcji kontrolera framework'a Symfony i wykorzystujemy zmyśloną zewnętrzną bibliotekę Datatables:

use Doctrine\ORM\QueryBuilder;
use ExternalLibrary\Datatables;
use App\Entity\User;
use App\Service\Service;

final class Controller 
{
    /** @var Service */ private $service;

    public function __construct(Service $service)
    {
        $this->service = $service;
    }

    public function someAction(
        Datatable $datatable,
        User $user
    ): Response {
        $datatable
            ->doSomething(
                function(QueryBuilder $qb) use ($user): void {
                  // implementacja z wykorzystaniem 
                  // zmiennej $user i pola $this->service
}
           )
            ->getResponse();
    }

}

Pojawia się tutaj kilka problemów które wynikają z natury funkcji anonimowych:

⚠️ co prawda posiadamy dostęp do obiektu $this będącego obecną instancją kontrolera, ale do zmiennej $user nie mamy dostępu w funkcji anonimowej i musimy ją już jawnie przekazać korzystając ze słowa kluczowego use. Prawdopodobnie będzie ona wykorzystana tylko wewnątrz funkcji anonimowej i do akcji kontrolera będzie wstrzyknięta tylko w celu pośrednim. Można by było wstrzyknąć pożądany obiekt klasy User do konstruktora kontrolera, ale wtedy byłby on w zasięgu każdej akcji które by tego nie wymagały co nie jest dobrym rozwiązaniem. Trzeba zadać sobie pytanie czy zależność do klasy User na pewno wymagana jest w naszym kontrolerze,

⚠️ przekazany do funkcji anonimowej parametr typu QueryBuilder sprawia, że kontroler jest zależny od klasy, która być może powinna znajdować się w świadomości klas należących do warstwy infrastruktury, 

⚠️ zaimplementowana funkcja anonimowa nie może być wykorzystana w innym kontrolerze/klasie.


Zamiana funkcji anonimowej na publiczną metodę kontrolera


    Takie rozwiązanie sprawia, że funkcja była by jedynie w zasięgu metod kontrolera. Negatywnym skutkiem ubocznym byłby rozszerzenie interfejsu klasy kontrolera o zbędną funkcję oraz w dalszym ciągu występuje problem ze zmienną $user. Uproszczeniu został by poddany natomiast kod omawianej akcji (co wydaje się jedyną zaletą):


$datatable
    ->doSomething([$this, 'newMethodName'])
    ->getResponse;


Nowa klasa z jedną metodą publiczną 


Klasa ta posiadałaby tylko jedną metodę o sygnaturze: 

public function execute(QueryBuilder $queryBuilder): void;

Cechy tego rozwiązania:

 zależności specyficzne tylko dla tej klasy (klasy QueryBuilder, User wstrzyknięte do bezpośredni do konstruktora) nie wyciekały by do klasy kontrolera,
 dzięki zarejestrowaniu tej klasy w Kontenerze Zależności, możemy ją wielokrotnie wstrzykiwać do innych klas,
 rozwiązanie staje się łatwe w testowaniu,
 upraszcza kod akcji kontrolera,
 możliwość nadania nazwy klasie zdradzającej jej intencje,
❌ dodatkowy plik w projekcie,
❌ gdy metoda klasy z biblioteki zewnętrznej oczekuje parametru typu Callable lub Closure, nie będziemy mogli zastosować tego rozwiązania,

Kod nowej klasy: 

use Doctrine\ORM\QueryBuilder;
use App\Entity\User;
use App\Service\Service;

final class ActiveUserProductsQueryBuilder
{
    /** @var Service */ private $service;
    /** @var User    */ private $user;

    public function __construct(
        User $user,
        Service $service
    ) {
        $this->user = $user;
        $this->service = $service;
    }

    public function execute(QueryBuilder $queryBuilder): void {
        // implementacja
    }
}


 Kod kontrolera pozbawiony jest teraz zależności do klasy User, Service oraz QueryBuilder, a nowa zaimportowana klasa ActiveUserProductsQueryBuilder może znajdować się w warstwie infrastruktury:


use ExternalLibrary\Datatables;
use App\Infrastructure\ActiveUserProductsQueryBuilder;

final class Controller 
{
    public function someAction(
        Datatable $datatable,
        ActiveUserProductsQueryBuilder $queryBuilder
    ): Response {
        $datatable
            ->doSomething([$queryBuilder, 'execute'])
            ->getResponse();
    }

}

Wykorzystanie metody magicznej __invoke()


    W tym przypadku można było by jeszcze bardzie uprościć kod klasy jak i klienta stosując metodę magiczną __invoke() wprowadzoną do PHP w wersji 5.3. Instancje klasy z implementacją tej metody można przekazywać jako parametr metody która Type Hint'uje na Callable

  • uproszenie kodu klasy polega na tym, że gdy posiada ona tylko jedno zachowanie, samo wykorzystanie __invoke() o tym mówi i daje znak innym programistą w zespole, że dodawanie nowych metod w tym miejscu jest błędne. 
  • uproszenie kodu klienta sprowadza się do tego, że nie jesteśmy już zobowiązani wskazywać na konkretną metodę zmiennej $queryBuilder:

$datatable
    ->doSomething($queryBuilder)
    ->getResponse;


Wyodrębnienie interfejsu


    W wyniku refaktoryzacji została wyodrębniona klasa ActiveUserProductsQueryBuilder ze stałym zachowaniem - jedną metodą __invoke(). W związku z tym można by utworzyć wyodrębniony interfejs i zaimplementować go do wspomnianej klasy. Jego kod przedstawiam poniżej, ale w zasadzie nie dzieje się tutaj nic nowego:

use Doctrine\ORM\QueryBuilder;

interface UserProductQueryBuilderInterface
{
    public function __invoke(QueryBuilder $queryBuilder): void;
}  

    Uaktualniony kod kontrolera różni się w zasadzie tylko jednym szczegółem od poprzedniej wersji: Type Hint na parametr (w akcji) $queryBuilder zmienia się na klasę interfejsu. Wiązanie klasy konkretnej ActiveUserProductsQueryBuilder z interfejsem UserProductQueryBuilderInterface odbywało by się w Kontenerze Zależności framework'a Symfony. Ten krok refaktoryzacyjny sprawia, że klasa kontrolera nie jest ściśle związana z konkretną implementacją i z łatwością możemy wprowadzić nowe funkcjonalności bez ingerowania w kod kontrolera. W przyszłości, do zestawu klas implementujących UserProductQueryBuilderInterface  mogły by dołączyć kolejne zachowania, które reprezentują przykładowe klasy:
  • NewestProductListQueryBuilder,
  • DiscountProductListQueryBuilder,
    Dzięki temu, że nowe klasy zawierają kontrakt z interfejsem i kontroler nie musi być modyfikowany, cała nasz praca będzie sprowadzała się utworzenia nowej klasy i zmodyfikowaniu klasy fabrycznej...  

Klasa fabryczna tworząca konkretną implementacje 


    W przypadku jednej klasy konkretnej, konfiguracja w Kontenerze Zależności jest prosta bo polega tylko na jawnym przypisaniu klasy konkretnej do interfejsu. W przypadku występowania wielu klas konkretnych wymagane będzie utworzenie klasy fabrycznej, która będzie decydować o tym jaka z konkretnych implementacji ma zostać instancjonowana. Klasa ta może znajdować się w tym samym namespace co klasy konkretne by uniknąć jawnego implementowania każdej z nich. Decyzja o wyborze klasy może być oparta np. o dane zawarte w klasie Symyfony Symfony\Component\HttpFoundation\Request.



    Poniżej schemat struktury klas/flow: 

╔════════════════════════════════════════════════════╗
                 Kontener Zależności               
╠════════════════════════════════════════════════════╣
  ┌─────   ProductListQueryBuilderFactory   ─────┐
  getInstance(): ProductListQueryBuilderInterface
  ──────┬─────────────────────────┬──────────────┘
╚═════════╪═════════════════════════╪════════════════╝
   ┌──────┴──────┐           ┌──────┴──────┐
   │Wstrzykiwanie│           │Rejestrowanie│
   └──────┬──────┘           └──────┬──────┘
╔═════════╧═════════╗ ╔═════════════╧══════════════════╗
ProductController ProductListQueryBuilderFactory
╠═══════════════════╣ ╚═════════════╤══════════════════╝
action($productQb)┌──────────────┴──────────────────┐
╚═══════════════════╝ProductListQueryBuilderInterface
                     ├─────────────────────────────────┤
                     public __invoke(QueryBuilder $qb)
                     │:void                            
                     └──────────────┬──────────────────┘
                      ┌─────────────┘
                      ╔═══════════════════════════════╗
                      ActiveProductListQueryBuilder  ║
                      ╚═══════════════════════════════╝
                      ╔═══════════════════════════════╗
                      NewestProductListQueryBuilder  ║
                      ╚═══════════════════════════════╝
                      ╔═══════════════════════════════╗
                      DiscountProductListQueryBuilder
                       ╚═══════════════════════════════╝



Brak komentarzy:

Prześlij komentarz