Blog
Mały… a potrafi! czyli Adapter
W ostatnim czasie przez bloga przewinęły się wpisy, w których został wspomniany wzorzec Adaptera. Jako, że jego tematyka nie została rozwinięta, postanowiłem zebrać garść informacji o tym wzorcu.
Adapter. Bardzo prosty wzorzec projektowy, “przeplotka”. Z pozoru nic szczególnego i nie ma się czym zachwycać, ale jak wiadomo.. pozory mylą. Gdyby przyrównać wszystkie wzorce do grupy uczestników australijskiego ultra maratonu w 1983 Sydney – Melbourne, Adapter byłby niewątpliwie samym Cliffem Youngiem! Łączy ich to, że obaj są prości w swojej istocie, a może i czasem niedoceniani. Niesłusznie, bo mogą wiele.
OK, przejdźmy do gęstego.
Adapter jest to strukturalny wzorzec projektowy, użyteczny gdy dwa niekompatybilne interfejsy muszą współpracować razem. Tworzy dodatkową warstwę pośrednią, definiuje nowy interfejs do istniejącego obiektu, aby dopasować go do tego, czego wymaga klient.
William Sanders w swojej książce PHP. Wzorce projektowe adapter opisał tak:
“Możesz myśleć o wzorcu Adapter jak o poradni dla małżeństw — niweluje różnice, tworząc wspólny interfejs. Dzięki niemu odrębne części nie muszą być przebudowywane, a mimo to mogą współpracować.”
Ponadto wzorzec adapter jest jednym ze sposobów zapewniania Interface Segregation Principle. Jest też bardzo ważnym elementem w clean/hexagonal/onion architecture, której stosowanie może znacznie uprościć pisanie odizolowanych testów.
Implementacja:
Istnieją 2 formy wzorca Adaptera
Forma obiektowa
class Adapter implements SomeInterface { private $someClassToAdapt; public function __construct(SomeClassToAdapt $class) { $this->someClassToAdapt = $class; } public function methodB() { return $this->someClassToAdapt->methodA(); } }
Stosowanie takiej implementacji posiada następujące plusy:
- łatwa w testowaniu
- klienta można uzależnić od interfejsu a nie od konkretnej klasy
- brak silnej zależności po między adapterem i klasą adoptowaną zarówno w kodzie aplikacji jak i w testach
Jak powszechnie wiadomo, nie licząc kiełbaski z grilla z piwem, nic nie jest idealne. Tak jest i w przypadku tej implementacji. Posiada ona następujące minusy:
- trzeba stworzyc nową klasę, tworzyć jej instancje i zwracac z nią obiekt bedacy przedmiotem dostosowania
- większe zużycie zasobow
Forma klasowa
class Adapter extends SomeClassToAdapt { public function methodB() { return parent::methodA(); } }
Taka implementacja moim zdaniem ma tylko jedną zaletę. Jest łatwiejsza w użyciu niż forma obiektowa.
Natomiast posiada kilka wad:
- wprowadza silną zależność pomiędzy adapterem i klasą adaptowaną (zarówno w kodzie aplikacji jak i w testach) co może prowadzić do tego, że
- zmiany w metodach klasy adaptowanej, które nie są wykorzystywane w adapterze, mogą mieć wpływ na działanie adaptera
- mniej wygodna w testowaniu
Aby przekonać się jak każda z implementacji może wpłynąć na kształt naszych testów, przyjrzyjmy się poniższemu przykładowi.
Testy formy obiektowej
class Invoice { public function __construct(Items $items, Payer $payer) { ... } public function getPayerStreet() { ... } public function getPayerHauseNo() { ... } } class InvoicePdfAdapter implements PdfGenerated { private $invoice; public function __construct(Invoice $invoice) { $this->invoice = $invoice; } public function getPayerAddress() { return $this->invoice->getPayerStreet() . ' ' . $this->invoice->getPayerHauseNo(); } } class InvoicePdfAdapter_getPayerAddress_Test extends TestCase { const PAYER_STREET = 'street'; const PAYER_HOUSE_NO = '2'; public function test_that_returns_payer_address() { $invoice = $this->getMockBuilder(Invoice::class) ->disableOriginalConstructor() ->getMock(); $invoice ->method('getPayerStreet') ->will($this->returnValue(self::PAYER_STREET)); $invoice ->method('getPayerHouseNo') ->will($this->returnValue(self::PAYER_HOUSE_NO)); $adapter = new InvoicePdfAdapter($invoice); $actualPayerAddress = $adapter->getPayerAddress(); $expectedPayerAddress = self::PAYER_STREET . ' ' . self::PAYER_HOUSE_NO ; $this->assertEquals($expectedPayerAddress, $actualPayerAddress); } }
Testy adaptera formy obiektowej możemy wykonać w zupełnej izolacji od obiektu adaptowanego używając mockowania. Zakładam, że klasa Invoice istniała już w systemie, więc testy dla niej już istnieją.
Poniższy przykład przedstawia próbę testowania
Formy klasowej
class InvoicePdfAdapter extends Invoice { public function getPayerAddress() { return $this->getPayerStreet() . ' ' . $this->getPayerHauseNo(); } } class InvoicePdfAdapter_getPayerAddress_Test extends InvoiceTestCase { const PAYER_STREET = 'street'; const PAYER_HAUSE_NO = '2'; public function test_that_returns_payer_address() { $items = $this->getMock(Items::class); $payer = $this->getMock(Payer::class); $payer ->method('getStreet') ->will($this->returnValue(self::PAYER_STREET)); $payer ->method('getHauseNo') ->will($this->returnValue(self::PAYER_HAUSE_NO )); $adapter = new InvoicePdfAdapter($items, $payer); $actualPayerAddress = $adapter->getPayerAddress(); $expectedPayerAddress = self::PAYER_STREET . ' ' . self::PAYER_HAUSE_NO ; $this->assertEquals($expectedPayerAddress, $actualPayerAddress); } }
Testując adapter formy klasowej musimy zadbać o to, aby zależności klasy adaptowanej zostały spełnione. Jest to tym bardziej problematyczne, im więcej ta klasa posiada zależności. W powyższym przykładzie musieliśmy stworzyć mocka obiektu klasy Items, który nie ma znaczenia dla działania testowanej metody. Ponadto nie jesteśmy w stanie sprawdzić wywołań metod getPayerStreet i getPayerHauseNo, z których korzysta testowana metoda.
Wzorzec Adapter a Liskov Substitution Principle
Podczas używania adapterów do tłumaczenia interfejsów trzeba zwrócić uwagę na to, czy nasze rozwiązanie nie łamie zasady Liskov (LSP). Czasem łatwo można przeoczyć jej złamanie.
Przykład: Załóżmy że w naszym systemie potrzebujemy użyć listy. Nasze wymaganie jest takie, że czasem obiekty do niej przekazywane powinny byc przechowywane w pamięci, a czasem w danych sesji w zależności od potrzeby. W naszym systemie istnieje już klasa realizująca operująca na pamięci – GenericList,
class GenericList { public function add($object) { ... } }
do przechowywania danych w sesji decydujemy się na użycie zewnętrznej klasy ExternalListInSession jej interfejs wygląda następująco:
class ExternalListInSession { public function insert($object) { ... } }
Podczas implementacji rozwiązania naszego problemu chcemy mieć możliwość wymiany sposobu przechowywania danych w liście więc tworzymy interfejs List, którym będziemy się posługiwać.
interface List { public add($object); }
Aby dopasować do niego wcześniej wspomniane klasy musimy stworzyc dla nich adaptery implementujące interfejs. Czyli przykładowo:
class ListInOperatingMemory implements List { private $list; public function __construct(GenericList $list) { $this->list = $list; } public function add($object) { $this->list->add($object); } } class ListInSession implements List { private $list; public function __construct(ExternalListInSession $list) { $this->list = $list; } public function add($object) { $this->list->insert($object); } }
Jak narazie wszystko wygląda OK. Załóżmy że pojawia się potrzeba dodania kolejnej listy. Tym razem zależy nam aby dane były przechowywane w bazie danych. Tutaj także decydujemy się na korzystanie z gotowego rozwiązania DatabaseList… i tutaj pojawia się problem. W przeciwieństwie do poprzednich klas DatabaseList w metodzie insert wymaga aby przekazany parametr był konkretnego typu: DatabaseListItem.
class DatabaseList { public function insert(DatabaseListItem $object) { ... } }
Stworzenie adaptera analogicznego do porzednich:
class ListInDatabsae implements List { private $list; public function __construct(DatabaseList $list) { $this->list = $list; } public function add($object) { $this->list->insert($object); } }
Zaowocuje to złamaniem zasady LSP. Klient korzystający z metody List::add() nie wie, czy dany obiekt jest obiektem implementującym DatabaseListItem. Natomiast implementacja sprawdzenia typu obiektu w kliencie, walidacji w adapterze czy dodanie typowania w metodzie ListInDatabsae::add
class ListInDatabsae implements List { private $list; public function __construct(DatabaseList $list) { $this->list = $list; } public function add(DatabaseListItem $object) { $this->list->insert($object); } }
jest naruszeniem zasady LSP.
Co z tym zrobić?
Rozwiązaniem tego problemu może być użycie dodatkowej klasy mapera, która mogłaby być zależnością naszego adaptera. Przykładowo:
class ListInDatabsae implements List { private $list; private $mapper; public function __construct(DatabaseList $list, DatabaseListItemMapper $mapper) { $this->list = $list; $this->mapper = $mapper; } public function add($object) { $databaseListItem = $this->mapper->mapToDatabaseListItem($object); $this->list->insert($databaseListItem); } }
Jak widać wzorzec Adaptera pomimo bardzo prostej formy jest niebywale użyteczny i ma realny wpływ na przejrzystość naszego systemu oraz formę testów. Podsumowując, Adapter jest jak nieskończone życie w Mario, jest jak kopnięcie z półobrotu Chucka Norrisa, jest jak epic split Jean Claude Van Damme’a, jest jak gol z przewrotki Ibrahimovicza, jest jak oscypek z grilla, z kiełbaską, z grilla z piwem…
po prostu wymiata!
Źródła:
- ‘Agile programowanie zwinne’ – Robert C. Martin, Micah Martin
- ‘PHP. Wzorce projektowe’ – William Sanders
- ‘Professional PHP Design Patterns’ – Aaron Saray
- https://github.com/domnikl/DesignPatternsPHP
- życie 😉