Jak (ne)používat komponenty?

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
Barvoj
Člen | 60
+
+12
-

Když jsme před několika měsíci začali stavět novou aplikaci, rozhodli jsme se členit ji do komponent. Inspirací nám byl článek https://zlml.cz/…lny-formular.

Komponentu jsme chápali jako něco co

  • má vlastní šablonu
  • v konstruktoru může přijímat ID entity
  • přes službu si může načíst entitu z databáze
  • přes signály může data modifikovat (prostřednictvím služby)

Jednoduchý presenter s komponentou v tomto pojetí by mohl vypadat takto:

class ArticlePresenter extends Presenter
{
    /** @var ArticleDetailFactory @inject */
    public $articleDetailFactory;

    /** @var int */
    public $articleId;

    public function actionDefault(int $articleId)
    {
        $this->articleId = $articleId;
    }

    public function createComponentArticleDetail()
    {
        return $this->articleDetailFactory->create($this->articleId);
    }
}
class ArticleDetail extends Control
{
    /** @var ArticleService */
    private $articleService;

    /** @var int */
    private $articleId;

    public __construct(ArticleService $articleService, int $articleId)
    {
        $this->articleService = $articleService;
        $this->articleId = $articleId;
    }

    public function render()
    {
        $this->template->setFile(__DIR__ . '/ArticleDetail.latte');
        // načtení dat z databáze
        $this->template->article = $this->articleService->get($this->articleId);
        $this->template->render();
    }

    public function handlePublish()
    {
        // uložení do databáze
        $this->articleService->publish($this->articleId);
    }
}

plus šablona a továrnička IArticleDetailFactory. Postupem času jsme ale začali narážet na problémy.

Duplikující se dotazy do DB

Některé stránky v aplikaci obsahují více komponent pracujících se stejnou entitou. Například komponenty editační formulář a náhled.
Jelikož komponenty přijmou vždy pouze ID, musí si každá komponenta entitu z databáze načíst sama, což vede ke dvěma stejným dotazům do DB.

Jak ošetřovat chyby v komponentách?

Zjistili jsme taky, že načítat entity až v render metodě komponenty může být pozdě. V tu dobu už totiž Nette začalo stránku renderovat, což docela výrazně omezuje možnosti reakce na případné chyby.

Mějme například stránku s komponentou editační formulář. Pokusím-li se editovat neexistující article, tak až při renderování stránky (komponenty) zjistí, že article neexistuje. V tu chvíli už nejde udělat redirect ani vrátit 404. Nette už totiž vrátilo 200. Jediná možnost jak reagovat je změnit šablonu komponenty na chybovou a dokončit renderování stránky.

Podobný problém nastane, pokud sice article existuje, ale aktuální uživatel nemá právo ho editovat.

Jak to dělat lépe?

Tyto problémy mě vedou k názoru, že komponenty používáme špatně. Přemýšlel jsem tedy, jak to dělat lépe. Nabízí se možnost předávat komponentám rovnou entitu (ne pouze ID). Entitu by načítal už presenter.

Komponenty by pak nemusely načítat nic z databáze, čímž by se předešlo jak opakovaným dotazům, tak i chybám vzniklým až při renderu.

Rád bych taky z komponent do presenteru přesunul zpracování signálů. Například přes callbacky.

Radikální varianta by byla, vůbec komponenty nepoužívat. Stačilo by vhodně členit latte šablony a pak je includovat.

Nově by to tedy mohlo vypadat takto:

class ArticlePresenter extends Presenter
{
    /** @var ArticleService @inject */
    public $articleService;

    /** @var ArticleDetailFactory @inject */
    public $articleDetailFactory;

    /** @var Article|null */
    public $article;

    public function actionDefault(int $articleId)
    {
        $this->article = $this->articleService->get($articleId);
        // check access rights
    }

    public function createComponentArticleDetail()
    {
        $component = $this->articleDetailFactory->create($this->article);

        $component->onPublish[] = function () {
            $this->articleService->publish($this->article);
            // flash message + redirect
        }

        return $component;
    }
}
class ArticleDetail extends Control
{
    /** @var Article */
    private $article;

    public $onPublish = [];

    public __construct(Article $article)
    {
        $this->article = $article;
    }

    public function render()
    {
        $this->template->setFile(__DIR__ . '/ArticleDetail.latte');
        $this->template->article = $this->article;
        $this->template->render();
    }

    public function handlePublish()
    {
        $this->onPublish();
    }
}

V našem projektu presenter typicky obsluhuje několik stránek, a obsahuje mnoho komponent. Pokud bychom přesunuli práci s daty z komponenty do presenteru, mohly by presentery hodně nabobtnat. Proto se mi líbí myšlenka, psát presentery pouze s jednou action/render metodou. Tím pádem bude presenter obsahovat pouze to, co je potřeba k vykreslení jedné stránky.

Co si o tom myslíte? Přehlédl jsem něco? Jak (ne)používáte komponenty Vy?

David Grudl
Nette Core | 8218
+
+14
-

Komponenta z pohledu základního balíčku nette/component-model je něco, co dokáže tvořit hierarchickou strukturu, nic víc, nic míň.

Komponenta z pohledu nette/application, tedy komponenta presenteru, využívá této hierarchické struktury k možnosti rozdělit činnost presenteru do více objektů.

Přičemž primárním úkolem presenteru je de facto práce se stavem (v podstatě stavem v URL). Při vytvoření získá tento stav (Request), který zpravidla přetvoří v HTML stránku obsahující cesty k předání a modifikaci tohoto stavu (tedy odkazy). Zároveň má v sobě „syntactic sugar“ umožňující při některých stavech rovnou vyvolat nějaký kód (akce/signály).

Aby celý stav nemusel být obstaráván jednou třídou, mají presentery tu možnost ho řešit hierarchií komponent.


Tedy jediný důvod vytvářet komponenty (myslím potomky Control) je práce s oním stavem, což znamená, že se na komponentu vytváří odkazy nebo má persistetní parametry. Pokud ani jedno neplatí, není třeba z ní dělat Control.

Taky se samozřejmě dá dědit od Control kvůli snadnému přístupu k šabloně, ale toho lze dosáhnout jednoduše bez děděni:

class NotControl
{
	/** @var Nette\Application\UI\ITemplate */
	private $template;

	public function __construct(Nette\Application\UI\ITemplateFactory $templateFactory)
	{
		$this->template = $templateFactory->createTemplate();
	}

	public function render()
	{
		$this->template->....
	}

No a samozřejmě dědění od Control poskytuje možnost nakládat s objekty v presenteru standardním mechanismem, tj. využívat továrničky createComponentXyz() a v šabloně makro {control}.


Samotné rozdělení úkolu presenteru do komponent může probíhat velmi odlišnými způsoby. Ačkoliv se nejčastěji používají líné továrničky createComponentXyz(), nemusí to být pravidlem. Komponenty můžu vytvořit už třeba v metodě startup(), nebo v action abych měl pohodlnější přístup k proměnným stavu (např articleId):

class ArticlePresenter extends Presenter
{
    /** @var ArticleDetailFactory @inject */
    public $articleDetailFactory;

    public function actionDefault(int $articleId)
    {
        $this->addComponent($this->articleDetailFactory->create($articleId), 'articleDetail');
        // nebo $this['articleDetail'] = $this->articleDetailFactory->create($articleId);
    }

Pokud komponenta nepracuje se stavem (tj. nemusí být potomkem Control), můžu úplně obejít komponentový systém:

class ArticlePresenter extends Presenter
{
    /** @var ArticleDetailFactory @inject */
    public $articleDetailFactory;

    public function renderDefault(int $articleId)
    {
        $this->template->articleDetail = $this->articleDetailFactory->create($articleId);
    }

Nevím, jestli jsem do toho teď nevnesl víc zmatku, primárně jde o to, že celý systém je flexibilní a ten nejčastěji používaný vzor není jediný.

Eda
Backer | 220
+
+2
-

@Barvoj Prošel jsem si velmí podobným vývojem, jako vy. V poslední době moje práce s komponentami vypadá takto:

  • Jednoakční presentery s akcí pojmenovanou default (až na výjimky)
  • V presenteru není žádná „byznys logika“, vše dělají komponenty (téměř žádná, např. v BasePresenteru mám tu kontrolu práv na přístup do administrace)
  • Komponenty nedrží žádný „stav“ samy, veškerý stav je v parametrech action metody a ukládá se vhodným způsobem do členských proměnných presenteru, ze kterých pak čerpají komponenty při svém vytvoření v createComponentXY, tzn. žádné persistentní parametry komponent (blbě se na ně pak odkazuje…). Tím získám to, že veškerý stav je na nejvyšší úrovni, je všem komponentám (vyžádají si jej v konstruktoru) přístupný a nemusí si jej jedna komponenta tahat z druhé, což by bylo špatně.
  • Akce s daty provádím v signálech komponent.
  • Pokud je logicky parametrem akce například „CarEntity“ (ty bys měl zřejmě jako parametr akce carId), mám opravdu již v parametru tyhint na CarEntity a router je upravený tak, aby vracel opravdu CarEntity. Tzn. již v routeru se provádí dotaz, který hledá požadovanou entitu, pokud ji nenajde, vůbec se běh nedostane do onoho presenteru a odchytne to až 404. Tím pádem nemusím vůbec injectovat do presenteru CarRepository jen kvůli tomu, abych našel entitu dle ID + provedl check. Tím se presentery zase o dost zpřehlednily, komponenty dostávají přímo CarEntity, stejné dotazy se neopakují a vše je tam, kde to má být.
Felix
Nette Core | 1196
+
0
-

Krom toho posledniho odstavce, to mam dost podobne.

Pokud je logicky parametrem akce například „CarEntity“ (ty bys měl zřejmě jako parametr akce carId), mám opravdu již v parametru tyhint na CarEntity a router je upravený tak, aby vracel opravdu CarEntity. Tzn. již v routeru se provádí dotaz, který hledá požadovanou entitu, pokud ji nenajde, vůbec se běh nedostane do onoho presenteru a odchytne to až 404. Tím pádem nemusím vůbec injectovat do presenteru CarRepository jen kvůli tomu, abych našel entitu dle ID + provedl check. Tím se presentery zase o dost zpřehlednily, komponenty dostávají přímo CarEntity, stejné dotazy se neopakují a vše je tam, kde to má být.

Tady jsem narazel dost casto na to, ze ID nebylo to jedine podle ceho se tahala entita. Pripadne ne vzdy to bylo ID, ale treba uuid, email a dalsi hodnoty. Proto mivam v presenterech 1 fasadu, ktera to obstara.

Eda
Backer | 220
+
0
-

Felix: Však to nevadí, že to je podle něčeho jiného (třeba v tom URL občas mám „url“ z DB, podle které se to tahá). Do presenteru ale propadne zase jen Entita. A to, podle čeho se routuje, je záležitostí routeru (což tam i logicky z mého pohledu ve většině případů patří).

CZechBoY
Člen | 3608
+
0
-

Mám to taky jak @Eda, s rozdílem, že u signálů vracím chybu/prázdný pole/atd, když se nic nenajde, až z komponenty – komponenta se pak v js nějak podle toho zachová a ví o tom jen ona.

srigi
Nette Blogger | 558
+
0
-

Eda napsal(a):

  • Pokud je logicky parametrem akce například „CarEntity“ (ty bys měl zřejmě jako parametr akce carId), mám opravdu již v parametru tyhint na CarEntity a router je upravený tak, aby vracel opravdu CarEntity. Tzn. již v routeru se provádí dotaz, který hledá požadovanou entitu, pokud ji nenajde, vůbec se běh nedostane do onoho presenteru a odchytne to až 404. Tím pádem nemusím vůbec injectovat do presenteru CarRepository jen kvůli tomu, abych našel entitu dle ID + provedl check. Tím se presentery zase o dost zpřehlednily, komponenty dostávají přímo CarEntity, stejné dotazy se neopakují a vše je tam, kde to má být.

Mohol by si ten router co takto vytahuje entity niekam zasharovat?

Gappa
Nette Blogger | 208
+
+1
-

@srigi – tady to je rozepsané:

newPOPE
Člen | 648
+
+4
-

Ono pristup nacitania entit v Routeri zavana anemickym modelom. No verim, ze je to pohodlne (mam to vyskusane na jednom projekte ale z odstupom casu mi to pride ako blbost).

Skor idem cestou ze do V(view)P(Presenter) su predavanane len DTO (read only) objekty ktore obsahuju vsetko co je potrebne pre ich zobrazenie (view, formulare, …). (ano ReadOnly entita sa da vytvorit aj niecim takymto new DTOFooEntity(FooEntity $fooEntity) kde DTOFooEntity ma len metody na citanie).

V pripade, ze treba data upravovat (menit stav) tak az tu nastupuju na scenu Sluzby, Entity ktore data naozaj menia (tzv. Domenovy model).

Cize VP vrstva realne nikdy s Entitami nepracuje pracuje len so skalarmi. Ma to vyhody aj nevyhody:

  • cely interface aplikacie je oddeleny od VP vrstvy tym padom je VP (UI, API, …) vrstva riadena modelom nie opacne (zmeni sa proces registracie upravi sa model az potom UI)
  • urobit API (UI bude v JS) je otazka implementacie transportnej vrstvy (http)
  • lahsie testovanie nakolko ziadne http nepotrebujete (webdriver, …) testujete len sluzby.
  • viac pisania (ako keby ste pisali model a UI oddelene)

Jo, este som zabudol na tie komponenty. Ono je to s nimi niekedy zlozite. My momentalne piseme na vsetko komponentu cize je normalne, ze na jednej stranke ich mam 5+ a kazda z nich pracuje s inymi datami. Mam aj projekt kde mam rovnaky stav ale potrebujem riesit ACL a tam s tym presne zacina problem. Co ked jedna z nich bude pracovat s datami ku ktorym user nema pristup.

  1. Riesenie v podstate nastrelil @DavidGrudl kde popisuje, ze si mozem napr. v startup (kludne inde) zavolat nad komponentami nejake ->checkRequirements() a vtedy si komponenta skontroluje prava a ked tak vyhodi vynimku tu presenter zachyti a vrati co je potrebne (404, 403, …)
  2. Moznost je, ze komponenta sa proste nevykresli prip. vypise ze k tomu dany user nema prava…

Editoval newPOPE (10. 2. 2017 8:38)

Kori
Člen | 73
+
0
-

Jak ošetřovat chyby v komponentách?

Zjistili jsme taky, že načítat entity až v render metodě komponenty může být pozdě. V tu dobu už totiž Nette začalo stránku renderovat, což docela výrazně omezuje možnosti reakce na případné chyby.

Mějme například stránku s komponentou editační formulář. Pokusím-li se editovat neexistující article, tak až při renderování stránky (komponenty) zjistí, že article neexistuje. V tu chvíli už nejde udělat redirect ani vrátit 404. Nette už totiž vrátilo 200. Jediná možnost jak reagovat je změnit šablonu komponenty na chybovou a dokončit renderování stránky.

Podobný problém nastane, pokud sice article existuje, ale aktuální uživatel nemá právo ho editovat.

Ja jsem tohle vyresil pomoci eventu. Komponenta ma setter pro identifikator (id), setter kontroluje zda objekt existuje, prava, atd. a pokud ne, tak vyhodi event, napr. $onNotFound

a v create component mam pak zpracovani eventu. Presenter tedy nemusi nic resit (krome reakci na eventy komponent) a veskera logika je v komponente.

CZechBoY
Člen | 3608
+
0
-

@Kori Jakej by byl rozdíl oproti vyhození výjimky přímo v konstruktoru komponenty?

Kori
Člen | 73
+
0
-

@CZechBoY Tak muzes mit componenty, kde predavas id uz v create metode factory → konstruktor a pak komponenty, kde predavas id az pozdeji. On i ten setter by mohl vyhazovat vyjimku misto eventu. Moznosti je x.

Jen jsem chtel hlavne poukazat na to, ze se chybove stavy daji odchytit drive nez v renderu. Pokud se tedy id nepredava az do render metody komponenty v sablone.

CZechBoY
Člen | 3608
+
0
-

@Kori z tvýho příspěvku jsem pochopil, že ID nastavuješ až v metodě createComponent, která se může volat až v renderu.

Kori
Člen | 73
+
0
-

@CZechBoY Ne, id pro komponentu se nastavuje v action.

Pocitam, ze pokud chytas vyjimky, tak si objekt nejprve attachnes pred renderem. V createComponent() uz by bylo IMHO pozde.

greeny
Člen | 405
+
0
-

Dovolím si přidat něco z mého soudku :)

Já třeba ve větších aplikacích vůbec nepoužívám IDčka. Předávám si rovnou entity. Jediné místo kde idčka zpracovávám jsou filtry v routeru. Výrazně se mi všechno zjednoduší a pokud třeba potřebuju aby v URL místo idčka byl nějakej title, tak to změním na jednom místě a funguje :) Navíc 404 mi vyhodí už router, takže ušetřím trochu času, kde by se musel vyrábět presenter, plnit daty (DI) a procházet životní cyklus k action, kde by teprv spadl.

Eda
Backer | 220
+
+2
-

@srigi Mám to velmi podobně, jak je to v tom odkazovaném článku. Pěkný článek :-)

@newPOPE Myslím, že už rozebíráš trochu něco jiného. Prezentujeme tu koncept toho, že do presenteru nepředáváme obecně identifikátor, ale objekt. A to, jestli je to Entita nebo ReadOnlyObjectOnlyForView je už z pohledu konceptu docela jedno. Podstatné je, že tento objekt je vytvořen z id (slugu/titlu/whatever) již v routeru, nikoliv až v presenteru.

Ad 404: Ano, chyba se tím pádem nedostane do našeho presenteru, ale spadne na ErrorPresenter. Ale tam už máme možnost rozdiskutovat dle původního požadavku, co zobrazíme.

Ad ruční připojování komponent/nastavování ID už v action: na tomto se mi nelíbí, že vznikne další místo, kde mám v kódu napsaný název komponenty. Již ho mám v šabloně a createComponentXY a nyní bych to XY měl i v konstantě pro addComponent nebo $this[‚XY‘]. Tomu se snažím vyhýbat.

Ad autorizace: Tohle řeším na úrovni presenteru v BasePresenteru. Pokud pak má komponenta další sepcifické požadavky na oprávnění, kontroluje si to sama. Podle mě je dost špatně, když komponenta „říká presenteru“, že by měl vyhodit 404. To má buď presenter už vědět sám, nebo už je to pak vnitřní záležitost té komponenty a ta se podle toho také zařídí (nevypíše nic/vypíše omezenou verzi dat atd).

CZechBoY
Člen | 3608
+
0
-

@Eda Je možnost použít addComponent a createComponent se vyhnout úplně.

Kori
Člen | 73
+
0
-

@Eda Entita / Objekt z routeru neni spatna myslenka, ale vidim tam jednu nevyhodu. Jak to resis kdyz skladas komponenty do sebe, atd. Pak ta nadrazena v sobe musi mit logiku na fetchovani dat (entity) a predani prislusnych dat te podrizene, atd. Takze uz duplikujes kod pro ziskavani dat (minimalne inject nejake service, ze ktere ty data dostanes). Dtto, pokud chces komponentu pouzit v nejake dalsi sluzbe, ktera se vola treba z Kdyby/Console apod. Pokud se komponenta stara sama o sebe, pak tohle vyresis jen v te komponente a na jednom miste.

Eda
Backer | 220
+
0
-

@CZechBoY

To je pravda. Ale to už pak nemůžu použít Kdyby/Autowired, což použít chci, protože to presenter dost zpřehledňuje a zjednodušuje.

Navíc, pokud použiju addComponent, tak to mám pak všechno v té action netodě, tzn. není to tak hezky rozčleněné do metod (vpodstatě to pak konverguje k tomu, že bych měl v presenteru jen jednu jedinou metodu actionDefault + případně renderDefault).

Pokud použiju createComponent, tak je to opět všechno v jedné metodě a navíc bych se oproti addComponent nezbavil toho, že musím ukládat všechny parametry akce nějakým způsobem do členských proměnných presenteru.

Navíc ty createComponentXY metody jsou velmi pohodlné na hledání, pokud koukáte například do šablony a chcete najít místo, kde se daná komponenta inicializuje, je to dost jednoduché najít.

@Kori

No, tohle ale přece musíš řešit i když vracíš z routeru jen ID :-) Tzn. řešíš to vždycky. A o injecty závislostí se starají generované továrny, to ručně řešit nemusím nikdy. Jediné, co musím řešit ručně, je inject továren na podřízené komponenty do nadřazených komponent. Ale to zase nemá souvislost s tím, co vracím z routeru.

V reálu to mám tak, že zpravidla komponentám (mimo „servisní“ závislosti) stačí příslušná entita vrácená z routeru, případně v podřízených komponentách jen nějaká jedna její property/případně jedna z hodnot v nějaké property, která je polem (např. výpis všech autorů článků v komponentě pro článek, kde každý autor má svou komponentu pro jeho výpis, té pak logicky stačí předat jen toho autora; tohle se ale děje v rámci ORM a nic zpravidla explicitně ručně nenačítám, jen iteruju přes nějakou property).

Komponenta je z mého pohledu vizuální vykreslitelný kousek stránky. Z toho plyne, že je v Commandech pro command line (s pomocí Kdyby/Console) nepoužívám. Tam jen servisy.

Marek Bartoš
Nette Blogger | 1263
+
0
-

@Eda Mohl by jsi poskytnout ukázkovou implementaci? Popis zní dost zajímavě

Kori
Člen | 73
+
0
-

@Eda Ano, mam to v podstate stejne. Veskere zavislosti jsou z DI a predavam si jen parametr(y), podle kterych komponenta vi, co ma vykreslit (priklad 1 nahore). Nezajima mne, zda se clanky tahaji z databaze, ze souboru, atd. Vsude si tedy vystacim jen z injectem factory, ktera mi tu komponentu vytvari. Narozdil od prikladu 2, kde rikam, tady mas uz nejaka data a ty mi vykresli. V tomto pripade uz je potreba navic inject service, ktera mi ta data pro predani vrati (dtto udela router). A musim to takhle mit vsude, kde s komponentou pracuji. Pokud se uloziste zmeni z databaze treba na Elastic, vymenim jeden inject service v komponente a je to. Na nic dalsiho nemusim sahat.

Ad komponenty: To uz je asi vic o filosofii pouzivani komponent, ja napr. mam v komponentach temer vse, co ma nejakou sablonu (maily, reporty, atd.) a ty se pak treba posilaji mailem pres Rabbit, atd.