Programowanie aplikacji biznesowych, zwłaszcza w języku PHP, bardzo często skupia się na tworzeniu dobrego modelu, odzwierciedlającego domenę aplikacji. Istnieje kilka zasad, które pomagają w utrzymywaniu modelu w dobrej kondycji. W tym wpisie pod lupę weźmiemy jedną z tych zasad, a będzie nią Prawo Demeter.

Przykład modelu anemicznego

Przykłady z e-commerce potrafią być bliskie większości programistów, zatem przeanalizujmy serwis, który dodaje do obiektu produktu nową właściwość:

<?php

class AdminProductModifier
{
    private MessageBus $bus;

    // Constructor...

    public function addProperty(
        string $propertyName,
        string $propertyImage,
        Product $product
    ): void {
        $variants = $product->getVariants();

        foreach ($variants as $variant) {
            $variantProperties = $variant->getProperties();
            $propertyExists = false;

            foreach ($variantProperties as $variantProperty) {
                if ($property->getName() === $propertyName) {
                    $propertyExists = true;
                }
            }
            
            if ($propertyExists === false) {
                $newProperty = new PropertyModel();
                $newProperty->setName($propertyName);
                $newProperty->setImage($propertyImage);

                $variantProperties->add($newProperty);

                if ($product->displayAllImages() === true) {
                    $galleryImages = $product->getGalleryImages();
                    if ($galleryImages->has($propertyImage) === false) {
                        $galleryImages->add($propertyImage);
                    }
                }
            }
        }

        $this->bus->dispatch(new ProductWasUpdatedMessage($product));
    }
}

To, co od razu rzuca nam się w oczy, to dostęp do wszystkich składowych modelu w dosłownie każdym miejscu, w którym mamy dostęp do obiektu produktu (wszystkie elementy są dostępne przez bogaty zestaw setterów i getterów). Kusić może, aby za pomocą produktu uzyskać dostęp do wszystkich elementów modelu, a następnie w nich grzebać. W taki sposób przenosimy logikę domenową na poziom serwisów (bardzo często znajdujących się w warstwie aplikacji). Jest to zła praktyka, której wadę postaram się opisać poniżej.

Powyższy kod należy do części panelu administracyjnego. Kiedy dodajemy nową właściwość do produktu (która musi składać się z nazwy oraz obrazka), to do systemu zewnętrznego zostaje wysłana wiadomość, że produkt został zaktualizowany. Dodatkowo, jeżeli produkt jest skonfigurowany tak, aby w galerii wyświetlać wszystkie załączone zdjęcia, to należy do galerii dodać zdjęcie załączone do aktualnie dodawanej właściwości.

Wyobraźmy sobie, że mamy do wykonanie nowe zadanie: importer produktów. Częścią importera będzie również importowanie właściwości produktów. Samo importowanie będzie kontrolowane przez system zewnętrzny, zatem nie chcemy komunikacji w kierunku do tego systemu po zaimportowaniu produktu (inaczej system zewnętrzny miałby do przetworzenia setki tysięcy nic nie wnoszących wiadomości). W zamian potrzebujemy, aby informacja o imporcie została zamieszczona w logach. Dodatkowo, chcemy, aby logika dodawania właściwości niczym nie różniła się od tej, która znajduje się w panelu administracyjnym. Od razu nasuwają nam się dwa rozwiązania:

  • Zrobić warunek w klasie AdminProductModifier, aby nie zawsze wysyłać wiadomość do systemu zewnętrznego. To rozwiązanie ma tę wadę, że stracimy informację, kiedy i dlaczego ma zostać wysłana wiadomość do systemu zewnętrznego. Jeżeli będziemy mieli klasę ProductModifier (zmiana nazwy jest tutaj niejako naturalna), to w końcu nie będziemy znali kontekstu, kiedy wiadomość ma zostać wysłana.
  • Skopiować klasę AdminProductModifier do klasy o nazwie ImportProductModifier, a następnie usunąć linijkę związaną z wysyłką wiadomości. Tutaj jednak istnieje niebezpieczeństwo zerwania spójności logiki dodawania właściwości do produktu. Jeżeli w przyszłości przyjdzie nam zmienić algorytm dodawania właściwości, to istnieje duża szansa na to, że zmienimy go tylko w jednej części systemu (np. panel administracyjny), pomijając logikę importu.

Istnieje trzecie, znacznie lepsze rozwiązanie problemu, jednak zanim do niego przejdziemy, zapoznajmy się z Prawem Demeter.

Z pomocą przychodzi Prawo Demeter

Jednym z nieco zapomnianych przez programistów prawem, które bardzo dobrze wpływa na wygląd modelu jest Prawo Demeter (nazywane również Zasadą Minimalnej Wiedzy), które mówi, że:

Każda metoda wewnątrz obiektu może odwoływać się jedynie do metod należących do:

– tego samego obiektu,
– dowolnego parametru przekazanego do niej,
– dowolnego obiektu przez nią stworzonego,
– dowolnego składnika klasy, do której należy dana metoda.

 Ian Holland, '1987

Oznacza to, że poprawnymi zapisami są:

<?php

class ProductModifier
{
    private MessageBus $bus;

    // Constructor...

    public function addProperty(string $propertyName, Product $product): void
    {
        $propertyModel = new PropertyModel();
        $propertyModel->setName($propertyName);                        // Constructed object method call

        $product->addProperty($propertyModel);                        // Parameter method call
        $this->logAddingProperty($propertyModel);                     // Same object method call

        $this->bus->dispatch(new ProductWasUpdatedMessage($product)); // Property method call
    }
}

Zapisami niezgodnymi z Prawem Demeter będą zatem:

<?php

class ProductModifier
{
    public function addProperty(string $propertyName, Product $product): void
    {
        $propertyModel = PropertyModelFactory::create($propertyName); // Static method call!
        
        $variants = $product->getVariants();
        $variants->addProperty($propertyModel);                      // Foreign object method call!

        $product->getGalleryImages()
                ?->getMainPhoto()
                ?->setAlternativeText($propertyName);                // Foreign object method call!
    }

}

Czy każdy getter jest zły?

Prawo Demeter uczy nas, abyśmy wewnątrz logiki biznesowej unikali metod wyłącznie zwracających dane, nazywanych potocznie getterami. Nie znaczy to, że tego typu metod musimy całkowicie się pozbyć z naszego kodu. Gettery są OK, jeżeli:

  • zwracają typy proste
  • zwracają obiekty będące strukturami danych, czyli nieposiadające jakichkolwiek metod.

Kontrowersyjne ValueObjecty

Do specyficznych struktur danych można zaliczyć ValueObject’y, czyli struktury danych z publicznym dostępem do przechowywanych wartości, ale tylko w trybie do odczytu. W języku PHP tego typu efekt uzyskamy, jeżeli składowe obiektu będą niepubliczne, a dostęp do nich będziemy mieli tylko i wyłącznie przez metody je zwracające (gettery). Jak w takim razie Prawo Demeter ma się do tego typu obiektów zwracanych przez wywołaną metodę?

Jeżeli przejdziemy przez wszystkie cztery podpunkty tej zasady, to wychodzi na to, że nie powinniśmy pozwolić na poniższy zapis:

<?php

class PropertyModel
{
    private string $name;

    // Constructor...

    public function generateSlug(): int
    {
        return str_replace(' ', '-', $this->name);
    }
}

class ProductModifier
{
    private PropertyModelFactory $propertyFactory;

    // Constructor...

    public function addProperty(string $propertyName, Product $product): void
    {
        $propertyModel = $this->propertyFactory->create($propertyName);

        $this->manageSlug($propertyModel->getSlug()); // Foreign object method call!
    }
}

Czy istnieje sposób na poprawę kodu powyżej tak, aby był on zgodny z Prawem Demeter? Oczywiście, że tak. Wystarczy, że do metody manageSlug(...) przekażemy cały obiekt $propertyModel. Wewnątrz metody manageSlug(...) możemy spokojnie wywołać metodę getSlug() i będzie to całkowicie zgodne z Prawem Demeter, gdyż będzie to wywołanie metody z obiektu przekazanego jako parametr.

Repozytoria i fabryki

Jak możemy zauważyć, powyższy kod korzysta ze wzorca Factory w celu utworzenia obiektu klasy PropertyModel. Korzystanie z tego wzorca nie zobowiązuje nas do zwracania jedynie struktur danych czy ValueObject’ów. Wewnątrz fabryki możemy skonstruować obiekt dowolnej klasy, również taki z bardzo skomplikowaną logiką. Podobnie jest z wzorcem Repository, który w większości przypadków służy do zapisu i odczytu encji. Zatem pokusić się można, aby operować na zwróconych obiektach (wywoływać ich metody) zaraz po ich konstrukcji/zwrotce. Aby być zgodnym z Prawem Demeter przy wykorzystywaniu tych wzorców, podobnie jak w przykładzie wyżej, należy przekazywać zwrócony obiekt do nowej metody, gdzie dalej możemy korzystać z jego metod.

Skoro zapoznaliśmy się dosyć szczegółowo z Prawem Demeter, możemy wrócić do funkcjonalności importu właściwości produktu.

Prawo Demeter a modelowanie domenowe

Tworząc aplikacje biznesowe, bardzo często sięgamy do zaawansowanych technik, takich jak architektura warstwowa czy Domain Driven Design. W obydwu podejściach (które można ze sobą łączyć) wydzielamy część aplikacji, która zawiera wszystkie reguły biznesowe bez otoczki około programistycznej. Taki fragment aplikacji nazywamy modelem domenowym. Może on składać się z różnego rodzaju klas, np.: encji, ValueObject’ów, struktur danych oraz fabryk. Dobrze napisany model charakteryzuje się wysoką testowalnością oraz dobrym poziomem enkapsulacji. Jeżeli model nie spełnia tych wymogów, to nazywa się go modelem anemicznym.

Przyjrzyjmy się jeszcze raz naszemu serwisowi aktualizującemu właściwości produktu:

<?php

class AdminProductModifier
{
    private MessageBus $bus;

    // Constructor...

    public function addProperty(
        string $propertyName,
        string $propertyImage,
        Product $product
    ): void {
        $variants = $product->getVariants();

        foreach ($variants as $variant) {
            $variantProperties = $variant->getProperties();
            $propertyExists = false;

            foreach ($variantProperties as $variantProperty) {
                if ($variantProperty->getName() === $propertyName) {
                    $propertyExists = true;
                }
            }
            
            if ($propertyExists === false) {
                $newProperty = new PropertyModel();
                $newProperty->setName($propertyName);
                $newProperty->setImage($propertyImage);

                $variantProperties->add($newProperty);

                if ($product->displayAllImages() === true) {
                    $galleryImages = $product->getGalleryImages();
                    if ($galleryImages->has($propertyImage) === false) {
                        $galleryImages->add($propertyImage);
                    }
                }
            }
        }

        $this->bus->dispatch(new ProductWasUpdatedMessage($product));
    }
}

Wewnątrz całej metody notorycznie lamiemy Prawo Demeter. Serwis aplikacyjny nie powinien mieć wiedzy o konstrukcji całego modelu. Jedyne, co powinien zrobić, to wymusić na modelu zmianę (której detale są przed nim ukryte), a następnie przesłać do systemu zewnętrznego informację o tym, że produkt został zaktualizowany:

<?php

class AdminProductModifier
{
    private MessageBus $bus;

    // Constructor...

    public function addProperty(
        string $propertyName,
        string $propertyImage,
        Product $product
    ): void {
        $product->addNewProperty($propertyName, $propertyImage);

        $this->bus->dispatch(new ProductWasUpdatedMessage());
    }
}

Tak przygotowany serwis nie dość, że jest czysty, to nie łamie Prawa Demeter. Całą logikę dodawania właściwości przenosimy natomiast do modelu:

<?php

class Product
{
    private Collection $variants;

    private Collection $galleryImages;

    public function addNewProperty(
        string $propertyName,
        string $propertyImage
    ): void {
        foreach ($this->variants as $variant) {
            $variantProperties = $variant->getProperties();
            $propertyExists = false;

            foreach ($variantProperties as $variantProperty) {
                if ($variantProperty->getName() === $propertyName) {
                    $propertyExists = true;
                }
            }
            
            if ($propertyExists === false) {
                $newProperty = new PropertyModel();
                $newProperty->setName($propertyName);
                $newProperty->setImage($propertyImage);

                $variantProperties->add($newProperty);

                if ($this->displayAllImages() === true) {
                    if ($this->galleryImages->has($propertyImage) === false) {
                        $this->galleryImages->add($propertyImage);
                    }
                }
            }
        }
    }
}

class Variant
{
    private Collection $properties;

    public function getProperties(): Collection
    {
        return $this->properties;
    }
}

Jest nieco lepiej, ale efektem naszych prac jest przeniesienie łamania Prawa Demeter z jednego miejsca do drugiego. Przeprowadźmy zatem nieco refactoringu, pozbywając się niepotrzebnego gettera, a logikę dodawania właściwości przenieśmy do klasy Variant:

<?php

class Product
{
    private Collection $variants;

    private Collection $galleryImages;

    public function addNewProperty(
        string $propertyName,
        string $propertyImage
    ): void {
       foreach ($this->variants as $variant) {
            if ($variant->hasProperty($propertyName) === true) {
                continue;
            }

            $variant->addProperty($propertyName, $propertyImage);

            if ($this->displayAllImages() === false) {
                continue;
            }

            if ($this->galleryImages->has($propertyImage) === false) {
                $this->galleryImages->add($propertyImage);
            }
        }
    }
}

class ProductVariant
{
    private Collection $properties;

    public function hasProperty(string $propertyName): bool
    {
        foreach ($this->properties as $property) {
            if ($property->getName() === $propertyName) {
               return true;
            }
        }

        return false;
    }

    public function addProperty(
        string $propertyName,
        string $propertyImage
    ): void {
        $newProperty = new PropertyModel();
        $newProperty->setName($propertyName);
        $newProperty->setImage($propertyImage);

        $this->properties->add($newProperty);
    }
}

Nasz model wygląda obecnie na domknięty i spójny. Możemy zatem przejść do naszego zadania, którym jest stworzenie dodatkowego serwisu importującego:

<?php

class ImportProductModifier
{
    private ImportLogger $logger;

    // Constructor...

    public function import(
        string $importId,
        string $propertyName,
        string $propertyImage,
        Product $product
    ): void {
        $product->addNewProperty($propertyName, $propertyImage);

        $this->logger->logImportedProperty($importId, time());
    }
}

Podsumowanie

Uzyskany przez nas efekt końcowy zaskakuje swoją prostotą i używalnością. Kod przez nas napisany jest łatwo testowalny i nie pozwala na zbyt dużą wiedzę na temat konstrukcji modelu bytom do tego nieuprawnionym. Wszystko dzięki zastosowaniu Prawa Demeter w naszym kodzie.

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

2 komentarze

  1. Hej,

    czy tu : „$this->logAddingProperty($property); ” nie powinno być $this->logAddingProperty($propertyModel); ?