Přeskočit na hlavní obsah

Pět vrstev modelu

Dneska bych chtěl shrnout svůj dosavadní myšlenkový posun ohledně podoby a struktury modelu aplikace, což je téma, které jsem nakopl ve svém předchozím článku a otevřel i na dubnové Poslední sobotě.

Pozor, tento článek jsem psal před lety uprostřed jakéhosi svého momentálního osobního myšlenkového vývoje a posunu. Neberte ho tedy příliš vážně, definitivně a dogmaticky. Může vám pomoci nasměrovat své přemýšlení nějakým směrem, posunout se o pár kroků dál a odrazit se ještě jinam. Ostatně i já jsem se poměrně krátce po jeho napsání, částečně i díky komentářům dole pod článkem i navazujícím diskuzím v hospodách, posunul mentálně, nadhledem a přehledem na zase o několik mil kupředu. A z tohoto mého dnešního pohledu je článek naivní a zastaralý. Nemažu ho – budu rád, pokud vám také pomůže se někam posunout. Ale fakt mu nevěřte :).

Není to asi nakonec nic objevného a zejména z pohledu lidí pohybujících se v jiných jazycích je to určitě vynalezení kola. Ale k některým věcem se prostě člověk musí dopracovat sám. A navíc v oblasti PHP nejsou přístupy založené na DDD vůbec známé, tak to třeba aspoň pár PHP vývojářů nakopne k přemýšlení.

Starý špatný Active Record

Přístup pomocí Active Recordu zjednodušeně řečeno mapuje strukturu a data z databáze do objektové reprezentace v aplikaci tak, že:

  • co tabulka, to jedna třída,
  • co řádek tabulky, to jedna instance,
  • všechny gettery, settery a obslužné metody jsou zpravidla v jedné třídě.

Obecná struktura takového modelu se pak dá znázornit následujícím obrázkem:

Práce s modelem založeným jen na databázi a Active Recordu pak vypadá například takto:

$article = new Article(123);
$article->setStatus(Article::STATUS_PUBLISHED);
$article->setPublished(new DateTime);
$article->save();

Active Record je poměrně jednoduchý, kompaktní a pochopitelný. Pokud s ním ale budete pracovat déle, budete mít větší aplikaci, větší tým programátorů, budete chtít různé části modelu sdílet napříč různými aplikacemi, pokaždé ale s nějakou drobnou odchylkou, tak narazíte na spoustu překážek:

  • Jeden record je právě jedna tabulka – pokud vám jedna entita překrývá více tabulek, které chcete skrýt, nebude vám Active Record stačit.
  • Silná vazba na strukturu databáze – veškeré atributy nebo vazby mezi tabulkami se zrcadlově přenášejí do aplikace, to se dost často nehodí. Typicky v případě jiných typů uložišť, které s relačními databázemi nemají vůbec nic společného.
  • Není to model – v lepším případě je to jen spodní část modelu, více viz Model není pouze databáze
  • Více různých uložišť – Active Record přestává stačit v okamžiku, kdy chcete v rámci jedné entity používat více uložišť, ať už více různých databází, nebo například nějakou memcache jako alternativu k primární databázi.
  • Rozšiřitelnost, reusability, modularita – skoro jakákoliv odchylka či změna se musí dělat děděním, což je z principu špatně. Výměnu funkčnosti či uložiště nemůžete provést snadným nahrazením jedné třídy za jinou. Jakákoliv změna zasahuje přímo do samotné třídy entity, což je špatně.
  • Single responsibility – drsně se porušuje princip single responsibility, jedna třída je tu zodpovědná za úplně všechno.
  • Testovatelnost – kompaktní třída, která dělá uvnitř sebe úplně vše, se velmi špatně testuje, nedá se využívat mockování apod.

Obecná struktura modelu

Active Record je prostě příliš úzký a omezující. Zkusme si jej tedy dekomponovat do více vrstev podle jednotlivých odpovědností:

Berte to primárně jako obecný pohled na věc. Ve své aplikaci nakonec nemusíte mít nutně všech pět vrstev, některé můžete vynechat nebo se vám mohou slít dohromady. Je ale dobré je tam všechny vidět a rozlišovat. Ostatně, i samotný Active Record je pak jen speciálním případem tohoto obecného pojetí – bez service vrstvy a s nevhodně sloučenou entitou, repository a mapperem.

Pojďme si teď popsat jednotlivé vrstvy a jejich odpovědnosti.

Entita

Oproti Active Recordu jsme z entity vyházeli všechny obslužné metody, všechno načítání, ukládání, mazání. Zůstala nám tedy jen přepravka na data, která nabízí pouze své gettery a settery:

$article = new Article;
$article->setTitle('Lorem ipsum');
$article->setStatus(Article::STATUS_PUBLISHED);
$article->setPublished(new DateTime);
echo $article->getTitle();

Navíc je fajn, pokud se můžeme u entity v jakémkoliv okamžiku spolehnout alespoň na základní typové konverze, takže ať pomocí funkce setPublished() uložíme cokoliv, tak se nám nikdy nestane, že by nám pak getPublished() vrátila cokoliv jiného, než instanci DateTime či NULL.

Entita pak slouží jako přepravka, ve které se předávají data z mapperu přes repository až ven z modelu, v ideálním případě na konci ještě přes nějakou servisu. Nic víc, nic méně.

EDIT: Z diskuze pod článkem a navazujících odkazů vyplývá, že entity v podobě pouhých přepravek bez jakékoliv další doménové logiky nejsou vhodným přístupem. V tomto našem případě by tak i sám Article mohl mít navíc definovanou metodu publish(), která by například nastavovala datum a status. Srovnejte se Service níže.

Mapper

Mapper zajišťuje práci s konkrétním uložištěm. Typicky tedy načítání, ukládání či mazání článku. Tímto konkrétním uložištěm pak může být databáze, memcache, filesystém nebo třeba i nějaká vzdálená služba.

$mapper = new ArticleDbMapper;

$article = $mapper->find(123);  // vrací entitu Article
$mapper->save($article);
$mapper->delete($article);

Jeden mapper je pro daný typ entity odpovědný za veškerou obsluhu jednoho typu uložiště. Pokud budeme mít například tři různé mappery ArticleDbMapper, ArticleMemcache­Mapper a ArticleFileMapper, všechny budou navenek nabízet totéž, jenom si to každý uvnitř sebe pořeší podle svého.

Když budu potřebovat v průběhu aplikace začít ukládat články někam úplně jinam, napíšu si pro to další mapper, aniž bych musel sáhnout do entity Article nebo do kteréhokoliv z dosavadních mapperů.

Repository

Byla by ale chyba, pokud bychom se přímo v controlleru aplikace rozhodovali, do kterého uložiště chceme článek uložit, kdybychom v controlleru vytvářeli instanci nějakého konkrétního mapperu. Pokud bychom se totiž například později rozhodli, že chceme místo databáze ukládat články do souborů, museli bychom to přepsat na mnoha místech aplikace.

Proto se mezi controller a mapper vkládá ještě jedna vrstva – repository, která nás odstiňuje od konkrétního použitého mapperu. V controlleru pak nikdy nevoláme přímo konkrétní mapper, ale vždy repository:

$articleRepository = new ArticleRepository;

$article = $articleRepository->find(123);
$articleRepository->save($article);
$articleRepository->delete($article);

Repository v nejjednodušším případě funguje jako mechanický předavač povelů. Nabízí navenek stejné funkce, jako mappery, akorát se až teprve sám uvnitř sebe rozhoduje, kterému mapperu povel předá. Změna konkrétního mapperu pak může být otázkou přepsání jedné proměnné uvnitř repository.

Takové vyčlenění samostatné repository nám pak ale umožňuje dělat mnohem zajímavější věci. Například snadno mohu před databázi předsunout cachování do memcache. Z aplikace pak i nadále volám stále tytéž funkce repository, v mapperech také nic neměním, jenom uvnitř samotné repository přepíšu kód tak, že se pokouším inteligentně kombinovat ArticleDbMapper a ArticleMemcache­Mapper.

Umožňuje nám to dokonce i takové věci, jako vracet z metody find() pokaždé instance různých tříd, například v závislosti na hodnotě nějakého databázového sloupce či rozšiřující tabulky:

// vrátí Television (potomek Product)
$product = $productRepository->find(5);

// vrátí Notebook (potomek Product)
$product = $productRepository->find(123);

EDIT: Jak v diskuzi níže poznamenal Aleš Roubíček, název Repository je chybný, ve skutečnosti se jedná o DAO.

Service

S využitím popsaných vrstev už si většinou vystačíme a takový model je dostatečně volný a flexibilní. V controllerech pracujeme s instancemi entit, voláme nad nimi jejich gettery a settery. A pokud něco potřebujeme načíst, uložit nebo smazat, požádáme o to příslušnou repository.

Stále nám tu ale přetrvává dost silná závislost rozhraní modelu na jednotlivých proměnných entity, na jejím vnitřním uspořádání. Například takové publikování článku:

if ($article->getStatus() != Article::STATUS_DRAFT) {
    throw new Exception('Lze publikovat jen draft článku.');
}
$article->setStatus(Article::STATUS_PUBLISHED);
$article->setPublished(new DateTime);
$articleRepository->save($article);

Často se hodí tohle celé do něčeho zabalit, například:

$post->publish();

A k tomu právě slouží nejvyšší service vrstva. Servisy jsou vlastně fasády postavené nad různými entitami. Jedno volání servisy v sobě zpravidla balí více volání nad entitou, může dokonce pracovat i nad více entitami, odstiňuje nás od jednotlivých sloupců, od jednotlivých getterů a setterů.

Možné využití fasád je nepoměrně větší. Můžeme například článek nabízet jednou v podobě AdminArticle servisy, která umožňuje veškerou editaci, a jednou jako UserArticle servisy, která nám zpřístupní pouze funkce na čtení. A podobně.

Ucelený příklad

Konkrétní podoba celého modelu, na které doceníte celou obecnost popsaných vrstev, pak může vypadat například takto:

Ale to je hrozně moc psaní!

Na první pohled to může vypadat, že pro jednoduché ukládání a načítání článku budu muset vytvořit deset tříd a napsat stovky či tisíce řádků kódu. Tak to ale vůbec nemusí být. Množství nutného kódu vůbec nesouvisí s architekturou modelu, jsou to dvě zcela různé nezávislé problémy.

Pokud si i normálně píšete vše ručně, tak tady je to skoro pořád ten samý kód, co byste bušili kdykoliv jindy, jenom místo cpaní do jedné třídy ho máte logicky rozdělený do tří či více tříd.

Ale dovedu si představit, že pro to lze napsat i systém typu Doctrine nebo Ormion. V něm jediné, co bude potřeba pro typické případy, je definovat nějaký YAML či jiný definiční soubor, a všechny entity, repository i mappery se z něj postaví samy. Rozdíl oproti stávajícím péhápéčkovským ORM systémům ale bude v oproštění se od omezujícího ActiveRecordu a nabídnutí výrazně vyšší flexibility právě ve smyslu důsledného rozdělení do popsaných vrstev.

Dokonce už mám v tomhle směru i něco málo nastřeleného, ale to zase až příště. Teď jenom chci, aby bylo jasné, že tenhle článek není o tom, jestli se bude psát hodně či málo kódu. Je to o myšlenkovém zbavení se ActiveRecordu a nastřelení lepšího rozvrstvení a obecné struktury, která mě nesvazuje a se kterou se dá pracovat.

Prezentace na závěr

Pro zájemce přikládám i prezentaci, kterou jsem k tématu promítal na Poslední sobotě.

Co si o tématu myslíte? Budu vděčný za jakékoliv vaše připomínky!

Související odkazy:

Komentáře

  1. [45] Když dostanu úkol a pouze ho deleguju, jsem za výsledek stejně zodpovědný, jako ten, co ten úkol ve skutečnosti plní.

  2. [21] Už jsem to dokončil a zveřejnil, jmenuje se to NotORM – http://www.notorm.com/

  3. [1] mezi is/get by přece neměl být významový rozdíl – ten je jen v tom, že „isNěco()“ můžu použít u booleovské hodnoty, zatímco v jiných případech musím použít get (resp. pojmenovat si metody můžu jak chci, ale neodpovídalo by to konvencím).

    [11] vyčerpávající odpověď, ušetřil jsi mi komentář :-)

    [13] „Výsledkem bude, že se vám z hlavy vykouří zažité sr.čky, otevře se hlava a budete čerství a schopni poznat, jakým způsobem se ubírat a jakým ne.“

    Hodně lidem bohužel chybí teoretické základy – datové modelování, princip tří architektur – a místo toho si už od samého začátku představují tabulky v MySQL (o těch dvou obecnějších úrovních abstrakce nemají vůbec ponětí).

    A co se týče publikuj() jakožto metody třídy Článek – chce to se trochu zamyslet a abstrahovat.* Komu tahle akce (publikovat) přísluší? Kdo je jejím vykonavatelem? Vydá se článek sám od sebe, nebo je to spíš neživá věc, která sama od sebe nic nedělá? ;-) Otázka není, zda to jde nějak zbastlit – otázka je, jestli to dává smysl, jaká je logika věci.

    Pak je ještě otázka, zda dělat anemický doménový model (jen přepravky bez další logiky) nebo v objektech spojovat data a chování (což je přece princip OOP). Trochu o tom psal Dagi: http://www.dagblog.cz/…archive.html Oba přístupy mají svá pro a proti – osobně si myslím, že anemický model je bezpečnější – v tom smyslu, že dá víc práce, aby to člověk totálně „zvoral“ (zvlášť u jednodušších aplikací). Takže s propojováním dat a logiky raději opatrně – člověk prostě musí vědět, co dělá :-)

    *) udělat analýzu, kterou mnoho „moderních“ „programátorů“ ignoruje a myslí si, že ji nepotřebují

  4. [53] Anemický model je bezpečnejší – prečo potom programuješ objektovo? To už potom stačia dátové štruktúry a metódy a je to to isté.

  5. Ahoj všem, konečně jsem se dostal k tomu, abych si tu všechno pročetl, promyslel a i dále dohledal. Díky moc všem za komentáře, námitky a připomínky, navazující diskuzi i zajímavé odkazy. Rozhodně mě to v mém rozhledu posunulo zase o velký kus dále. Můj původní záměr hned opravit různá pomýlení či zavádějící terminologii v textu narazil na to, že mi postupem času vyvstaly zase další a další nejasnosti. Zkusím tedy své otázky opět zformulovat v nějakém dalším článku. Ještě jednou všem díky!

  6. [54] A ty děláš jen bezpečné věci? Resp. je bezpečnost jediným/nejdů­ležitějším kritériem, že takhle reaguješ?

    BTW: mnohdy opravdu stačí jen datové struktury, protože hodně webových aplikací je jen „zobrazovač dat z databáze“ a jejich entity žádnou vnitřní logiku nemají. Je celkem hloupé, když si někdo přečte, že anemický model je špatný, a tak najednou začne vytvářet metody a cpát logiku i tam, kam nepatří.

  7. Nenarážal som konkrétne na tú bezpečnosť (to bol len výňatok z tvojho textu), ale na to, že vyzdvihuješ anemický model.
    Nie som zástancom toho, pchať logiku tam, kam nepatrí. Ale tvoj príspevok vyznieva tak, že odporúčaš umiestniť logiku do servisnej vrstvy, aj keď je možné dať ju do objektov. To je podľa mňa nesprávna architektúra.
    Uznávam, že keď niekto ten objektový model urobiť nevie, môže tým narobiť viac škody ako úžitku. Ale potom je otázne, či nezvolil zlú programovaciu paradigmu. Ale ako radu začiatočníkom to beriem, aj keď by som zdôraznil (tak, ako píšeš), že si treba doplniť vedomosti z objektového modelovania a nezanedbávať analýzu.

  8. „ale na to, že vyzdvihuješ anemický model“

    Vždyť já ho ale nevyzdvihuji :-) Psal jsem že oba přístupy mají pro a proti. Dagiho článek jsem odkazoval právě proto, že upozorňuje na nevýhody anemického modelu. Stojí to za přečtení, inspiraci – i kdyby se pak člověk rozhodl, že stejně zůstane u svých klasických přepravek-struktur bez vnitřní logiky. A konec konců to, že OOP umožňuje spojovat data a chování do objektů neznamená, že této vlastnosti musíme u všech objektů využívat – můžeme mít objekty „živé“, které mají nějaké svoje chování a pak objekty „neživé“, které mají jen stav, ale samy od sebe se nijak nechovají, nevyvíjejí žádnou činnost – odpovídá to reálnému světu.

  9. Objektové programování mělo svou historickou úlohu, ale teď mi přijde jako takový mor, který brání dalšímu vývoji. Navíc Java, C# a podobné jazyky jsou již koncepčně zastaralé a za čistě objektové se považovat rozhodně nedají. Dál už je i třicet let starý Smalltalk.

  10. [59] aha, takže Java a C# jsou zastaralé, Smalltalk je v objektovosti dál, ale zároveň OOP je mor… takže v čem programovat? :-)

  11. Dobrej článek;) nesouhlasim s tim co bylo napsaný výš Tomášem, že se řeší něco dávno prozkoumanýho nebo se objevuje kolo. Znám dobře ORM frameworky jako Hibernate, Doctrine (které vypadá ve verzi 2 dost zajímavě) či Propel. Stejně tak jsem prošel spoustu věcí okolo DDD a mám načtený bible jako jsou knížky of Fowlera, kde popisuje ORM vzory a další related vzory. A stejně řešim jak přistupovat k architektuře aplikace.. potřeba o tom mluvit je.

    Dospěl jsem vesměs k tomu samému co tu píšeš, jenom bych varoval před těmi „entitami jako přepravkami dat“.. to, že spojujeme data a funkce, které s nimi souvisí do jednoho celku je přece gró objektovýho přístupu, a jakmile bude entita jenom uloziste pro data tak nám to přestane přinášet jakýkoliv výhody..

    Zajímalo by mě co jsi zvolil jako Mapper, nějaké open-source, nebo vlastní řešení? pokud vlastní, co tě k tomu vedlo?

  12. Franta: Velmi provokativní příspěvek, že? :) Já bych totiž rád viděl, aby se programátoři začali více zajímat o funkcionální jazyky a ty se tak dostaly více do praxe. Již jsou totiž na velmi použitelné úrovni. Třeba Haskell.

  13. Domnívám se, že označení Repository v článku je správné. (Snad jen označení metod save a delete lze považovat za sporné.) Na podporu tohoto tvrzení si dovolím citovat Domain Driven Design od Erica Evanse:

    > For each type of object that needs global access, create an object that can provide the illusion of an in-memory collection of all objects of that type. Set up access through a well-known global interface. Provide methods to add and remove objects, which will encapsulate the actual insertion or removal of data in the data store.

    Koncepčně se tedy rozhodně nejedná o >>kolekci pro dotazování zdroje<<.

    Zde uvedené z diskuze později vyplývá, ale nit se vleče přes několik komentářů. Výše uvedené tedy jen na ujasněnou.

  14. Ještě ujasním ujasnění: nejedná se o kolekci pouze pro dotazování zdroje.

  15. Jak se zdá, tak jsem poslední koment k poslednímu článku href=„http://­www.phpguru.cz/clan­ky/obsluzne-metody-modelu/comment-page-1#comment-9371“ rel=„nofollow“>http:/­/www.phpguru.cz/clan­ky/obsluzne-metody-modelu/comment-page-1#comment-9371 psal naprosto zbytečně. Ale jsem rád, že jsi došel v podstatě ke stejnému názoru. Tedy dobře pro mě, alespoň vím že neplácám úplně z cesty.

    To že je tenhle postup jeden z nejlepších jsem se převědčil celkem nedávno. Konkrétní příklad: Mám entitu která má velké množství vlastností a které se navzájem ovlivňují (cena a dph a dph u produktu a podobně). To je přesně ten příklad entity, která překrývá několik tabulek (nebo to všechno nacpeš do XML DB a jsi zpátky u ActiveRecord). Taková třída může nakynout na tisíce řádků a hlavně, jak jsem psal v minulém komentu k minulému článku, hromadné operace nebudou vrchol elegance.

    Ve výsledku se ti může stát, že v tom, co jsi tu předvedl budeš psát i míň. Když si napíšeš obecný source, pak je velmi jednoduché ho upravit a filtrovat napříkald pomocí getrů. V případě active record to bude pokaždé nová metoda.

    Mimochodem, s active record jsem měl vždycky jeden zásadní problém. Dejme tomu že chci získat instance poslední 100článků. Jak to s active record udělám aniž bych odkrýval implementaci a pokud možno jedním parametrizovaným dotazem? Jasně udělám to, ale jak to udělat hezky?

  16. A ještě jedna otázka. Nebylo by efektivnější napsat mapper jako Strategy a vypustit repository? Resp. jejich funkcionalitu přenechat už mapperu?

  17. Teda pánové, tímhle pětivrstvým obrázkem jste mě (coby zend framework začátečníka) teda nepotěšili. :-) Sotva jsem se rozkoukal a zas tohle… ;-)
    Ale na druhou stranu, jestli dobře koukám, Zend_DB mi řeší cca 3 nejspodnější vrstvy, tak toho na mě tolik nezbývá. Každopádně tady nacházím moc dobrý články. Díky moc!
    Možná tip na příští článek: Kde ještě plavu je to, jak co nejlépe do modelu navrhnout třeba zrovna ten článek se štítkama (cizí klíč), nebo jiný „průnik“ dat.

  18. Chvíli jsem to zkoušel, ale přijde mi to podivné.

    V podstatě jde o http://martinfowler.com/…inModel.html – entity jsou jen data, nic neumí a nic se po nich nesmí chtít, a servicy jsou bezstavové manipulátory → a z OOP jsme zpět v procedurálu (snad možná s o trochu větší flexibilitou, nicméně…).

  19. Pekny clanek Honzo, presne takhle to vetsinou funguje v EE Jave. Takovy luxus bych v PHP uvital.

  20. Pročetl jsem si tento článek a přidružené odkazy a narazil jsem na jeden teoretický problém, kdy si nejsem jistý správností řešení.

    • Úložiště nabízí základní funkcionality, nad kterými teprve programujeme aplikaci.
    • Mapper by měl obsloužit příslušnou implementaci základních metod nad úložištěm.
    • Vrstva nad Mapperem (DAO) v tom nejjednodušším případě funguje jen jako mechanický předavač povelů do Mapperu.
    • Entita stojí bokem od těchto vrstev (respektive prostupuje všemi vrstvami) a slouží jako objektové zapouzdření surových dat se základní typovou kontrolou.

    A teď pokud jsem to správně pochopil, tak by Unit of work nadstavba href=„http://­voho.cz/wiki/in­formatika/oop/na­vrhovy-vzor/unit-of-work/“ rel=„nofollow“>http:/­/voho.cz/wiki/in­formatika/oop/na­vrhovy-vzor/unit-of-work/ měla využívat vrstvu DAO a zajišťovat transakční přístup k úložišti.

    Otázka zní: Má být „Unit of work“ součástí „Service“ vrstvy, nebo samostatně a tedy jako „6. vrstva“ modelu?

  21. Pěkně rozepsáno, má to svůj smysl, snažím se to používat podobně, jenže kolikrát je rychlejší i v presenteru napsat
    getValues();
    dibi::query(‚INSERT … %s‘, $data);
    ?>
    A pak není potřeba vůbec žádný model, i když vím že je to špatně.