Ohledně toho, jak používat Doctrine 2 bylo napsáno již mnoho. Jak ze článků zde na Zdrojáku, tak z oficiální dokumentace se dozvíme, jak psát entity, jak používat mapování, pracovat s Entity Managerem, psát složité „selecty“, používat pokročilejší funkce v podobě práce s událostmi nebo psaní vlastních typů pro práci s daty. Přesně o všech těchto věcech tento článek nebude. Budeme se zabývat pouze tím, jak by mohla vypadat aplikace, která toto ORM používá.
S používáním Doctrine 2 začneme u velmi jednoduchého příkladu a postupně si budeme představovat problémy, na které je možné narazit (především s růstem aplikace), a ukážeme si, jak je možné reagovat. Určitě nebudu ukazovat nějaké všeléky nebo unikátní pravdy. Představím architekturu, ke které jsme v Medio Interactive dospěli při práci na našich projektech. Je velmi flexibilní a prakticky na všech místech si může vývojář v závislosti na složitosti aktuální aplikace vybrat, jak velkou míru dekompozice zvolí.
Příklady jsou silně zjednodušené, aby zbytečně neprotahovaly už tak obsáhlý článek. Vynechal jsem většinu validací, starosti o namespaces, vzorné vypisování PHPdoc bloků atp. Kód je tedy pouze ilustrativní, neklade si za cíl být funkčním. Změny v jednotlivých krocích možná budou lépe patrné při zobrazení diffů, je možné si je prohlédnout v repozitáři na GitHubu.
Jedoduchý příklad
Vzhledem k pohodlnosti vývoje je možné Doctrine 2 použít i na malé aplikace, kde si vystačíme s velmi přímočarým postupem. Začneme pouze s controllerem a entitou, která v sobě obsahuje Doctrine 2 anotace potřebné k mapování do databáze. V controlleru přímo pracujeme s entitou a s Entity Managerem.
<?php /** * @Entity */ class Article { const STATUS_DRAFT = 1; const STATUS_PUBLISHED = 2; /** @Column(type="integer") */ private $status; /** @Column(type="date") */ private $publishDate; /** @Column(type="integer") */ private $viewCount; // další properties public function __construct() { $this->status = static::STATUS_DRAFT; $this->viewCount = 0; } public function setStatus($status) { if ($status !== static::STATUS_DRAFT && $status !== static::STATUS_PUBLISHED) { throw new InvalidArgumentException('Invalid status value: ' . $status); } $this->status = $status; } public function setPublishDate(DateTime $date) { $this->publishDate = $date; } // další gettery a settery (settery by měly obsahovat validaci vstupů) }
<?php class ArticleController { /** @var DoctrineORMEntityManager */ private $entityManager; /** @var int */ private $topArticlesCount; public function __construct(DoctrineORMEntityManager $entityManager, $topArticlesCount) { // mapování do private proměnných } private function getTopArticles() { return $this->entityManager->createQueryBuilder() ->select('a') ->from('Article', 'a') ->where('a.status = ?1') ->orderBy('a.viewCount DESC') ->setMaxResults($this->topArticlesCount) ->getQuery() ->setParameter(1, Article::STATUS_PUBLISHED) ->getResult(); } private function publish($id) { $article = $this->entityManager->find('Article', $id); $article->setPublishDate(new DateTime()); $article->setStatus(Article::STATUS_PUBLISHED); $this->entityManager->flush(); } // další obslužné metody }
Entity
Způsobem, jakým je nyní napsána entita, jde vlastně jen o přepravku na data obohacenou pouze o validaci vstupních dat. Kontrolujeme jen, jestli do proměnných přichází data ve správné podobě.
Pokud bychom chtěli validovat stav celé entity, museli bychom v setterech kontrolovat čím dál tím větší množství věcí, aby se entita nedostala do nějakého nežádoucího stavu. Například zkontrolovat, že pokud chceme nastavit entitu do stavu published (podle kterého zřejmě budeme vybírat aktivní články), měl by již mít předem nastavené datum publikování, jinak se entita ocitne v nekonzistentním stavu. Momentálně toto nijak v kódu zachycené není a spoléháme tedy na to, že nikdo nezapomene zavolat obě metody a že je případně zavolá v zamýšleném pořadí.
Obranou proti tomu jsou zmiňované validace nebo dle mého lepší varianta – omezení rozhraní entity, aby nabízelo jen operace, které chceme zvenčí povolit (zapouzdření). Zavedeme tedy metodu setPublished, ve které budou všechny operace, které se musí provést v rámci entity, pokud má být připravena k publikování. V budoucnu sem můžeme bezpečně přidávat další změny, jak budou případně přibývat properties této třídy – a bude potřeba tuto změnu provést vždy jen na tomto jednom místě – viz DRY přístup. Díky tomu se vzdalujeme od anemického modelu a míříme k doménovému modelu.
<?php /** * @Entity */ class Article { const STATUS_DRAFT = 1; const STATUS_PUBLISHED = 2; /** @Column(type="integer") */ private $status; /** @Column(type="date") */ private $publishDate; /** @Column(type="integer") */ private $viewCount; // další properties public function __construct() { $this->status = static::STATUS_DRAFT; $this->viewCount = 0; } public function setPublished(DateTime $date) { $this->status = static::STATUS_PUBLISHED; $this->publishDate = $date; } // další gettery a settery (settery by měly obsahovat validaci vstupů) }
<?php class ArticleController { /** @var DoctrineORMEntityManager */ private $entityManager; /** @var int */ private $topArticlesCount; public function __construct(DoctrineORMEntityManager $entityManager, $topArticlesCount) { // mapování do private proměnných } private function getTopArticles() { return $this->entityManager->createQueryBuilder() ->select('a') ->from('Article', 'a') ->where('a.status = ?1') ->orderBy('a.viewCount DESC') ->setMaxResults($this->topArticlesCount) ->getQuery() ->setParameter(1, Article::STATUS_PUBLISHED) ->getResult(); } private function publish($id) { $article = $this->entityManager->find('Article', $id); $article->setPublished(new DateTime()); $this->entityManager->flush(); } // další obslužné metody }
Repository
V momentě, kdy budeme chtít používat některé dotazy na různých místech, začne opět být nepraktické je kopírovat. Bylo by lepší, kdybychom je měli někde znovupoužitelně připravené. Tímto místem může být například repository, kde můžeme shromažďovat dotazy, které se primárně týkají článků. Vlastní repository tak, aby s ním uměla Doctrine 2 efektivně pracovat, musíme podědit od DoctrineORMEntityRepository a uvést ho v anotaci @Entity pro Article.
<?php /** * @Entity(repositoryClass="ArticleRepository") */ class Article { const STATUS_DRAFT = 1; const STATUS_PUBLISHED = 2; /** @Column(type="integer") */ private $status; /** @Column(type="date") */ private $publishDate; /** @Column(type="integer") */ private $viewCount; // další properties public function __construct() { $this->status = static::STATUS_DRAFT; $this->viewCount = 0; } public function setPublished(DateTime $date) { $this->status = static::STATUS_PUBLISHED; $this->publishDate = $date; } // další gettery a settery (settery by měly obsahovat validaci vstupů) }
<?php class ArticleRepository extends DoctrineORMEntityRepository { public function findTopArticles($maxResultsCount) { return $this->getEntityManager()->createQueryBuilder() ->select('a') ->from('Article', 'a') ->where('a.status = ?1') ->orderBy('a.viewCount DESC') ->setMaxResults($maxResultsCount) ->getQuery() ->setParameter(1, Article::STATUS_PUBLISHED) ->getResult(); } }
<?php class ArticleController { /** @var DoctrineORMEntityManager */ private $entityManager; /** @var int */ private $topArticlesCount; public function __construct(DoctrineORMEntityManager $entityManager, $topArticlesCount) { // mapování do private proměnných } private function getTopArticles() { return $this->entityManager->getRepository('Article')->findTopArticles($this->topArticlesCount); } private function publish($id) { $article = $this->entityManager->find('Article', $id); $article->setPublished(new DateTime()); $this->entityManager->flush(); } // další obslužné metody }
Repository nám může vracet buď již hotové kolekce objektů, alternativou je ale i vracení nějak přednastaveného QueryBuilderu, který pak můžeme dál upravit.
Z repository vytvářené tímto způsobem se pravděpodobně časem stane obrovská třída s mnoha metodami, která dost silně porušuje Open/closed principle. Pokud bychom se tomu chtěli vyhnout, máme možnost zvolit odlišný přístup, jeden takový popisuje například Aleš Roubíček ve svém článku Doménové dotazy, kde vzniká defacto pro každý dotaz samostatná třída. Vše je tam velmi přehledně vysvětleno (ač v syntaxi C#), takže to zde nebudu opakovat.
Nicméně variantu repository bych určitě nezavrhoval – beru repository jako fasádu pro získávání dat a fasády dost často obsahují více (ale jednoduchých) metod. Další výhodou je, že pokud budeme chtít, aby dotazy sdílely určité části kódu, půjde to samozřejmě mnohem lépe v jedné třídě. V případě Doménových dotazů bychom museli přidat třídám další závislosti a jejich používání by již nebylo tak snadné.
Service
Aktuálně hlavní výkonná logika zůstává v controlleru. Jakmile budeme chtít určité postupy, ať už kompletně, nebo po částech používat v jiných controllerech, měli bychom tyto metody opět přesunout jinam – resp. na místo, kde by se měly ideálně nacházet od začátku – do modelu. Vzniknou nám tak service třídy. V nich by se měly objevit všechny metody, které souvisí s doménovým modelem, ale zároveň nejdou zapsat do samotných entit (tam jsme je přesouvali v prvním kroku). Můžou jimi být například metody, které pracují nad více objekty. Rozdělení, co ještě patří do entity a co už do service, velmi závisí na konkrétní situaci a kde daná logika dává větší smysl. Service třídy jsou tedy místem, kde by se měly objevit konkrétní ucelené postupy, jak řešit konkrétní věci (třeba nějaké složitější výpočty), abychom je dále mohli používat napříč aplikací. Je velmi důležité do services nemíchat žádnou práci s persistentní vrstvou, musí zde zůstat čistě doménová logika. Všechna data, která metody v service potřebují, musí získat prostřednictvím svých parametrů. Services dost možná vaše aplikace nevyužije, zkrátka proto, že v ní nejsou takové věci, které by stálo za to do ní psát.
Services by v zásadě měly být stateless – tzn. ve svých properties by měly mít pouze reference na své závislosti, které dostanou přes konstruktor pomocí Dependency Injection. Nic dalšího by si metody do properties ukládat neměly, což v praxi znamená, že nám stačí jedna instance pro mnoho použití (což skvěle funguje v kombinaci s DI kontejnerem, kde pak můžeme mít od každé service právě jednu instanci) a nebudou spolu nijak interferovat.
Vymyslet nějaký pěkný příklad na takto jednoduché ukázce je dost složité, nakonec jsem zvolil situaci, kdy bychom chtěli vylepšit původní vypisování top článků na základě více údajů a lepší logiky než jen prostého počtu zobrazení. Vytvořil jsem tedy ArticleService, který obsahuje metodu, která z předaných článků vybere požadovaný počet nejlepších. To je flexibilní, protože si pak zvenčí můžeme zvolit, kolik článků servise předáme. Může to být stejné množství, jaké ve výsledku požadujeme, může jich být ale více. Chtěl jsem tím ukázat to, že se občas nevyhneme stažení více dat, než budeme ve skutečnosti potřebovat, protože některé věci není vhodné řešit čistě pomocí selectů (netvrdím, že toto je nutně ten případ). Díky stateless povaze servis a oproštění od používání perzistence zásadně zjednodušíme testovatelnost a znovupoužitelnost kódu.
<?php class ArticleService { public function getTopArticlesByMagic(array $articles, $maxResults) { // Zde je nějaký promyšlený algoritmus, který projde všechny předané články a vybere // nám z nich daný počet nejzajímavějších. Může k tomu používat metody entity, případně // přes asociace získávat další související entity a pracovat tak i s jejich daty. } }
<?php class ArticleRepository extends DoctrineORMEntityRepository { public function findArticlesSince(DateTime $date) { return $this->getEntityManager()->createQueryBuilder() ->select('a') ->from('Article', 'a') ->where('a.status = ?1 AND a.publishDate > ?2') ->getQuery() ->setParameter(1, Article::STATUS_PUBLISHED) ->setParameter(2, $date) ->getResult(); } // findTopArticles a další metody ... }
<?php class ArticleController { /** @var DoctrineORMEntityManager */ private $entityManager; /** @var ArticleService */ private $articleService; /** @var int */ private $topArticlesCount; public function __construct(DoctrineORMEntityManager $entityManager, ArticleService $articleService, $topArticlesCount) { // mapování do private proměnných } private function getTopArticles() { $now = new DateTime(); $lastMonthArticles = $this->entityManager->getRepository('Article')->findArticlesSince($now->sub(new DateInterval('P1M'))); return $this->articleService->getTopArticlesByMagic($lastMonthArticles, $this->topArticlesCount); } private function publish($id) { $article = $this->entityManager->find('Article', $id); $article->setPublished(new DateTime()); $this->entityManager->flush(); } // další obslužné metody }
Facade
Ještě stále ale nemáme vyřešenou situaci, kdy budeme chtít některé akce (v našem případě publikování článků nebo vybrání těch nejlepších) používat na více místech v naší aplikaci. To může jednak znamenat použití ve více různých controllerech, ale zároveň jakmile přesuneme tyto metody do modelu (kam podle MVC rozdělení rozhodně patří), získáme možnost nad naším modelem stavět další aplikace. To znamená, že ve výsledku může být nad modelem postavený hlavní web, administrace, dále například REST API a koneckonců i když budeme psát CLI skripty (spouštěné třeba cronem), měly by tyto fasády využívat. Vrstva fasád nám tedy vytvoří přípustné API celého modelu.
V naší ukázce to tedy znamená, že pouze přesuneme metody z controlleru do nově vzniklé fasády, protože již nyní byly privátními a pouze volány dalšími obslužnými metodami. Díky tomu se ale controller konečně zbaví své závislosti na Entity Manageru.
Fasáda by měla být velmi přímočará a pouze využívat metody definované na ostatních částech systému a vhodným způsobem je spojovat dohromady. Každá public metoda ve fasádě by měla sloužit k jasně definovanému účelu a public rozhraní všech fasád popisuje případy užití, které naše aplikace pokrývá. Metody ve fasádách jsou popis jedné business transakce, takže by se pravděpodobně na konci operací, které v dané metodě provádíme, měla na EntityManageru zavolat metoda flush, která odešle provedené změny do databáze (do té doby se pouze hromadí v Unit of Work).
Pokud nastane situace, kdy bychom měli mít velmi podobný kód pro několik metod, nic nebrání vytvoření privátní metody, kam se tento společný kód umístí, a další metody jej budou pouze využívat (a flush tedy pravděpodobně budeme chtít použít až ve „vnějších“ metodách, aby nám nebránil v dalším používání).
<?php class ArticleFacade { /** @var DoctrineORMEntityManager */ private $entityManager; /** @var ArticleService */ private $articleService; public function __construct(DoctrineORMEntityManager $entityManager, ArticleService $articleService) { // mapování do private proměnných } public function getTopArticles(DateTime $since, $maxResults) { $lastMonthArticles = $this->entityManager->getRepository('Article')->findArticlesSince($since); return $this->articleService->getTopArticlesByMagic($lastMonthArticles, $maxResults); } public function publish($id, DateTime $date) { $article = $this->entityManager->find('Article', $id); $article->setPublished($date); $this->entityManager->flush(); } }
<?php class ArticleController { /** @var ArticleFacade */ private $articleFacade; /** @var int */ private $topArticlesCount; public function __construct(ArticleFacade $articleFacade, $topArticlesCount) { // mapování do private proměnných } private function getTopArticles() { $now = new DateTime(); return $this->articleFacade->getTopArticles($now->sub(new DateInterval('P1M')), $this->topArticlesCount); } private function publish($id) { $this->articleFacade->publish($id, new DateTime()); } // další obslužné metody }
Zde se s rozšiřováním již zastavíme. Jen zopakuji, že z výše zobrazených tříd se práce s perzistencí týká Facade, EntityManager, Repository. Všechny ostatní části by měly být od perzistence kompletně oproštěny.
Mapovat, nebo nemapovat?
Z posledního obrázku je zřetelně vidět, že všechny části našeho systému jsou závislé na entitě a ta prostupuje všechny vrstvy, čímž se trochu nabourává izolovanost jednotlivých vrstev.
Pokud bychom ale chtěli tento problém vyřešit, museli bychom na několika místech sáhnout k mapování – například do controlleru bychom již neposílali entitu, ale vytvořili bychom speciální objekt, který by v sobě nesl jen potřebná data, a ty bychom do něj z entity překopírovali.
Tento přístup se nám v praxi při programování webových aplikací příliš neosvědčil, protože benefity, které bychom získali z izolovaných vrstev, zdaleka nedosahovaly overheadu, který byl způsoben přepisováním všech mapování a tříd reprezentujících data na několika úrovních. Toto přepisování se stávalo pokaždé, když se změnilo zadání nebo přišly nové požadavky.
Pokud entita prostupuje celým systémem, pak přidání nové property je potřeba reflektovat pouze na místech, kde chceme s novou hodnotou pracovat. Pokud má dojít ke změně stávajících evidovaných hodnot a je to změna, která vyžaduje modifikaci API dané entity, bude samozřejmě nutné upravit všechny části aplikace odpovídajícím způsobem, nicméně opět jen tam, kde se s nimi opravdu pracuje (oproti mapování, kde se data většinou čistě kopírují, popř. nějak agregují). Pokud pro prezentační účely potřebujeme entitě nějaké hodnoty přeci jen přidat, než ji předáme dále do view, může nám pomoci kompozice – vytvoříme objekt, který bude obsahovat referenci na danou entitu a dále v sobě bude obsahovat potřebné další properties určené k uchování prezentačních hodnot.
Závěr
Názorně jsem ukázal, jak se z několika málo tříd postupným refaktoringem může stát tříd poměrně hodně. Nicméně si rozhodně nemyslím, že by situace, kdy programátor využije všechny zde představené třídy, byla složitější, než ta původní. Kód jako takový prostě někde napsaný být musí a v momentě, kdy ho chceme používat na více místech, dochází buď k duplicitám, které velmi zvyšují riziko výskytu chyb a nekonzistencí, nebo jsme stejně donuceni refactoring udělat.
Pokud budeme architekturu členit od začátku, bude kód o hodně přehlednější – bude rozložený na více místech, ale jednotlivé metody nebudou tak dlouhé, a vždy by mělo být jasné, kde programátor najde funkcionalitu, kterou právě potřebuje (platí zvláště pro práci v týmu). Pokud ji nenajde na místě, kam logicky patří, lze usuzovat, že ještě nebyla naprogramována, a může ji sám na toto místo doplnit, což je situace diametrálně odlišná od toho, kdy by musel prohledat celou aplikaci (v úvodním příkladu všechny controllery) a zjistit, jestli požadovaná funkce již někde je, a případně ji refaktorovat, aby ji mohl použít i on sám.
Pokud vás článek zaujal a chcete se dozvědět více o tom, jak navrhovat aplikace a psát kód, aby byl lépe znovupoužitelný, udržovatelný a snadno testovatelný, přijďte se podívat na naše školení Pokročilý vývoj a testování aplikací.