PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Wzorzec Visitor – double dispatch, eksport CSV/PDF, walidacja bez modyfikacji klas

by Henryk Tews / wtorek, 13 lutego 2024 / Opublikowano w Wzorce projektowe

Visitor to jeden z trudniejszych wzorców GoF do zrozumienia, ale rozwiązuje bardzo konkretny problem: chcesz dodać nową operację do grupy powiązanych klas bez modyfikowania tych klas. Zamiast dodawać metodę do każdej klasy, tworzysz Visitora który „odwiedza” każdą z nich. Szczególnie przydatny przy AST (drzewach składniowych), formatowaniu danych i eksporcie do różnych formatów.

Problem który rozwiązuje Visitor

<?php

// Masz hierarchię klas elementów zamówienia
abstract class OrderElement {}
class ProductItem extends OrderElement {}
class ServiceItem extends OrderElement {}
class DiscountItem extends OrderElement {}
class ShippingItem extends OrderElement {}

// Chcesz dodać WIELE nowych operacji: export do PDF, eksport do CSV,
// obliczenie podatku, walidację, serializację...
// Dodawanie każdej operacji jako metody do KAŻDEJ klasy narusza SRP i OCP

// Visitor przenosi operacje poza hierarchię klas

Implementacja wzorca

<?php

declare(strict_types=1);

// Interfejs Visitora - metoda visit dla każdego typu elementu
interface OrderElementVisitorInterface
{
    public function visitProduct(ProductItem $item): mixed;
    public function visitService(ServiceItem $item): mixed;
    public function visitDiscount(DiscountItem $item): mixed;
    public function visitShipping(ShippingItem $item): mixed;
}

// Interfejs elementu - metoda accept dla podwójnej dyspozycji
interface OrderElementInterface
{
    public function accept(OrderElementVisitorInterface $visitor): mixed;
}

// Konkretne elementy - każdy deleguje do właściwej metody visitora
class ProductItem implements OrderElementInterface
{
    public function __construct(
        public readonly string $sku,
        public readonly string $name,
        public readonly float $price,
        public readonly int $qty,
        public readonly float $taxRate = 0.23
    ) {}

    public function accept(OrderElementVisitorInterface $visitor): mixed
    {
        return $visitor->visitProduct($this);
    }

    public function rowTotal(): float
    {
        return $this->price * $this->qty;
    }
}

class ServiceItem implements OrderElementInterface
{
    public function __construct(
        public readonly string $name,
        public readonly float $price,
        public readonly float $taxRate = 0.23
    ) {}

    public function accept(OrderElementVisitorInterface $visitor): mixed
    {
        return $visitor->visitService($this);
    }
}

class DiscountItem implements OrderElementInterface
{
    public function __construct(
        public readonly string $code,
        public readonly float $amount,
        public readonly bool $isPercent = false
    ) {}

    public function accept(OrderElementVisitorInterface $visitor): mixed
    {
        return $visitor->visitDiscount($this);
    }
}

class ShippingItem implements OrderElementInterface
{
    public function __construct(
        public readonly string $method,
        public readonly float $price,
        public readonly float $taxRate = 0.23
    ) {}

    public function accept(OrderElementVisitorInterface $visitor): mixed
    {
        return $visitor->visitShipping($this);
    }
}
<?php

declare(strict_types=1);

// Visitor #1 - obliczanie podatku VAT
class TaxCalculatorVisitor implements OrderElementVisitorInterface
{
    private float $totalTax = 0.0;

    public function visitProduct(ProductItem $item): float
    {
        $tax = $item->rowTotal() * $item->taxRate;
        $this->totalTax += $tax;
        return $tax;
    }

    public function visitService(ServiceItem $item): float
    {
        $tax = $item->price * $item->taxRate;
        $this->totalTax += $tax;
        return $tax;
    }

    public function visitDiscount(DiscountItem $item): float
    {
        return 0.0; // rabaty nie mają podatku
    }

    public function visitShipping(ShippingItem $item): float
    {
        $tax = $item->price * $item->taxRate;
        $this->totalTax += $tax;
        return $tax;
    }

    public function getTotalTax(): float { return round($this->totalTax, 2); }
}

// Visitor #2 - eksport do CSV
class CsvExportVisitor implements OrderElementVisitorInterface
{
    private array $rows = [['type', 'name', 'qty', 'price', 'total']];

    public function visitProduct(ProductItem $item): string
    {
        $row = ['product', "{$item->name} ({$item->sku})", $item->qty, $item->price, $item->rowTotal()];
        $this->rows[] = $row;
        return implode(',', $row);
    }

    public function visitService(ServiceItem $item): string
    {
        $row = ['service', $item->name, 1, $item->price, $item->price];
        $this->rows[] = $row;
        return implode(',', $row);
    }

    public function visitDiscount(DiscountItem $item): string
    {
        $value = $item->isPercent ? "-{$item->amount}%" : "-{$item->amount}";
        $row   = ['discount', "Rabat: {$item->code}", 1, $value, $value];
        $this->rows[] = $row;
        return implode(',', $row);
    }

    public function visitShipping(ShippingItem $item): string
    {
        $row = ['shipping', $item->method, 1, $item->price, $item->price];
        $this->rows[] = $row;
        return implode(',', $row);
    }

    public function getCsv(): string
    {
        return implode("\n", array_map(fn($row) => implode(',', $row), $this->rows));
    }
}

// Visitor #3 - walidacja
class ValidationVisitor implements OrderElementVisitorInterface
{
    private array $errors = [];

    public function visitProduct(ProductItem $item): bool
    {
        if ($item->qty <= 0) {
            $this->errors[] = "Produkt {$item->sku}: ilość musi być większa od 0";
            return false;
        }
        if ($item->price < 0) {
            $this->errors[] = "Produkt {$item->sku}: cena nie może być ujemna";
            return false;
        }
        return true;
    }

    public function visitService(ServiceItem $item): bool
    {
        if (empty($item->name)) {
            $this->errors[] = "Usługa: nazwa jest wymagana";
            return false;
        }
        return true;
    }

    public function visitDiscount(DiscountItem $item): bool
    {
        if ($item->isPercent && ($item->amount <= 0 || $item->amount > 100)) {
            $this->errors[] = "Rabat {$item->code}: procent musi być między 1 a 100";
            return false;
        }
        return true;
    }

    public function visitShipping(ShippingItem $item): bool
    {
        if ($item->price < 0) {
            $this->errors[] = "Wysyłka: cena nie może być ujemna";
            return false;
        }
        return true;
    }

    public function isValid(): bool  { return empty($this->errors); }
    public function getErrors(): array { return $this->errors; }
}
<?php

// Kolekcja elementów i użycie wielu visitorów
class OrderItemCollection
{
    /** @var OrderElementInterface[] */
    private array $items = [];

    public function add(OrderElementInterface $item): void
    {
        $this->items[] = $item;
    }

    public function accept(OrderElementVisitorInterface $visitor): array
    {
        return array_map(fn($item) => $item->accept($visitor), $this->items);
    }
}

// Użycie
$order = new OrderItemCollection();
$order->add(new ProductItem('SKU-001', 'Widget Pro', 29.99, 2, 0.23));
$order->add(new ProductItem('SKU-002', 'Gadget Plus', 49.99, 1, 0.23));
$order->add(new ServiceItem('Montaż', 15.0, 0.23));
$order->add(new DiscountItem('SAVE10', 10, isPercent: true));
$order->add(new ShippingItem('Kurier DPD', 9.99, 0.23));

// Walidacja - nowa operacja, zero zmian w klasach elementów
$validator = new ValidationVisitor();
$order->accept($validator);

if (!$validator->isValid()) {
    foreach ($validator->getErrors() as $error) {
        echo "Błąd: {$error}\n";
    }
}

// Podatek - inna operacja, ten sam mechanizm
$taxCalc = new TaxCalculatorVisitor();
$order->accept($taxCalc);
echo "Łączny VAT: " . $taxCalc->getTotalTax() . " PLN\n";

// Eksport CSV - kolejna operacja, zero zmian w klasach
$csvExport = new CsvExportVisitor();
$order->accept($csvExport);
echo $csvExport->getCsv();

Visitor vs alternatywy

Podejście Zalety Wady
Visitor Łatwe dodawanie operacji, OCP dla operacji Trudne dodawanie nowych typów elementów
Metody w klasach Proste, OCP dla typów Klasy rosną przy każdej nowej operacji
Instanceof + match Prosto napisać Naruszenie OCP, trudne utrzymanie

Visitor ma odwróconą elastyczność względem dziedziczenia: łatwo dodać nową operację (nowy Visitor), trudno dodać nowy typ elementu (trzeba dodać metodę do każdego Visitora). Wybierz Visitor gdy hierarchia typów jest stabilna, ale operacje rosną.

Podsumowanie

Visitor to wzorzec który błyszczy przy drzewach obiektów z wieloma operacjami – AST kompilatorów, dokumenty XML/HTML, struktury zamówień. Kluczem jest mechanizm podwójnej dyspozycji (double dispatch) przez metodę accept() – element decyduje jaką metodę visitora wywołać, visitor decyduje co z nim zrobić. Dodanie nowej operacji (nowego Visitora) nie wymaga zmian w żadnej z istniejących klas elementów.

About Henryk Tews

Co możesz przeczytać następne

Wzorzec Command – undo, CommandBus, makra, integracja z kolejką Magento 2
Strategy w PHP – i jak Magento 2 używa go w cenach
Wzorzec Specification – enkapsulacja reguł biznesowych, AND/OR/NOT kompozycja, testowanie
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}