Czym jest wzorzec decorator?

Wzorzec decorator pozwala dodać nową funkcjonalność do istniejącego obiektu, nie zmieniając jego struktury.

Może stanowić lepszą alternatywę dla nadpisań, ponieważ pozwala na elastyczne dodawanie kodu w określonym miejscu z zachowaniem oryginalnej funkcjonalności.

Dokumantacja Presty prezentuje dwa przykłady wykorzystania wzorca decoratora:

1. Dekorowanie kontrolera
2. Dekorowanie serwisu

W tym artykule przedstawię praktyczny przykład wykorzystania dla obu powyższych podejsć.

Dekorator, hook, czy override...?

Można zadać sobie pytanie: czy nie lepiej "wpiąć" się w prestowe hooki i operować na nich, zamiast tworzyć moduł wykorzystujący wzorzec decoratora? Odpowiedź brzmi: "to zależy" :) ...

Po pierwsze - nie zawsze mamy dostęp do hooków, które byłyby dla nas odpowiednie.

Po drugie - może być tak, że chcemy przechwycić moment dokonania zmian stricte z poziomu panelu admina.

W przykładowym module zajmiemy się monitorowaniem zmian statusu zamówienia dokonanych przez pracowników sklepu. Teoretycznie można to zrobić za pomocą hooków actionOrderStatusUpdate i/lub actionOrderStatusPostUpdate, natomiast nie daje nam to gwarancji, że status został zmieniony ręcznie. Wiele modułów płatności automatycznie ustawia status zamówienia po zaksięgowaniu płatności, więc taka sytuacja będzie sprzeczna z naszymi oczekiwaniami.

W naszym przypadku lepiej jest wykorzystać kontroler admina, który obsługuje zmianę statusu zamówienia i w nim odnieść się do metod odpowiedzialnych za ustawianie nowego statusu. Dzięki temu będziemy mieli pewność, że taka zmiana odbyła się ręcznie.

Tego typu logika dotyczy również innych miejsc, gdzie chcemy przechwycić zmiany dokonane przez pracowników sklepu. Oczywiście, jeżeli chcemy wyłapać każdy przypadek zmiany oraz istnieje w tym celu odpowiedni hook, to warto go wykorzystać.

Natomiast jeśli chodzi o nadpisania, to powinno się je stosować wyłącznie w uzasadnionej ostateczności, w miejscach, gdzie nie ma utworzonego hooka lub serwisu (np. w którymś konkretnym miejscu front controllera).

Moja subiektywna hierarchia wyboru rozwiązania rozszerzania kodu presty to:

hook > dekorator > nadpisanie

Tworzenie przykładowego modułu ze wzorcem dekoratora

Opis funkcjonalności

Moduł będzie zawierał dwa decoratory:

  1. Dekorator dla admin kontrolera OrderController, który będzie monitorował zmiany statusu zamówień.
  2. Dekorator dla serwisu prestashop.adapter.maintenance.form_handler, który będzie monitorował zmiany w ustawieniach trybu przerwy technicznej.

Wymagania

alt text alt text

Moduł napiszemy w oparciu o wersję PHP 8.1, ponieważ jest ona zalecana dla PrestaShop 8.1 (która na dzień publikacji tego artykułu jest najnowszą wersją PrestaShop). Ta wersja PHP będzie miała zapewnione wsparcie w nadchodzącej wersji Presty 9.0, więc rozwiązanie przedstawione w artykule powinno pozostać kompatybilne.

Klasa główna modułu

Zacznijmy od stworzenia modułu, który będzie zawierał dekorator dla kontrolera oraz serwisu.

Utwórz katalog modułu w katalogu modules swojego PrestaShop'a o nazwie decoratorexample.

W nim utwórz plik decoratorexample.php z kodem:

<?php

if (!defined('_PS_VERSION_')) {
    exit;
}

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

class decoratorexample extends Module
{
    public function __construct()
    {
        $this->name = 'decoratorexample';
        $this->tab = 'others';
        $this->version = '1.0.0';
        $this->author = 'inIT Kamil Góralczyk';
        $this->bootstrap = true;

        parent::__construct();

        $this->displayName = $this->trans(
            'Decorator example',
            [],
            'Modules.Decoratorexample.Admin'
        );

        $this->description = $this->trans(
            'Help developers to understand how to use decorator.',
            [],
            'Modules.Decoratorexample.Admin'
        );

        $this->ps_versions_compliancy = ['min' => '8.1.0', 'max' => _PS_VERSION_];
    }

    public function isUsingNewTranslationSystem()
    {
        return true;
    }
}

composer.json

Utwórz plik composer.json w katalogu modułu o następującej zawartości:

{
  "name": "init/decoratorexample",
  "type": "prestashop-module",
  "authors": [
    {
      "name": "Kamil Góralczyk",
      "homepage": "https://kamilgoralczyk.pl"
    }
  ],
  "autoload": {
    "psr-4": {
      "inIT\\DecoratorExample\\": "src/"
    }
  },
  "config": {
    "platform": {
      "php": "8.1.0"
    }
  }
}

services.yml

Utwórz plik config/services.yml w katalogu modułu:

services:
  inIT\DecoratorExample\Decorator\OrderControllerDecorator:
    class: inIT\DecoratorExample\Decorator\OrderControllerDecorator
    decorates: PrestaShopBundle\Controller\Admin\Sell\Order\OrderController
    arguments: ['@inIT\DecoratorExample\Decorator\OrderControllerDecorator.inner']

Utwórz plik src/Decorator/OrderControllerDecorator.php w katalogu modułu:

<?php

namespace inIT\DecoratorExample\Decorator;

use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
use PrestaShopBundle\Controller\Admin\Sell\Order\OrderController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

class OrderControllerDecorator extends FrameworkBundleAdminController
{
    public function __construct(private readonly OrderController $decoratedController)
    {
    }

    public function updateStatusFromListAction(int $orderId, Request $request): RedirectResponse
    {
    }

    public function updateStatusAction(int $orderId, Request $request): RedirectResponse
    {
    }
}

Composer

Z poziomu katalogu modułu decoratorexample wykonaj polecenia:

composer install
composer dump-autoload -o

Instalacja modułu

Zaloguj się do BackOffice PrestaShop i przejdź do zakładki Moduły. Wyszukaj moduł wpisując decoratorexample, a następnie zainstaluj go.

Możesz oczywiście zrobić to również z użyciem wiersza poleceń z poziomu katalogu głównego Twojego sklepu poleceniem*:

php bin/console prestashop:module install decoratorexample

* Jeżeli użycie tej komendy nie powiedzie się, spróbuj użyć aliasu dla Twojej wersji PHP, np. php8.1 lub php81

Czyszczenie cache

Ostatnim krokiem będzie wyczyszczenie cache - wykonaj następujące komendy z poziomu katalogu głównego Twojego sklepu:

php bin/console c:c --env=dev
php bin/console c:c --env=prod

O klasie OrderControllerDecorator

Do tej pory nasz moduł nie zawiera swojej logiki, natomiast mamy już gotowy szkielet dekoratora w obiekcie OrderControllerDecorator, a w nim zaimplementowane dwie metody, które dekorują oryginalne tj. updateStatusFromListAction oraz updateStatusAction.

Co ważne, w konstruktorze dekoratora przekazujemy obiekt oryginalnego kontrolera i przypisujemy do zmiennej $decoratedController.

Weryfikacja działania dekoratora oraz dodawanie logiki

W całej tej sekcji będziemy operować na klasie src/Decorator/OrderControllerDecorator.php.
Wszystkie zmiany będą dotyczyły tylko i wyłącznie tego pliku.

Przejdź do strony listy zamówień w BackOffice - powinien pojawić się następujący komunikat:

alt text

Jak widać w powyższym komunikacie błędu, PrestaShop oczekuje w naszym decoratorze metody indexAction. Oznacza to, że zwykle nie wystarczy wyłącznie odwołać się do oryginalnej metody, ale trzeba również zaimplementować inne metody, które są wywoływane w oryginalnym kontrolerze.

Dodajmy zatem do pliku src/Decorator/OrderControllerDecorator.php implementację metody indexAction zwracającą wynik oryginalnej metody:

public function indexAction(Request $request, OrderFilters $filters)
{
    return $this->decoratedController->indexAction($request, $filters);
}

Parametry metody wklejamy z oryginalnego kontrolera.

Dodajmy na górze pliku:

use PrestaShop\PrestaShop\Core\Search\Filters\OrderFilters;

Odśwież stronę listy zamówień w BackOffice i teraz powinna ona prawidłowo wyświetlić listę zamówień.

Z poziomu listy zamówień w adminie PrestaShop, zmień status dowolnego zamówienia, np:

alt text

Zobaczysz następujący komunikat: alt text

W praktyce oznacza to, że kod oczekuje od naszego dekoratora zwrócenia tego samego obiektu, który zwraca oryginalna metoda.

Wiesz już, że mamy do dyspozycji obiekt oryginalnego kontrolera, więc wykorzystamy go do zwrócenia wyniku metody.

Dodaj w metodzie updateStatusFromListAction następujący kod:

public function updateStatusFromListAction(int $orderId, Request $request): RedirectResponse
{
    return $this->decoratedController->updateStatusFromListAction($orderId, $request); // dodajemy ten fragment
}

Dodaj analogiczny kod do metody updateStatusAction:

public function updateStatusAction(int $orderId, Request $request): RedirectResponse
{
    return $this->decoratedController->updateStatusAction($orderId, $request); // dodajemy ten fragment
}

Aby upewnić się, że nasz dekorator działa, dla szybkiego testu dodajmy w metodzie updateStatusFromListAction na samej górze kod:

public function updateStatusFromListAction(int $orderId, Request $request): RedirectResponse
{
    die("Zmieniony status z poziomu listy zamówień"); // dodajemy ten fragment
    return $this->decoratedController->updateStatusFromListAction($orderId, $request);
}

Spróbuj zmienić status zamówienia z poziomu listy zamówień w BackOffice. Zobaczysz, że prawidłowo wyświetli się wprowadzony przez nas komunikat.

Zróbmy jeszcze to samo dla metody updateStatusAction:

public function updateStatusAction(int $orderId, Request $request): RedirectResponse
{
    die("Zmieniony status z poziomu szczegółów zamówienia"); // dodajemy ten fragment
    return $this->decoratedController->updateStatusAction($orderId, $request);
}

Przejdź na stronę ze szczegółami zamówienia. Ponownie zobaczysz komunikat, który wygląda podobnie do tego z listy zamówień:

alt text

W związku z tym analgocznie zaimplementujmy metodę viewAction:

public function viewAction(int $orderId, Request $request): Response
{
    return $this->decoratedController->viewAction($orderId, $request);
}

Zwracamy obiekt Response, którego jeszcze nie używaliśmy w tej klasie i w związku z tym musimy dodać na górze pliku:

use Symfony\Component\HttpFoundation\Response;

Teraz odśwież stronę ze szczegółami zamówienia i zmień status zamówienia na dowolny. Zauważysz, że również w tym przypadku zostanie wyświetlony komunikat.

Podsumowanie klasy OrderControllerDecorator

Co wynika z powyższych działań?

  1. Implementowane metody w dekoratorze muszą zwracać obiekt, który zwraca oryginalna metoda.
  2. Dekorator wymaga implementacji innych metod, które są wywoływane w oryginalnym kontrolerze i będące powiązane z dekorowaną metodą.

Implementacja funkcji informowania o zmianie statusu

Teraz, kiedy mamy gotową logikę w dekoratorze przejdźmy do implementacji powiadomień.

Utwórz plik src/Utils/OrderStatusNotifier.php w katalogu modułu:

<?php

namespace inIT\DecoratorExample\Utils;

use inIT\DecoratorExample\Exception\OrderStateException;

class OrderStatusNotifier
{
    public function __construct(private readonly \Context $context)
    {}

    public function getMessage(int $orderId, int $newOrderStatusId): string
    {
        $order = new \Order($orderId);
        $orderStateCurrent = new \OrderState($order->current_state);

        if (!\Validate::isLoadedObject($orderStateCurrent)) {
            throw new OrderStateException('Order state not found');
        }

        $orderStateNew = new \OrderState($newOrderStatusId);
        $idLang = $this->context->language->id;

        return 'Zmieniono status zamówienia nr '.$order->reference.' (ID '.$orderId.') 
        z "'.$orderStateCurrent->name[$idLang].'" na "'.$orderStateNew->name[$idLang].'"';
    }
}
Musimy uważać na to, aby odwołanie do metody getMessage() odbywało się przed faktycznym ustawieniem nowego statusu zamówienia w Preście.

Jest to prosta klasa z metodą odpowiedzialną za wyświetlanie komunikatu o zmianie statusu zamówienia.

Wykorzystamy ją w naszym dekoratorze. Podmień cały plik src/Decorator/OrderControllerDecorator.php następującym kodem:

<?php

namespace inIT\DecoratorExample\Decorator;

use inIT\DecoratorExample\Utils\OrderStatusNotifier;
use PrestaShop\PrestaShop\Core\Search\Filters\OrderFilters;
use PrestaShopBundle\Controller\Admin\FrameworkBundleAdminController;
use PrestaShopBundle\Controller\Admin\Sell\Order\OrderController;
use PrestaShopBundle\Form\Admin\Sell\Order\UpdateOrderStatusType;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class OrderControllerDecorator extends FrameworkBundleAdminController
{
    public function __construct(
        private readonly OrderController $decoratedController,
        private readonly OrderStatusNotifier $orderStatusNotifier
    )
    {
    }

    public function updateStatusFromListAction(int $orderId, Request $request): RedirectResponse
    {
        $newIdOrder = $request->request->getInt('value');
        $notifierMessage = $this->orderStatusNotifier->getMessage($orderId, $newIdOrder);
        $this->addFlash('success', $notifierMessage);

        return $this->decoratedController->updateStatusFromListAction($orderId, $request);
    }

    public function updateStatusAction(int $orderId, Request $request): RedirectResponse
    {
        $formFactory = $this->get('form.factory');

        $form = $formFactory->createNamed(
            'update_order_status',
            UpdateOrderStatusType::class
        );
        $form->handleRequest($request);

        if (!$form->isSubmitted() || !$form->isValid()) {
            $form = $formFactory->createNamed(
                'update_order_status_action_bar',
                UpdateOrderStatusType::class
            );
            $form->handleRequest($request);
        }

        if ($form->isSubmitted() && $form->isValid()) {
            $newIdOrder = $form->getData()['new_order_status_id'];
            $notifierMessage = $this->orderStatusNotifier->getMessage($orderId, $newIdOrder);
            $this->addFlash('success', $notifierMessage);
        }

        return $this->decoratedController->updateStatusAction($orderId, $request);
    }

    public function indexAction(Request $request, OrderFilters $filters)
    {
        return $this->decoratedController->indexAction($request, $filters);
    }

    public function viewAction(int $orderId, Request $request): Response
    {
        return $this->decoratedController->viewAction($orderId, $request);
    }
}

Do konstruktora przekazaliśmy obiekt OrderStatusNotifier, dzięki czemu możemy go użyć w metodach updateStatusFromListAction oraz updateStatusAction. Dodatkowo zmodyfikowaliśmy te metody, aby przechwycić ID nowego statusu zamówienia. Kod wewnątrz tych metod pochodzi z oryginalnego kontrolera.

Zmień jeszcze deklarację serwisów w pliku config/services.yml - podmień całośc na:

services:
  _defaults:
    bind:
      $orderStatusNotifier: '@inIT\DecoratorExample\Utils\OrderStatusNotifier'
      $context: "@=service('prestashop.adapter.legacy.context').getContext()"

  inIT\DecoratorExample\:
    resource: '../src/*'

  inIT\DecoratorExample\Decorator\OrderControllerDecorator:
    class: inIT\DecoratorExample\Decorator\OrderControllerDecorator
    decorates: PrestaShopBundle\Controller\Admin\Sell\Order\OrderController
    arguments: ['@inIT\DecoratorExample\Decorator\OrderControllerDecorator.inner']

Tutaj deklarujemy wszystkie nasze klasy jako serwisy, a także korzystamy z bindowania aby przekazywać do kontruktorów OrderStatusNotifier oraz prestowy Context.

Teraz zmień statusy wybranych zamówień z poziomu listy lub szczegółów zamówienia. Zauważysz na górze strony komunikat odnośnie zmiany statusu zamówienia.

alt text

Komunikat z widoku listy zamówień

alt text

Komunikat z widoku szczegółów zamówienia

Implementacja funkcji informowania o zmianach w trybie przerwy technicznej

Aby przechwycić zmiany dokonane na stronie ustawień przerwy technicznej najlepiej byłoby "wpiąć się" w moment zapisu danych formularza i porównać je z aktualną konfiguracją z bazy danych.

Wyszukajmy wszystkie serwisy powiązane ze słowem kluczowym "maintenance". W głównym katalogu sklepu uruchamiam polecenie:

php ./bin/console debug:container maintenance

Wpisujemy liczbę widoczną przy serwisie prestashop.adapter.maintenance.form_handler i klikamy enter.

Potrzebujemy także serwisu konfiguracji przerwy technicznej, więc jeszcze raz uruchamiamy komendę, tym razem wybierając 4:

alt text

alt text

W pliku z naszymi serwisami, tzn. config/services.yml dopisujemy deklarację naszego nowego dekoratora:

  inIT\DecoratorExample\Decorator\MaintenanceDecorator:
    class: inIT\DecoratorExample\Decorator\MaintenanceDecorator
    decorates: prestashop.adapter.maintenance.form_handler
    arguments: [
      '@inIT\DecoratorExample\Decorator\MaintenanceDecorator.inner',
      '@prestashop.adapter.maintenance.configuration'
    ]

Dekorujemy FormHandler dla przerwy technicznej, ponieważ to właśnie w tę akcję chcemy się wpiąć. Dodatkowo przekazujemy do konstruktora serwis konfiguracji przerwy technicznej, aby pobrać aktualne dane i porównać je z danymi z formularza.

Utwórz plik src/Decorator/MaintenanceConfigurationDecorator.php w katalogu modułu:

<?php

namespace inIT\DecoratorExample\Decorator;

use inIT\DecoratorExample\Helper\CompareHelper;
use inIT\DecoratorExample\Helper\FileHelper;
use inIT\DecoratorExample\Helper\MessageFormatHelper;
use PrestaShop\PrestaShop\Adapter\Shop\MaintenanceConfiguration;
use PrestaShop\PrestaShop\Core\Form\Handler;

class MaintenanceDecorator
{
    public function __construct(
        private readonly Handler $maintenanceHandler,
        private readonly MaintenanceConfiguration $maintenanceConfiguration,
        private readonly CompareHelper $compareHelper,
        private readonly MessageFormatHelper $messageFormatHelper,
        private readonly FileHelper $fileHelper,
    )
    {
    }

    public function getForm()
    {
        return $this->maintenanceHandler->getForm();
    }

    public function save(array $data)
    {
        $currentValues = $this->maintenanceConfiguration->getConfiguration();

        // Dane z konfiguracji jako pierwszy argument & dane z formularza jako drugi
        $diff = $this->compareHelper->getDiff($currentValues, $data);
        $formatMessage = $this->messageFormatHelper->getFormattedMessages($diff);
        $this->fileHelper->saveToFile($formatMessage);

        return $this->maintenanceHandler->save($data);
    }
}

W tej klasie przekazujemy do konstruktora Handler (który dekorujemy), obiekt konfiugracji przerwy technicznej, a także nasze pomocnicze obiekty: CompareHelper, MessageFormatHelper oraz FileHelper.

Metoda getForm() musiała zostać zaimplementowana i w związku z tym zawiera odwołanie do oryginalnej metody, tak, jak było to w przypadku dekoratora dla kontrolera zamówień.

Główna logika zawarta jest w metodzie save(). Pobieramy aktualne dane konfiguracji przerwy technicznej, porównujemy je, a na końcu tworzymy plik (jeśli nie istnieje) i dopisujemy informacje do maintenance.log w głównym katalogu modułu decoratorexample.

Kod wszystkich tych klas znajdziesz w repozytorium tego modułu - tutaj.

Możesz teraz usunąc cały katalog modules/decoratorexample, a następnie ściągnąć go z poziomu katalogu:

cd /your_shop/modules

poleceniem:

git clone https://github.com/elektryz/decoratorexample.git

Dla pewności wyczyść cache i przeinstaluj moduł.

Teraz, gdy dokonasz zmian na stronie przerwy technicznej w panelu admina, to do pliku maintenance.log w katalogu modułu zostaną dopisane odpowiednie informacje (uwaga: tylko w przypadku zmian wartości).

Przykład:

Data zmiany: 2025-01-07 19:04:12

Wyłączono opcję "enable_shop"
Włączono opcję "maintenance_allow_admins"
Zmieniono wartość " (English (English))" z "    test EN 2 sds " na "Test wiadomości ENG"
Zmieniono wartość " (Polski (Polish))" z "   2 test PL" na "Test wiadomości PL"

Data zmiany: 2025-01-07 19:04:27

Zmieniono wartość "maintenance_ip" z "" na "1.23.456.7"

Możesz równiesz podejrzeć zmiany dokonane w pliku config/services.yml oraz dodany kod w nowych klasach.

Podsumowanie

Mam nadzieję, że ten artykuł dokładnie wyjaśnił działanie decoratora w Symfony z przykładami praktycznego zastosowania w PrestaShop 8.

Oczywiście zastosowany przykład nie jest zbyt praktyczny, gdyż doskonale widzimy wszystkie te zmiany z poziomu BackOffice (zmiany statusów lub wartości konfiguracyjnych), ale można byłoby to wykorzystać np. do wysyłania powiadomień mailowych do właściciela sklepu o zachodzących zmianach w panelu admina.

Dekorator to bardzo przydatny wzorzec, który pozwala na elastyczne dodawanie nowych funkcjonalności do istniejącego kodu.

zmiany? rozwój Twojej strony? zaistnienie w sieci?
Gotów na Skontaktuj się i napisz, czego Ci trzeba