Jednym z dziedzictw świata programowania są wzorce projektowe. Jest to meta-język, którym mogą posługiwać się programiści niezależnie od tego, w jakiej technologii, czy języku piszą. Bardzo podobnie jest z zasadami SOLID. Dziś połączymy te dwie rzeczy w pierwszym, z dwuczęściowej serii, wpisie.
Otwarty-Zamknięty, czyli jak?
Drugą zasadą SOLIDu jest Open-Close Principle, czy jak kto woli – Otwarty-Zamknięty. Wg Wujka Boba, zasada ta brzmi:
You should be able to extend the behavior of a system without having to modify that system.
Robert Martin
Cytowane oczywiście z wpisu Wujka Boba z 2014 roku. Oznacza to, że powinniśmy faworyzować rozszerzanie systemu względem jego modyfikacji. Jeżeli w kodzie dodajemy nowy zestaw warunków dla jakiegoś szczególnego zachowania, albo dorzucamy nową pozycję do instrukcji switch
, oznacza to, że modyfikujemy nasz system, potencjalnie narażając logikę modyfikowanej części na bugi. Kiedy rozszerzamy aplikację, zazwyczaj tworzymy nowe klasy o specjalnym znaczeniu, co powoduje, że nie dotykamy innych klas, a co za tym idzie – nie narażamy go na niekontrolowane zmiany.
Wzorce projektowe przyjazne Open-Close Principle
Wzorców projektowych jest dużo. Są takie, które pozwalają na lepsze stubowanie w testach jednostkowych (np. fabryka), a są takie, które które pozwalają pisać ładniejszy kodzik (np. Prawo Demeter oraz CQS). Istnieje również pełna gama wzorców, które promują rozszerzanie systemu. Poniżej opisałem kilka z nich 🙂 .
Dekorator
Wzorzec Dekoratora w swojej istocie oznacza, że chcemy przejąć prawo własności nad inną klasą, równocześnie implementując jej interfejs. Dodatkowo, w systemie (po całości, albo w części systemu) zastępujemy klasę dekorowaną (tą, której własność przejęliśmy) naszą klasą dekorującą. Wzorzec Dekoratora oznacza również przynajmniej częściowe wykorzystywanie logiki klasy dekorowanej. Bo po to właśnie jest nam potrzebny obiekt klasy dekorowanej przekazywany do konstruktora.
W świecie rzeczywistym możemy wyobrazić sobie, że gdzieś w systemie mamy klasę kontekstu profilu użytkownika, która zwraca profil dla użytkownika o konkretnym identyfikatorze. Jeżeli użytkownik takiego profilu jeszcze nie posiada, to tworzymy nowy profil. Poniżej przykładowa implementacja:
<?php
// use ...
interface UserProfileContextInterface
{
public function readUserProfile(
UserId $profileOwnerId
): UserProfileInterface;
}
final class UserProfileContext implements UserProfileContextInterface
{
// ... constructor
public function readUserProfile(
UserId $profileOwnerId
): UserProfileInterface {
$profile = $this->userProfileRepository->findByUserId($profileOwnerId);
if ($profile === null) {
return $this->profileDTOFactory->createNewProfile($profileOwnerId);
}
return $this->profileDTOFactory->createFromEntity($profile);
}
}
Kiedy przychodzi nowe wymaganie, aby wdrożyć system uprawnień oparty na rolach, mamy dwa wyjścia. Pierwsze, to nadpisanie serwisu kontekstu profilu z ryzykiem, że logika zwracania profilu zostanie naruszona. Jeżeli logika generowania profilu nie należy do nas (a np. do klasy jakiejś biblioteki), to dodatkowo zamykamy się na ewentualne zmiany tej logiki przez vendora. Druga opcja to udekorowanie serwisu oraz dodanie odpowiednich warunków, jak poniżej:
<?php
// use ...
final class GuardedUserProfileContext implements UserProfileContextInterface
{
public function __construct(
private UserProfileContextInterface $decoratedContext,
private UserContextInterface $userContext,
private AclResolverInterface $aclResolver,
) {}
public function readUserProfile(
UserId $profileOwnerId
): UserProfileInterface {
$currentUser = $this->userContext->getUser();
$hasPermission = $this->aclResolver->hasPermission(
AclResolverInterface::PERMISSION_PROFILE_READ,
$currentUser
);
if (!$hasPermission) {
throw new NoPermissionForUser(
AclResolverInterface::PERMISSION_PROFILE_READ,
$currentUser
);
}
return $this->decoratedContext->readUserProfile($profileOwnerId);
}
}
Na koniec o dekoratorze: Symfony daje nam opcję dekorowania serwisów na poziomie kontenera zależności. Ułatwia to znacznie pracę, ponieważ Symfony załatwia całą podmianę za nas. Link do dokumentacji: https://symfony.com/doc/current/service_container/service_decoration.html.
Kompozyt
Drugim wzorcem pozwalającym na rozszerzanie aplikacji jest Kompozyt. Jest to konstrukcja, która pod określonym interfejsem ukrywa proces złożony z mniejszych procesów, określonych tym samym interfejsem. Kompozytem możemy połączyć zarówno obiekty domenowe, jak i serwisy. Bardzo dobrym przykładem implementacji Kompozytu jest kod Syliusa.
Pierwszym, i najbardziej popularnym, wykorzystaniem kompozytu w Syliusie jest OrderProcessor
, czyli mechanizmu przeliczania zamówienia wykorzystywanego np. podczas dodawania lub usuwania pozycji do koszyka:
<?php
// https://github.com/Sylius/Sylius/blob/1.13/src/Sylius/Component/Order/Processor/CompositeOrderProcessor.php
// ...
final class CompositeOrderProcessor implements OrderProcessorInterface
{
private PriorityQueue $orderProcessors;
public function __construct()
{
$this->orderProcessors = new PriorityQueue();
}
public function addProcessor(OrderProcessorInterface $orderProcessor, int $priority = 0): void
{
$this->orderProcessors->insert($orderProcessor, $priority);
}
public function process(OrderInterface $order): void
{
foreach ($this->orderProcessors as $orderProcessor) {
$orderProcessor->process($order);
}
}
}
Jak możemy zauważyć, mamy tu zwykłą klasę, która implementuje interfejs OrderProcessorInterface
, a następnie w metodzie process(...)
, w pętli wywołuje tą samą metodę na poszczególnych procesorach. Aby dodać własny procesor zamówienia, należy dodać do serwisu odpowiedni tag:
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://github.com/Sylius/Sylius/blob/1.13/src/Sylius/Bundle/CoreBundle/Resources/config/services/order_processing.xml#LL30C34 -->
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<defaults public="true" />
<!-- ... -->
<service id="sylius.order_processing.order_prices_recalculator" class="Sylius\Component\Core\OrderProcessing\OrderPricesRecalculator">
<argument type="service" id="sylius.calculator.product_variant_price" />
<tag name="sylius.order_processor" priority="50"/>
</service>
</services>
</container>
Po dodaniu tagu sylius.order_processor
do serwisu, zostanie on wychwycony przez klasę RegisterProcessorsPass, który następnie doda go do kolekcji, używając metody `addProcessor(…)`.
Drugim przykładem implementacji Kompozytu w Syliusie jest ChannelContext
– klasa, która służy odnalezieniu, w ramach którego kanału sprzedaży obecnie się znajdujemy:
<?php
// https://github.com/Sylius/Sylius/blob/1.13/src/Sylius/Component/Channel/Context/CompositeChannelContext.php
// ...
final class CompositeChannelContext implements ChannelContextInterface
{
private PriorityQueue $channelContexts;
public function __construct()
{
$this->channelContexts = new PriorityQueue();
}
public function addContext(ChannelContextInterface $channelContext, int $priority = 0): void
{
$this->channelContexts->insert($channelContext, $priority);
}
public function getChannel(): ChannelInterface
{
foreach ($this->channelContexts as $channelContext) {
try {
return $channelContext->getChannel();
} catch (ChannelNotFoundException) {
continue;
}
}
throw new ChannelNotFoundException();
}
}
Zasada działania ChannelContext
oraz OrderProcessor
różni się tym, że procesor zamówień leci po wszystkich procesorach po kolei, a kontekst kanału sprzedaży kończy swoją pracę po wykonaniu tego kontekstu, który jako pierwszy znajdzie kanał. Pomimo tej różnicy, w dalszym ciągu mamy do czynienia z Kompozytami.
Przykłady Kompozytu w Syliusie kończą się na jednym poziomie komponowania. Nic nie stoi na przeszkodzie, aby poziom zagłębień był większy – bo właśnie to jest prawdziwa moc Kompozytu. Ukrywamy zaawansowane mechanizmy pod postacią prostego interfejsu.
Strategia
Kolejnym wzorcem w dzisiejszym wpisie jest Strategia. Polega ona na implementowaniu tego samego interfejsu, który ma wykonywać tą samą operację, kolokwialnie mówiąc, w inny sposób. Na przykładzie e-commerce, możemy chcieć wyliczać zniżki w inny sposób, w zależności od kontekstu. Przyjrzyjmy się poniższemu przykładowi:
<?php
interface DiscountCalculatorInterface
{
public function supports(
ProductInterface $product
): bool;
public function calculate(
ProductInterface $product
): int;
}
final class BlackFridayDiscountCalculator implements DiscountCalculatorInterface
{
private const DISCOUNT_PERCENTAGE = 0.75;
public function __construct(
private CalendarInterface $calendar,
) {}
public function supports(
ProductInterface $product
): bool {
return $this->calendar->isBlackFriday();
}
public function calculate(
ProductInterface $product
): int {
if (!$this->calendar->isBlackFriday()) {
return 0;
}
return self::DISCOUNT_PERCENTAGE * $product->getPrice();
}
}
final class ChristmasDiscountCalculator implements DiscountCalculatorInterface
{
private const DISCOUNT_PERCENTAGE = 0.15;
public function __construct(
private CalendarInterface $calendar,
) {}
public function supports(
ProductInterface $product
): bool {
return $this->calendar->isChristmas();
}
public function calculate(
ProductInterface $product
): int {
if (!$this->calendar->isChristmas()) {
return 0;
}
return self::DISCOUNT_PERCENTAGE * $product->getPrice();
}
}
Powyższy kod realizuje wyliczanie zniżki w zależności od tego, w jakim okresie obecnie się znajdujemy. Każda ze Strategii zna warunki, w których może zostać uruchomiona. Jest to częsta (ale nie jedyna) forma implementacji wzorca Strategii. Czasami może wystapić taka sytuacja, gdzie nie będziemy mogli, z poziomu klasy implementującej interfejs, stwierdzić, czy możemy jej użyć. Przykładowo, w zależności od konfiguracji promocji, możemy chcieć naliczać np. tylko najlepszą zniżkę, albo tylko pierwszą dostępną. Do tego celu przyda nam się dodatkowa klasa, która będzie sterowała pozostałymi składnikami Strategii:
<?php
final class DiscountCalculatorStrategy
{
public function __construct(
private Collection $discountCalculators,
) {}
public function calculate(
PromotionSettings $promotionSettings,
ProductInterface $product
): int {
$discountToApply = 0;
foreach ($this->discountCalculators as $discountCalculator) {
if (!$discountCalculator->supports($product) {
continue;
}
$calculatedDiscount = $discountCalculator->calculate($product);
$applyingBetterPromotion = $promotionSettings->isBestPromotionOn()
&& $calculatedDiscount > $discountToApply;
if ($applyingBetterPromotion) {
$discountToApply = $calculatedDiscount;
} else if ($promotionSettings->isExclusivePromotionOn()) {
return $calculatedDiscount;
} else {
$discountToApply += $calculatedDiscount;
}
}
if ($discountToApply > $product->getPrice()) {
$discountToApply = $product->getPrice();
}
return $discountToApply;
}
}
Pytanie do publiczności: czy widzicie tutaj opcję na zmianę, by sama klasa strategii również była bardziej otwarta na rozszerzanie? ?
Adapter
Ostatnim wzorcem, o którym dziś wspomnę jest Adapter. Jest to wzorzec bardzo często stosowany w warstwie infrastruktury i polega na implementacji wymiennych sterowników, które w przeciwieństwie do strategii, nie muszą być wymienialne w trakcie działania aplikacji.
Doskonałym przykładem będzie dobrze wszystkim znany Monolog. Kiedy odwiedzimy repozytorium monolog-bundle, znajdziemy tam mnóstwo różnego rodzaju klas formatujących, które implementują wzorzec Adaptera. Pomimo bardzo bogatej biblioteki formatterów Monologa, możemy, dzięki dobrodziejstwu zasady Otwarty-Zamknięty, dorzucić własny Adapter formatujący.
Na koniec wspomnę, że wzorzec Adapter jest stosowany w Architekturze Heksagonalnej, inaczej nazywanej właśnie Porty i Adaptery.
Comments are closed.