Wzorce projektowe – specyfikacja (specification) i mini przykład DDD

Jednym ze wzorców, który pojawia się często w kontekście Domain Driven Design jest wzorzec specyfikacji. Jest to wzorzec, przekształcający reguły biznesowe na logikę Boole’a. Dzięki wzorcowi specyfikacji, możemy w elastyczny sposób sprawdzić, czy dany obiekt spełnia nasze reguły biznesowe.

Tłumacząc go na język interfejsów dostajemy coś takiego:

interface ISpecification {
    /**
     * @return boolean
     */
    public function isSatisfiedBy($candidate);
    /**
     * @return ISpecification
     */
    public function andSatisfiedBy(ISpecification $otherSpec);
    /**
     * @return ISpecification
     */
    public function orSatisfiedBy(ISpecifiaction $otherSpec);

    public function not();
}

Metoda isSatisfiedBy sprawdza czy dany obiekt spełnia warunki reguły. Metoda not() neguje nam warunek, natomiast metody „andSatisfiedBy” i „orSatisfiedBy” umożliwiają nam łączenie reguł w łańcuchy. Łańcuchy te będą połączone odpowiednimi operatorami logicznymi – „and” albo „or”.

Poniżej zaprezentuje implementacje na przykładzie kawałka wyimaginowanej aplikacji do obsługi poborowych zrealizowanej w metodyce DDD.
Nasz model będzie składał się z trzech klas:

/**
 * @entity
 */

class CPoborowy {
    protected $_pesel;
    protected $_imie;
    protected $_nazwisko;
    protected $_waga;
    protected $_wzrost;
    protected $_ksiazeczkaWojskowa;

    public function __construct($pesel, $imie, $nazwisko, CWaga $waga, CWzrost $wzrost){
        $this->_pesel = $pesel;
        $this->_imie = $imie;
        $this->_nazwisko = $nazwisko;
        $this->_waga = $waga;
        $this->_wzrost = $wzrost;
    }

    public function getWzrost(){
        return $this->_wzrost;
    }

    public function getWaga(){
        return $this->_waga;
    }

    public function zrobJaskolke(){
        echo 'robie jaskolke';
    }

    public function odbierzKsiazeczkeWojskowa(CKsiazeczkaWojskowa $kw){
        if(empty($this->_ksiazeczkaWojskowa)){
            $this->_ksiazeczkaWojskowa = $kw;
            return 'Dziękuje uprzejmie';
        }
        return 'Mam juz ksiazeczke';
    }
}

Klasa „poborowy” reprezentuje nam osobę poborowego, który określony jest kilkoma parametrami ważnymi dla członków komisji uzupełnień – imię, nazwisko, wzrost i waga. Posiada konstruktor, dwa getter’y oraz metody „odberzKsiazeczkeWojskowa” oraz „zrobJaskolke”, które są niezbędne dla logiki biznesowej rekrutacji do wojska ;).
Jak pewnie zauważyliście, w konstruktorze przy parametrach „wzrost” i „waga” mamy typehint’y na CWzrost i CWaga.

/**
 * @value
 */

class CWzrost {

    protected $_wartosc;
    protected $_jednostka;


    public function __construct($wartosc, $jednostka){
        $jednostka = strtolower($jednostka);
        if($wartosc <= 0){
            throw new InvalidArgumentException('Wzrost musi byc wiekszy od 0!');
        }
        if(!in_array($jednostka,array('cm','mm','m'))){
            throw new InvalidArgumentException('Dozwolone jednostki to cm, mm i m');
        }
        $this->_wartosc = $wartosc;
        $this->_jednostka = $jednostka;
    }

    public function __toString(){
        return $wartosc.' '.$jednostka;
    }
}

/**
 * @value
 */

class CWaga {

    protected $_wartosc;
    protected $_jednostka;


    public function __construct($wartosc, $jednostka){
        $jednostka = strtolower($jednostka);
        if($wartosc <= 0){
            throw new InvalidArgumentException('Waga musi byc wiekszy od 0!');
        }
        if($jednostka != 'kg'){
            throw new InvalidArgumentException('Dozwolona jednostka to kilogram');
        }
        $this->_wartosc = $wartosc;
        $this->_jednostka = $jednostka;
    }

    public function __toString(){
        return $wartosc.' '.$jednostka;
    }
}

Obie klasy są tzw. ValueObject’s. W odróżnieniu od poborowego, który jest encją i posiada swój unikalny identyfikator w postaci książeczki wojskowej czy pesel-u, wzrost i waga są bytami nieidentyfikowalnymi – można je rozróżnić tylko po wartości, ubranie tych wartości w klasy ma za zadanie:

  • wyróżnienie ich jako ważnej części języka domeny i modelu
  • walidacje

gdy przyjrzymy się głębiej, widzimy, że w konstruktorach obu klas zawarta jest logika walidacji – zarówno waga jak i wzrost nie mogą być mniejsze od zera, są też sprawdzane jednostki obu wielkości, w końcu nasza komisja poborowa jest w Polsce gdzie mamy jednostki układu SI.
Przejdźmy jednak do sedna, czyli wzorca specyfikacji. Załóżmy, że komisja poborowa kwalifikuje do wojska tylko poborowych o wzroście równym 180 cm i wadze 80 kg. Te dwa warunki tworzą naszą specyfikacje, implementacja będzie wyglądać tak:

Najpierw abstrakcyjna klasa bazowa:

abstract class ASpecification implements ISpecification {

    protected $_and;
    protected $_or;
    protected $_not = false;

    public function andSatisfiedBy(ISpecification $otherSpec) {
        $this->_and = $otherSpec;
    }

    public function orSatisfiedBy(ISpecifiaction $otherSpec) {
        $this->_or = $otherSpec;
    }

    public function not() {
        $this->_not = true;
    }
}

Konkretne implementacje:

class CWzrostSpec extends ASpecification {

    protected $_wzrost;

    public function __construct($wzrost){
        $this->_wzrost = $wzrost;
    }

    public function isSatisfiedBy($candidate){
        if($candidate->getWzrost() == $this->_wzrost){
            if(isset($this->_and)){
                return $this->_and->isSatisfiedBy($candidate);
            }
            return true;
        } elseif(isset($this->_or)) {
            return $this->_or->isSatisfiedBy($candidate);
        }
        return false;
    }
}

class CWagaSpec extends ASpecification {
    protected $_waga;

    public function __construct($waga){
        $this->_waga = $waga;
    }

    public function isSatisfiedBy($candidate){
        if($candidate->getWaga() == $this->_waga){
            if(isset($this->_and)){
                return $this->_and->isSatisfiedBy($candidate);
            }
            return true;
        } elseif(isset($this->_or)) {
            return $this->_or->isSatisfiedBy($candidate);
        }
        return false;
    }

}

Przykład użycia:

//okreslamy specyfikacje
$oSpec = new CWzrostSpec(new CWzrost(180,'cm'));
$oSpec->andSatisfiedBy(new CWagaSpec(new CWaga(80,'kg')));

//tworzymy poborowych
$oPoborowy1 = new CPoborowy('123456','Franek', 'Gondala', new CWaga(80,'kg'), new CWzrost(180,'cm'));
$oPoborowy2 = new CPoborowy('789123', 'Max', 'Perreira', new CWaga(72,'kg'), new CWzrost(176,'cm'));

//sprawdzamy czy poborowi spelniaja nasze kryterium
var_dump($oSpec->isSatisfiedBy($oPoborowy1));
var_dump($oSpec->isSatisfiedBy($oPoborowy2));

Dzięki zastosowaniu wzorca specyfikacji, gdy komisja poborowa dostanie nowe wytyczne z Ministerstwa Obrony, będzie można w łatwy sposób zmienić lub dołożyć nowe warunki.

Jak widzimy wzorzec specyfikacji przydaje się przy walidacji obiektów, do walidacji formularzy wystarczyło by jeszcze dodanie metody, która dała by jakąś zwrotną informację o tym, w którym miejscu obiekt nie spełnia specyfikacji. Ponadto wzorca specyfikacji używa się jeszcze w kontekście budowania zapytań do bazy danych a dokładniej do repozytorium. Jak wspomniałem we wcześniejszych artykułach z serii o DDD, repozytorium, jest klasą, która pozwala nam wyszukiwać wcześniej utrwalone obiekty wg pewnych kryteriów. Zamiast więc w klasie repozytorium tworzyć metody typu „findByFirstName”, „findByLastName”, „findBy[a-z0-9_]+” można mieć jedną metodę „find”, która jako argument będzie pobierała obiekt specyfikacji i na jego podstawie tworzyła zapytanie do bazy danych.

Jak zwykle czekam na wszelki feedback ;).

  1. klr pisze:

    Masz literówkę w ostatniej linii, pierwszego akapitu:
    „spełnia nasze regułU biznesowe.”

  2. arecki pisze:

    Klasa CWzrostSpec rozszerza ASpecification a chyba powinna implementować ISpecification ? :)

  3. Wojciech Soczyński pisze:

    Przepraszam, zapomniałem dorzucić abstrakcyjną klasę, już poprawiam…

  4. wookieb pisze:

    Ogólnie pomysł bardzo bardzo fajny, ale… zrealizowanie grupowania warunków jest raczej znacznie trudniejsze. Przykład:
    waga == 90 OR (waga == 100 && wzrost == 100) żeby coś takiego zrealizować trzeba się nieźle namęczyć. Chyba, że się mylę?

  5. No raczej nie jest trudniejsze. Wystarczy napisać klasę o nazwie „Parentheses”, która w konstruktorze przyjmuje dwie lub więcej specyfikacji, a w metodzie isSatisfiedBy sprawdza ich warunki…

  1. There are no trackbacks for this post yet.

Leave a Reply

Informuj mnie o odpowiedziach poprzez e-mail. Możesz również subskrybować wpis bez zostawiania komentarza.