Závislost jednoho modelu na jiných
- Magnus
- Člen | 65
Ahoj,
dejme tomu, že mám čtyři modely – A, B, C, D.
Model A ve svých třech metodách potřebuje modely B, C, D.
V některé metodě ale může potřebovat třeba všechny tři modely.
Obecná ukázka:
<?php
class ModelA
{
function first()
{
// potřebuje instanci modelu B
}
function second()
{
// potřebuje instanci modelu C
}
function third()
{
// potřebuje instanci modelu B
// potřebuje instanci modelu D, ale potřebuje si ji vytvořit až tady na základě výsledku z modelu B
}
}
?>
Otázka zní – jaký je nejlepší způsob řešení?
Předávat všechny tři instance přes konstruktor se mi nelíbí. Když se
třída rozroste, může vyžadovat další objekty, takže v konstruktoru
vznikne hromada parametrů (a navíc některé objekty se vůbec
nepoužijí).
Slyšel jsem o Kdyby\Events, ale nevím, zda se v tomto případě dají
využít.
Když uvedu příklad, který by mohl být reálný a snad i lehce
pochopitelný:
Budu dělat jakousi RPG a hráč koupí na tržišti předmět od jiného
hráče. Chci, aby oběma hráčů přišla zpráva (řeší MessageModel) a
předmět změnil vlastníka (řeší ItemModel).
<?php
class PlayerModel
{
private $playerId;
function buyItem($id)
{
// jak na následující tři řádky?
$item = vytvoritObjektItemModel($id);
$messagePlayer = vytvoritObjektMessageModel($this->playerId);
$messageRecipient = vytvoritObjektMessageModel(ID hráče, který prodal předmět);
$result = $item->setOwner($this->playerId);
if ($result) {
$messagePlayer->send("koupil jsi předmět ...");
$messageRecipient->send("prodal jsi předmět ...");
}
}
}
class ItemModel
{
function __construct($itemId)
{
...
}
}
class MessageModel
{
function __construct($playerId)
{
...
}
}
?>
Když už jsme u tohoto tématu – jaké je správné řešení, když
daná metoda buyItem() zasáhne i do tabulky, která obecně s hráčem
nesouvisí? Například smaže záznam o předmětu z tabulky
‚market‘.
Má databázový příkaz poslat metoda ve třídě Player nebo v nějaké
třídě Market?
V podstatě podle čeho se mám rozhodnout, do které ze tříd dát danou
metodu, když pracuje s více tabulkami?
Zatím to obcházím způsobem, který není moc OK. Mám třídu ModelFactory, která na základě potřeby vytváří objekty. Tu předávám konstruktorem modelům, které ji potřebují.
<?php
class ModelFactory
{
private $db;
function __construct($db)
{
$this->db = $db;
}
function createPlayerModel($playerId)
{
return new PlayerModel($playerId, $this->db, $this);
}
function createItemModel($itemId)
{
return new ItemModel($itemId, $this->db);
}
function createMessageModel($playerId)
{
return new MessageModel($playerId, $this->db);
}
}
// poté řešení v metodě PlayerModel::buyItem()
class PlayerModel
{
private $db;
private $playerId;
private $modelFactory;
function __construct($playerId, $db, $modelFactory)
{
$this->playerId = $playerId;
$this->db = $db;
$this->modelFactory = $modelFactory;
}
function buyItem($itemId)
{
$item = $this->modelFactory->createItemModel($itemId);
$ownerId = $item->getOwnerId();
$result = $item->setOwner($this->playerId);
if ($result) {
$playerMessage = $this->modelFactory->createMessageModel($this->playerId);
$playerMessage->send("koupil jsi předmět ...");
$recipientMessage = $this->modelFactory->createMessageModel($ownerId);
$recipientMessage->send("prodal jsi předmět ...");
}
}
}
?>
Určitě se podobné téma někdy řešilo, nenašel jsem ale stejný
případ. Budu proto moc rád, když mi případně pošlete nějaké odkazy,
kde je tento problém vysvětlen.
Snad je to napsané srozumitelně, věnoval jsem tomu docela dost času :D
Díky!
- Tharos
- Člen | 1030
Je to napsané úžasně srozumitelně. Věnoval jsi dotazu evidentně docela dost času, a tak i já věnuji docela dost času odpovědi. :)
Nabídnu Ti trochu jiný přístup, než na jaký jsi možná zvyklý. Řešení Ti popíšu pro snazší pochopení „inkrementálně“:
Krok 1
PHP nám umožňuje psát objektově, a tak pojďme toho využít a nasimulujme si pomocí objektů realitu, kterou programujeme. Určitě budeme potřebovat nějaký objekt reprezentující hráče a také nějaký objekt reprezentující věc, se kterou hráči obchodují (neřeším ve své ukázce ten market – co by to přesně mělo být?):
<?php
class Player
{
/**
* @var string
*/
private $id;
/**
* @param string $id
*/
public function __construct($id)
{
$this->id = $id;
}
}
class Item
{
/**
* @var int
*/
private $id;
/**
* @param int $id
*/
public function __construct($id)
{
$this->id = $id;
}
}
?>
Žádnáv velká věda, zatím chceme jen umět hráče a předměty nějak identifikovat (mají tedy ID, hráč textové).
Krok 2
Chceme, aby mezi nimi existovala asociace (vztah) a abychom si to udělali zajímavé :), řekněme, že chceme obousměrnou asociaci. To znamená, že chceme, aby hráč věděl, jaké má předměty, a zároveň aby „předmět věděl“, jakému hráči patří. Jdeme na to:
<?php
class Player
{
/**
* @var string
*/
private $id;
/**
* @var Item[]
*/
private $items = [];
/**
* @param string $id
*/
public function __construct($id)
{
$this->id = $id;
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function giveItem(Item $item)
{
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
throw new InvalidArgumentException("Player doesn't own requested item.");
}
unset($this->items[$index]);
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function receiveItem(Item $item)
{
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
$this->items[] = $item;
}
$item->changeOwner($this);
}
}
class Item
{
/**
* @var int
*/
private $id;
/**
* @var Player
*/
private $owner;
/**
* @param int $id
* @param Player $owner
*/
public function __construct($id, Player $owner)
{
$this->id = $id;
$this->owner = $owner;
$owner->receiveItem($this);
}
/**
* @param Player $newOwner
*/
public function changeOwner(Player $newOwner)
{
if ($this->owner !== $newOwner) {
$this->owner->giveItem($this);
$this->owner = $newOwner;
$this->owner->receiveItem($this);
}
}
}
?>
Tohle už vypadá mnohem zajímavěji. V bodech sepíšu, čemu je dobré věnovat pozornost:
- Nechceme, aby existoval předmět, který nemá majitele. Předpokládejme, že takové je prostě zadání (a schválně jsem si ho pro ukázku vymyslel obtížné). Proto předmět už v konstruktoru vyžaduje hráče, kterému má patřit. Zkrátka nemůže vzniknout instance předmětu, který by neměl žádného vlastníka.
- Asociace, kterou mezi oběma třídami máme, je obousměrná, a proto když se nastaví nový majitel předmětu, je zapotřebí, aby se daný předmět přidal mezi hráčovo předměty.
- Pro vlastní změnu vlastníka budeme využívat metodu
Item::changeOwner
, která řídí proces změny vlastníka. Je zapotřebí nastavit nového vlastníka přímo v předmětu a kvůli obousměrné asociaci i odebrat předmět původnímu vlastníkovi a přidat jej mezi předměty nového vlastníka. To celé děláme proto, aby náš model byl v každém okamžiku konzistentní.
Krok 3
Pokud jsi hodně pozorný, asi sis všiml, že s tou konzistencí je to
vcelku odvážné tvrzení. :) Výše uvedený model lze pořád vcelku snadno
rozbít (v praxi samozřejmě omylem a v dobrém úmyslu). Stačí totiž,
když například obejdeš metodu Item::changeOwner
a předmět
přidáš „ručně“ více různým hráčům. Samotný předmět bude mít
sice vždy jen jednoho vlastníka, ale více hráčů jej bude mít mezi svými
předměty. Nekonzistence je na světě… Také po zavolání
Player::giveItem
předmět už není v předmětech hráče, ale
zároveň v předmětu je daný hráč ještě uveden jako jeho majitel (to
proto, protože předmět v našem zadání nemůže být ani na okamžik bez
majitele).
Je tu totiž jedna specialita vyplývající přímo ze zadání. :) Změna
vlastníka předmětu je jakousi transakcí, kterou nelze provést jinak, než
tak, jak je vyjádřena v metodě Item::changeOwner
. Naštěstí
existuje způsob, jak tento model udělat úplně neprůstřelný:
K dispozici je také verze bez transaction hell
<?php
class Player
{
/**
* @var string
*/
private $id;
/**
* @var Item[]
*/
private $items = [];
/**
* @param string $id
*/
public function __construct($id)
{
$this->id = $id;
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function giveItem(Item $item)
{
if (!$item->isInTransaction()) {
throw new InvalidArgumentException("Cannot give item that isn't in transaction.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
throw new InvalidArgumentException("Player doesn't own requested item.");
}
unset($this->items[$index]);
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function receiveItem(Item $item)
{
if (!$item->isInTransaction()) {
throw new InvalidArgumentException("Cannot receive item that isn't in transaction.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
$this->items[] = $item;
}
}
}
class Item
{
/**
* @var int
*/
private $id;
/**
* @var Player
*/
private $owner;
/**
* @var bool
*/
private $isInTransaction = FALSE;
/**
* @param int $id
* @param Player $owner
*/
public function __construct($id, Player $owner)
{
$this->id = $id;
$this->owner = $owner;
$this->doInTransaction(function () use ($owner) {
$owner->receiveItem($this);
});
}
/**
* @param Player $newOwner
*/
public function changeOwner(Player $newOwner)
{
if ($this->owner !== $newOwner) {
$this->doInTransaction(function () use ($newOwner) {
$this->owner->giveItem($this);
$this->owner = $newOwner;
$this->owner->receiveItem($this);
});
}
}
/**
* @return bool
*/
public function isInTransaction()
{
return $this->isInTransaction;
}
/**
* @param Closure $callback
*/
private function doInTransaction(Closure $callback)
{
$this->isInTransaction = TRUE;
$callback();
$this->isInTransaction = FALSE;
}
}
?>
Prostě obohatíme předmět o stav, který vyjadřuje „fajn, jsem
v transakci mezi hráči, mohou si mě předat“. Uvedením se do tohoto stavu
je zodpovědnost přímo předmětu a hráči jen kontrolují, že obchodují
s předmětem, který je v takovémto stavu. Jelikož předmět je jediný,
který se do toho stavu a zase z něj zpět umí uvést, umí si vynutit, že
všechny změny vlastníků se budou odehrávat přes metodu
Item::changeOwner
, která řeší vše potřebné.
Takto napsaný model už nedostaneš do nekonzistentního stavu. Dokonce se
obejdeme i bez „pro jisotu“ volání Item::changeOwner
na
konci Player::Item
, protože problém, který to volání řešilo,
řeší ta transakce ještě lépe.
Editoval Tharos (27. 2. 2015 1:52)
- Tharos
- Člen | 1030
Krok 4
No a teď k tomu posílání zpráv a Kdyby\Events, na které ses ptal. :) Teď už je úplná brnkačka obohatit hráče o události, které se vyvolají při vydání či přijetí předmětu:
Šlo by přepsat do verze bez transaction hell
<?php
/**
* @method onItemGiven(Player $player, Item $item)
* @method onItemReceived(Player $player, Item $item)
*/
class Player extends Object
{
/**
* @var Closure[]
*/
public $onItemGiven = [];
/**
* @var Closure[]
*/
public $onItemReceived = [];
/**
* @var string
*/
private $id;
/**
* @var Item[]
*/
private $items = [];
/**
* @param string $id
*/
public function __construct($id)
{
$this->id = $id;
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function giveItem(Item $item)
{
if (!$item->isInTransaction()) {
throw new InvalidArgumentException("Cannot give item that isn't in transaction.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
throw new InvalidArgumentException("Player doesn't own requested item.");
}
unset($this->items[$index]);
$this->onItemGiven($this, $item);
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function receiveItem(Item $item)
{
if (!$item->isInTransaction()) {
throw new InvalidArgumentException("Cannot receive item that isn't in transaction.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
$this->items[] = $item;
$this->onItemReceived($this, $item);
}
}
}
class Item
{
/**
* @var int
*/
private $id;
/**
* @var Player
*/
private $owner;
/**
* @var bool
*/
private $isInTransaction = FALSE;
/**
* @param int $id
* @param Player $owner
*/
public function __construct($id, Player $owner)
{
$this->id = $id;
$this->owner = $owner;
$this->doInTransaction(function () use ($owner) {
$owner->receiveItem($this);
});
}
/**
* @param Player $newOwner
*/
public function changeOwner(Player $newOwner)
{
if ($this->owner !== $newOwner) {
$this->doInTransaction(function () use ($newOwner) {
$this->owner->giveItem($this);
$this->owner = $newOwner;
$this->owner->receiveItem($this);
});
}
}
/**
* @return bool
*/
public function isInTransaction()
{
return $this->isInTransaction;
}
/**
* @param Closure $callback
*/
private function doInTransaction(Closure $callback)
{
$this->isInTransaction = TRUE;
$callback();
$this->isInTransaction = FALSE;
}
}
?>
Je vhodné, aby hráč vůbec nevěděl o nějaké messages vrstvě aplikace, a proto jsou eventy velmi dobrým způsobem, jak ty notifikace naimplementovat.
No a proč jsem se o tom takhle rozepsal? Protože tohle je tak nějak objektový přístup a přístup, ve kterém Ti pak jdou úplně na ruku nástroje jako Doctrine 2 nebo právě Kdyby\Events.
Kdyby ses totiž ve výše uvedené ukázce rozhodl mít hráče, předměty a vztahy mezi nimi persistované v relační databázi, jediné, co bys při použití Doctrine 2 musel udělat navíc, by bylo napsat nad properties těch tříd pár anotací. :)
Editoval Tharos (27. 2. 2015 1:53)
- Magnus
- Člen | 65
Děkuji za obsáhlou odpověď! Snad tvé první dvě věty nebyly myšleny ironicky, opravdu jsem to tu měl dlouho otevřené a snažil se najít správné věty :D
V MVC/MVP se mi zatím nepodařilo psát objektově tak hezky, protože
vždy byly určité problémy, mezi které hlavně patří, jak objektům
předat spojení s databází (bez toho bohužel nejde obyčejné new
Obj($id).
Doctrine bohužel neovládám a v nejbližší době určitě nebudu. Nejsem si
jistý, zda jsem tvé příklady pochopil správně.
Nejdříve bych si rád ujasnil jinou věc. Budu chtít:
- Získat seznam všech předmětů ve hře. To bude nejspíš řešit nějaká služba s názvem třeba ItemService.
- Manipulovat s určitým předmětem (změnit vlastníka atp.). To by měla řešit jiná třída, např. Item.
Pokud chápu správně, pak by jedna třída neměla reprezentovat určitý předmět a zároveň umět vrátit seznam všech předmětů v nějakém poli. Jednalo by se o porušení single responsibility. Je to tak?
Co se týká samotného tématu. Pokusím se vytvořit praktický příklad. Dokázal bys mi, prosím, upřesnit pár nejasností?
Použijeme databázi MySQL, ve které budou tabulky player
(id,
name), item
(id, ownerId, place (kde předmět je – inventář,
tržiště, …)) a message
(id, recipientId, content).
Cílem je, že:
- Hráč na stránce klikne na odkaz „koupit předmět“
- Zpracuje se signál pomocí metody handleBuyItem()
- Upraví se řádek v tabulce
item
(změní se ownerId a place (z tržiště na inventář), nebudeme teď řešit, zda má hráč dostatek peněz apod.) - Oběma hráčům se odešle zpráva (2× insert do tabulky
message
)
Dle zažitého postupu bych to řešil nějak takto:
<?php
class BasePresenter extends Nette\Application\UI\Presenter
{
/** @var Player */
protected $player;
// jak injectovat objekt třídy Player, aby mohl i komunikovat s databází?
// možná by se to řešilo pomocí továrničky
/** @var PlayerFactory @inject */
protected $playerFactory;
public function startup()
{
parent::startup();
$this->player = $this->playerFactory->create($this->user->id);
}
}
class MarketPresenter extends BasePresenter
{
/**
* @param int $id
*/
public function handleBuyItem($id)
{
try {
$this->player->buyItem($id);
} catch (Nette\InvalidArgumentException $e) {
$this->flashMessage($e->getMessage());
$this->redirect("default");
}
}
}
class Model
{
/** @var Nette\Database\Context */
protected $db;
/**
* @param Nette\Database\Context
*/
public function __construct(Nette\Database\Context $db)
{
$this->db = $db;
}
}
class Player extends Model
{
/** @var int */
private $playerId;
/**
* @param int $id
* @param Nette\Database\Context $db
*/
public function __construct($id, Nette\Database\Context $db)
{
parent::__construct($db);
$this->playerId = $id;
}
/**
* @param int $id
*/
public function buyItem($id)
{
// jak vyřešit modifikaci záznamu v tabulce `item`?
// o to by se měla postarat spíš metoda ve třídě Item
// zároveň je potřeba zjistit ID vlastníka daného předmětu, proto by zde objekt třídy Item měl být
// jak zde vyřešit odeslání zprávy oběma hráčům?
// je potřeba vytvořit instance třídy Message až zde, protože teprve uvnitř metody zjistíme ID vlastníka předmětu
}
}
?>
Nějak takto je (nebo alespoň byl, když jsem začínal) popsán postup
v Nette. Místo Player tam ještě byla nějaká service třída, která
přijímala i ID hráče v metodě buyItem a vůbec ho nepoužívala jako
atribut.
Je proto dost možné, že na to jdu špatně.
Pokud nebudu používat vychytávky jako je Doctrine, ale s databází pracovat jednoduše pomocí SQL příkazů uvnitř metod modelů, pak mi přijde, že se objevuje několik problémů, kvůli kterým by mi tvůj příklad úplně správně nefungoval.
- duke
- Člen | 650
Vzhledem k tomu, že operace „prodeje věci“ není věcí výhradně hráče ani výhradně věci (nýbrž se to týká vztahů hráčů a věcí), neměla by být IMHO taková logika řešena přímo v těchto entitních třídách, ale někde jinde, ve službě (může jít i o entitu), která tyto vztahy řeší. Samotnou akci prodeje bych zapouzdřil do zvláštní třídy, např. takto:
class Sale
{
/** @var IItemOwner */
private $seller;
/** @var IItemOwner */
private $buyer;
/** @var Item */
private $item;
public __construct(IItemOwner $seller, IItemOwner $buyer, Item $item)
{
$this->seller = $seller;
$this->buyer = $buyer;
$this->item = $item;
}
/**
* @throws ItemNotOwnedException
*/
public function execute()
{
$this->seller->releaseItem($this->item); // u Tharose giveItem // release se mi zdá vhodnější, neboť give evokuje adresáta
$this->item->setOwner($this->buyer);
$this->buyer->receiveItem($this->item);
}
}
A tyto objekty by pak zpracovávala jiná služba k tomu určená.
Jak a kdy řešit persistenci pak závisí na tom, zda použiješ nějaké ORM,
nebo si to vše řešíš sám. Pokud sám, musíš někde udržovat informaci,
které entity je třeba persistovat (protože byly změněny) a to pak ve
vhodné službě provést.
Dokážu si např. představit třídu SalesManager, která zpracovává objekty Sale, a následně persistuje pozměněné entity skrz služby PlayerRepository a ItemRepository.
- Etch
- Člen | 403
@Magnus
Poměrně důležitá věc je si uvědomit jednu řekněme základní věc. Nesnaž se chápat modelovou vrstvu jako něco co má přesně opisovat strukturu databáze. Není úplně běžné, že by se modelová vrstva skládala pouze z repository a entitních tříd, které by odpovídaly tabulkám v DB. Velmi často se repository nepoužívají „na přímo“, ale obalují se do Fasádních tříd, které sdružují a obstarávají zpracování složitějších operací. Fasády se pak většinou i „jednodušeji“ používají lidem, kteří neznají dobře implementaci entit a repositářů, protože mají zpravidla přívětivější interface.
Fasáda je pak v podstatě třída „SalesManager“, na kterou naráží @duke.
- Tharos
- Člen | 1030
@duke: Díky za Tvou ukázku, protože úplně krásně demonstruje jednu věc. :)
Ty jsi tu transakční logiku přesunul z entit do servisní vrstvy, která nad těmi entitami operuje. Z mého pohledu jde o přechod od „spíše objektového“ řešení ke „spíše procedurálnímu“ a vidím v něm hned několik z toho vyplývajících nevýhod:
- Tvůj model už není nerozbitný, snadno jej přivedu do nekonzistentního
stavu (v praxi samozřejmě nechtěně). Stačí, když někde zavolám
releaseItem
, čímž odeberu hráči předmět, ale zapomenu pak upravit informaci v předmětu, komu že patří. - Přibyl setter
setOwner
, který mohu zavolat s jakýmkoliv parametrem a zase tím narušit konzistenci (třeba nastavím jako vlastníka jiného hráče, než který má aktuálně předmět mezi svými předměty). - Musím vědět, že změna vlastníka se má
dělat přes třídu
Sale
. Když to nebudu vědět, model mi nezabrání udělat to jinak, nesprávně. - Co se stane, když zavolám (nechtěně) dvakrát
Sale::execute
po sobě?
No a teď by mě zajímalo, jestli to řešení má i nějaké výhody?
Editoval Tharos (26. 2. 2015 23:46)
- duke
- Člen | 650
Tharos napsal:
@duke: Moc díky za Tvou ukázku, protože úplně krásně demonstruje jednu věc. :)
Také děkuji za reakci.
Ty jsi tu transakční logiku přesunul z entit do servisní vrstvy, která nad těmi entitami operuje. Z mého pohledu jde o přechod od „spíše objektového“ řešení ke „spíše procedurálnímu“…
Pravdou je, že věc lze řešit:
- v entitách
- v servisní vrstvě nad nimi
Asi jsem se nevyjádřil úplně přesně, ale šlo mi o to, že ať tak či onak, měly by entity/služby samy řešit jen to, co jim náleží (vše, co se týká jich samotných) a nic nad to. V tomto ohledu byl můj argument neutrální vůči tomu, zda pro logiku použít entitu, či servisní vrstvu.
Poté jsem doporučil zapouzdřit prodej do třídy Sale
(kterou
bych s dovolením chápal spíš jako entitu než službu). Smyslem nebylo
přenést kód z entit do servisní vrstvy, ale přenést ho z místa, kam
IMHO nepatří (z logiky hráčů a věcí), na místo, kam patří (do logiky
prodejů, ať už je prodej realizován jako entita či jinak).
Z mého pohledu jde o přechod od „spíše objektového“ řešení ke „spíše procedurálnímu“
Spíše procedurální by to asi bylo, kdyby neexistovala ta třída Sale a
celý prodej byl realizován jen nějakou metodou ve službě, např.
SalesManager::sell($seller, $buyer, $item)
. Pokud je ale prodej
vyjádřen instancí třidy Sale
, nedokážu si představit více
objektové řešení.
…a vidím v něm hned několik z toho vyplývajících nevýhod:
- Tvůj model už není nerozbitný, snadno jej přivedu do nekonzistentního stavu (v praxi samozřejmě nechtěně). Stačí, když někde zavolám
releaseItem
, čímž odeberu hráči předmět, ale zapomenu pak upravit informaci v předmětu, komu že patří.
Nesnažil jsem se o nerozbitný model, ale o přehledný model. Když se
chce, rozbít jde cokoli.
Mimochodem různé pojistky podobné tvému doInTransaction
by
v řešení s třídou Sale
jistě mohly být také.
Nevím ale, zda něco jako isInTransaction
do entitní třídy
Item
z hlediska SRP patří, neboť zodpovědností entity
Item
jistě nejsou nějaké transakce, o kterých
Item
vůbec nic neví (transakce probíhají nad
ní). Spíše mi to připadá jako takový hack (to, co má být nad, je
přesunuto dovnitř), který sice někdy může být užitečný, ale bál bych
se ho využívat ve velkém. Protože, když takovou metodu může mít
Item
, proč ne i Player
a všechny ostatní entity, a
kdo si pak má pamatovat, kdo musí být při jaké operaci v transakci, a kdo
naopak ne. Půlka kódu v entitách by pak také mohla být o tom, zda to či
ono je v transakci. Pak bychom pro to mohli ještě vynalézt nový pojem,
např. transaction hell.
- Přibyl setter
setOwner
, který mohu zavolat s jakýmkoliv parametrem a zase tím narušit konzistenci (třeba nastavím jako vlastníka jiného hráče, než který má aktuálně předmět mezi svými předměty).
IMHO je lepší takovéto chyby řešit testováním. Mám za to, že mít o něco více nerozbitný model za cenu znepřehlednění kódu oním transaction hell nestojí. Ale třeba se pletu…
- Musím vědět, že změna vlastníka se má dělat přes třídu
Sale
. Když to nebudu vědět, model mi nezabrání udělat to jinak, nesprávně.
Ale změna vlastníka se nemusí dělat přes třídu Sale
,
nýbrž se má dělat metodou Item::setOwner
. Ano, není to
zabezpečené z hlediska konzistence. Pokud bych potřeboval zajištění
konzistence, asi bych si to zapouzdřil do třídy ItemTransfer
(Sale
by ji mohla rozšiřovat). A ano, pak bych si musel
pamatovat, že pokud chci praktikovat bezpečnou změnu vlastníka, je třeba ji
dělat přes třídu ItemTransfer
. :-)
- Co se stane, když zavolám (nechtěně) dvakrát Sale::execute po sobě?
Vyhodí to výjimku ItemNotOwnedException
(při volání
$this->seller->releaseItem($this->item)
).
No a teď by mě zajímalo, jestli to řešení má i nějaké výhody?
Doporučuji zagooglit na téma výhody respektování single reponsibility
principu.
Ale mohu něco ocitovat:
- Code complexity is reduced by being more explicit and straightforward
- Readability is greatly improved
- Coupling is generally reduced
- Your code has a better chance of cleanly evolving
Editoval duke (27. 2. 2015 0:54)
- Tharos
- Člen | 1030
duke napsal(a):
Poté jsem doporučil zapouzdřit prodej do třídy
Sale
(kterou bych s dovolením chápal spíš jako entitu než službu). Smyslem nebylo přenést kód z entit do servisní vrstvy, ale přenést ho z místa, kam IMHO nepatří (z logiky hráčů a věcí), na místo, kam patří (do logiky prodejů, ať už je prodej realizován jako entita či jinak).
Vtip je trochu v tom, že to, co my zde honosně nazýváme prodejem, je jen změnou stavu obousměrné asociace mezi dvěma třídami. Kdyby ta prodej obnášela další logiku, pravděpodobně by i můj návrh vypadal jinak.
A přijde mi strašně neohrabané vyžadovat kvůli změně stavu takové asociace třetí třídu. Považuji za suboptimální vynést správu té asociace mimo ty dvě třídy, kterých se týká. To totiž v praxi nemá vůbec žádné výhody. Rozhodně bych to nenazval „naplněním SRP“. Spíš bych to nazval oddělením chování od dat, takže zahozením řady výhod plynoucích z OOP.
Když se chce, rozbít jde cokoli.
Já ale řeším situaci, kdy se nechce rozbíjet. Pak je fakt, že některá řešení se omylem rozbijí snáze, než jiná. Stojím si za tím, že mé řešení je hodně blbuvzdorné a věřím, že v praxi by s ním bylo minimum problémů.
Mimochodem různé pojistky podobné tvému
doInTransaction
by v řešení s třídouSale
jistě mohly být také.
Nevím ale, zda něco jakoisInTransaction
do entitní třídyItem
z hlediska SRP patří, neboť zodpovědností entityItem
jistě nejsou nějaké transakce, o kterýchItem
vůbec nic neví (transakce probíhají nad ní). Spíše mi to připadá jako takový hack (to, co má být nad, je přesunuto dovnitř), který sice někdy může být užitečný, ale bál bych se ho využívat ve velkém. Protože, když takovou metodu může mítItem
, proč ne iPlayer
a všechny ostatní entity, a kdo si pak má pamatovat, kdo musí být při jaké operaci v transakci, a kdo naopak ne. Půlka kódu v entitách by pak také mohla být o tom, zda to či ono je v transakci. Pak bychom pro to mohli ještě vynalézt nový pojem, např. transaction hell.
Vtip je v tom, že v praxi žádný takový transaction hell nevznikne. Já jsem si v tom svém zadání trochu naběhl, protože jsem si zadal správu obousměrné asociace s podmínkou, že předmět vždy musí mít nějakého majitele, což je velmi okrajové zadání. Při změně stavu takové asociace prostě nejde provést všechny potřebné změny v jednom kroku a vede to k dané transakci. Potřeba takové transakce pro mě ale stále ještě není důvodem, abych tu správu vynesl ven z těch entit.
Proč by takovou metodu měl mít i Player
? Nerozumím, k čemu
by mu byla dobrá.
IMHO je lepší takovéto chyby řešit testováním. Mám za to, že mít o něco více nerozbitný model za cenu znepřehlednění kódu oním transaction hell nestojí. Ale třeba se pletu…
IMHO testování nenahradí dobře zapouzdřený model. Transaction hell viz výše. :)
Ale změna vlastníka se nemusí dělat přes třídu
Sale
, nýbrž se má dělat metodouItem::setOwner
. Ano, není to zabezpečené z hlediska konzistence. Pokud bych potřeboval zajištění konzistence, asi bych si to zapouzdřil do třídyItemTransfer
(Sale
by ji mohla rozšiřovat). A ano, pak bych si musel pamatovat, že pokud chci praktikovat bezpečnou změnu vlastníka, je třeba ji dělat přes tříduItemTransfer
. :-)
No jasně, pořád prostě narážíš na problémy, na které sis zadělal, když si vynesl tu správu ven mimo ty entity. ;)
Doporučuji zagooglit na téma výhody respektování single reponsibility principu.
Ale mohu něco ocitovat:
- Code complexity is reduced by being more explicit and straightforward
- Readability is greatly improved
- Coupling is generally reduced
- Your code has a better chance of cleanly evolving
Těšil jsem se, co mi na tuhle otázku odpovíš, a zamrzelo mě, že to spadlo do takovéto nic neříkající abstraktní roviny, totiž do „fallbacku porušuješ SRP“.
Abych pravdu řekl, tak:
- Nemyslím si, že by komplexita mé ukázky byla nějak nezdravá.
- Nemyslím si, že by má ukázka byla špatně čitelná (až do té transakce je IMHO perfektně čitelná).
- Rozodně si nemyslím, že by má ukázka byla příliš provázaná. Co je tam příliš provázané?
- Myslím si, že ten můj kód má slušnou šanci, že by se čistě rozvíjel :)
Konkrétní výhodu jsem se bohužel nedozvěděl vůbec žádnou…
Editoval Tharos (27. 2. 2015 2:08)
- Tharos
- Člen | 1030
@duke Btw, mám trochu dojem, že ses tak trochu upnul na tu transakci a na představu jakéhosi transaction hell.
Ještě jsem tedy pro Tebe tu ukázku z kroku 3 zrefaktoroval, aby tam nebyla o žádné transakci výslovná zmíňka a vůbec jsem to celé ještě trochu zjednodušil (při zachování úplně stejných kvalit co se zajištění konzistence týče):
<?php
class Player
{
/**
* @var string
*/
private $id;
/**
* @var Item[]
*/
private $items = [];
/**
* @param string $id
*/
public function __construct($id)
{
$this->id = $id;
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function giveItem(Item $item)
{
if (!$item->isReleased()) {
throw new InvalidArgumentException("Cannot give item that isn't released.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
throw new InvalidArgumentException("Player doesn't own requested item.");
}
unset($this->items[$index]);
}
/**
* @param Item $item
* @throws InvalidArgumentException
*/
public function receiveItem(Item $item)
{
if (!$item->isReleased()) {
throw new InvalidArgumentException("Cannot receive item that isn't released.");
}
$index = array_search($item, $this->items, TRUE);
if ($index === FALSE) {
$this->items[] = $item;
}
}
}
class Item
{
/**
* @var int
*/
private $id;
/**
* @var Player
*/
private $owner;
/**
* @var bool
*/
private $isReleased = TRUE;
/**
* @param int $id
* @param Player $owner
*/
public function __construct($id, Player $owner)
{
$this->id = $id;
$this->owner = $owner;
$owner->receiveItem($this);
$this->isReleased = FALSE;
}
/**
* @param Player $newOwner
*/
public function changeOwner(Player $newOwner)
{
if ($this->owner !== $newOwner) {
$this->isReleased = TRUE;
$this->owner->giveItem($this);
$this->owner = $newOwner;
$this->owner->receiveItem($this);
$this->isReleased = FALSE;
}
}
/**
* @return bool
*/
public function isReleased()
{
return $this->isReleased;
}
}
?>
Věřím, že teď je to opět perfektně čitelné. Ono to vůbec čerpá všechny výhody, které vyplývají z dodržování SRP, protože nevím, čím by to mělo SRP porušovat. :)
Editoval Tharos (27. 2. 2015 2:48)
- duke
- Člen | 650
Asi bys to mohl ještě více zpřehlednit, kdybys povolil NULL u ownera
itemu a místo $this->isReleased
prostě pracoval s
$this->owner === NULL
…
Nicméně jádro problému je o tom, zda logika změny vlastnictví věci je
něco, co se týká jen věci samotné a měla by to tudíž řešit věc sama,
či zda je to něco, co se týká také jejího okolí (v tomto případě
vlastníka), a mělo by to být tudíž řešeno někde vně. V mém
příkladě je tím vnějším místem třída (entita) Sale
či
ItemTransfer
. Dle mého názoru prostě není zodpovědností
entity Item
řešit konzistenci svého vlastníka, nýbrž jen svou
vlastní. O konzistenci transferu věci (tj. všeho, co mu podléhá, tj. věci
a vlastníka) ať se postará ItemTransfer
.
Tvé řešení jistě také bude fungovat, jen budeš mít vnější logiku
nějak asymetricky rozdělenou uvnitř relevantních entit (v diskutovaném
případě je asymetrie dána také vztahem 1:N, což tě patrně vedlo k Tvé
volbě, nicméně představ si tentýž problém u vztahu 1:1 – která
entita by pak volala isReleased? – tam bys musel volit naprosto libovolně).
Budeš pak chtít opravit něco, co souvisí se změnou vlastníka, a budeš se
muset ptát: dal jsem to do Itemu nebo do Playera? Já tuto otázku řešit
nebudu, protože tu logiku nebudu mít na dvou místech a v entitách
Item
a Player
budu mít jen logiku týkající se jich
samotných.
Budu chtít např. nějak omezit onu změnu vlastnictví podle zadaných
pravidel (např. podle času, původního vlastníka, nového vlastníka,
vlastností itemu), a budu vědět, že musím řešit
ItemTransfer
. Ty budeš muset prostudovat implementace obou entit
Item
a Player
a podle toho, pro jakou asymetrii ses
dříve rozhodl, budeš muset obě entity odpovídajícím způsobem (opět
asymetricky) rozšířit.
Vlastně by se dalo říci, že Tvé řešení je tendenční, neboť nadřazuje Item Playerovi. Stejně tak ses mohl rozhodnout obráceně. Mé řešení je naproti tomu naprosto nestranné. :-)
- Tharos
- Člen | 1030
duke napsal(a):
Asi bys to mohl ještě více zpřehlednit, kdybys povolil NULL u ownera itemu a místo
$this->isReleased
prostě pracoval s$this->owner === NULL
…
To vím, ale tím bych výrazně změnil zadání. :) A celou věc zjednodušil, což jsem záměrně nechtěl.
Vedlo by to pak třeba i k nullable FK v databázi a pokud by například předmět bez vlastníka byl z doménového pohledu nesmysl, zbytečně by sis vytvořil prostor, kde by Ti mohl vznikat nepořádek v datech.
Nicméně jádro problému je o tom, zda logika změny vlastnictví věci je něco, co se týká jen věci samotné a měla by to tudíž řešit věc sama, či zda je to něco, co se týká také jejího okolí (v tomto případě vlastníka), a mělo by to být tudíž řešeno někde vně. V mém příkladě je tím vnějším místem třída (entita)
Sale
čiItemTransfer
. Dle mého názoru prostě není zodpovědností entityItem
řešit konzistenci svého vlastníka, nýbrž jen svou vlastní. O konzistenci transferu věci (tj. všeho, co mu podléhá, tj. věci a vlastníka) ať se postaráItemTransfer
.
To už je na uvážení každého. Nicméně pokud pracuješ například s Doktrínou, předpokládá se spíše to, že obousměrné asociace a jejich konzistenci (rozuměj konzistenci vlastnící a inverzní strany) si entity interně spravují samy (prostě viz zapouzdření). Můžeš se podívat třeba přímo do tutoriálu k Doctrine:
class Bug
{
// ...
public function setEngineer($engineer)
{
$engineer->assignedToBug($this);
$this->engineer = $engineer;
}
Tvé řešení jistě také bude fungovat, jen budeš mít vnější logiku nějak asymetricky rozdělenou uvnitř relevantních entit (v diskutovaném případě je asymetrie dána také vztahem 1:N, což tě patrně vedlo k Tvé volbě, nicméně představ si tentýž problém u vztahu 1:1 – která entita by pak volala isReleased? – tam bys musel volit naprosto libovolně). Budeš pak chtít opravit něco, co souvisí se změnou vlastníka, a budeš se muset ptát: dal jsem to do Itemu nebo do Playera? Já tuto otázku řešit nebudu, protože tu logiku nebudu mít na dvou místech a v entitách
Item
aPlayer
budu mít jen logiku týkající se jich samotných.
Říká se tomu vlastnící a inverzní strana asociace. Existují pravidla a vodítka, podle čeho je u jednotlivých typů asociací určit.
Vlastně by se dalo říci, že Tvé řešení je tendenční, neboť nadřazuje Item Playerovi. Stejně tak ses mohl rozhodnout obráceně. Mé řešení je naproti tomu naprosto nestranné. :-)
V mém řešení je prostě jen vlastnící stranou té asociace předmět. To není nic tendenčního. :)
Tvé řešení je možná nestranné /ať to v praxi znamená cokoliv :)/, nicméně konzistenci stavu asociace mezi dvěma entitami řešíš mimo ty entity.
Editoval Tharos (27. 2. 2015 3:35)
- Filip Klimeš
- Nette Blogger | 156
Tohle je asi nejvíc gentlemanská diskuse dvou programátorů, co jsem viděl :) tleskám.
Doporučuju přečíst Anemic domain model od Fowlera. Řeší velmi podobné věci.
Editoval Filip Klimeš (27. 2. 2015 8:34)
- Tharos
- Člen | 1030
Magnus napsal(a):
Použijeme databázi MySQL, ve které budou tabulky
player
(id, name),item
(id, ownerId, place (kde předmět je – inventář, tržiště, …)) amessage
(id, recipientId, content).
Cílem je, že:
- Hráč na stránce klikne na odkaz „koupit předmět“
- Zpracuje se signál pomocí metody handleBuyItem()
- Upraví se řádek v tabulce
item
(změní se ownerId a place (z tržiště na inventář), nebudeme teď řešit, zda má hráč dostatek peněz apod.)- Oběma hráčům se odešle zpráva (2× insert do tabulky
message
)
@Magnus: Až budu mít chvilku, rád Ti tohle celé pro inspiraci naimplementuji (s použitím Nette, Kdyby\Doctrine a Kdyby\Events). Jen prosím o chvilku strpení. Strávil jsem v tomhle vlákně nakonec o něco víc času, než jsem plánoval, a teď mi trochu chybí jinde. :)
Editoval Tharos (27. 2. 2015 14:40)
- Magnus
- Člen | 65
Koukám, že je toto hodně diskutabilní téma a zřejmě neexistuje jedno
100% správné řešení.
Děkuji všem, kteří se na této diskusi podílí a doufám, že sem ještě
někdo přidá svůj názor a své zkušenosti :)
@Tharos: Pokud ten příklad uděláš, budu moc rád! Jen si nejsem jistý, zda tomu s Doctrine porozumím. Bude to velká změna oproti tomu, kdyby se pro získávání dat z databáze používaly klasické DB dotazy? Ať pomocí N\DB nebo ručně psané (ty používám i teď, protože automatizace není dokonalá a některé dotazy by se psaly složitě).
- duke
- Člen | 650
@Tharos Beru na vědomí, že v Doktríně je toto řešení
běžné. Stále ale nejsem přesvědčen o tom, zda je to z hlediska SRP
správně (Item
prostě jednak řeší své vnitřní chování a
nad to ještě zajišťuje synchronizaci s Playery
, a to chápu
jako 2 různé zodpovědnosti). Nicméně chápu, že toto řešení má
i své výhody (zejména odolnost proti rozbití konzistence nevhodným
použitím), takže jej zcela nezatracuji. :-)
Tou tendenčností jsem měl na mysli to, že když se řeší synchronizace
obousměrné vazby, je třeba si vybrat jednu stranu, která tu synchronizaci
řídí (vlastnící strana). Chápu, že Doctrine na to má své konvence, ale
např. pro vazbu M:N uvádí: „You can pick the owning side of a
many-to-many association yourself.“
Tvé řešení je možná nestranné /ať to v praxi znamená cokoliv :)/, nicméně konzistenci stavu asociace mezi dvěma entitami řešíš mimo ty entity.
Ano, protože i ten jejich vztah nechápu jako něco, co je vlastněno jednou či druhou stranou, nýbrž jako něco, co je mezi nimi a je jaksi sdíleno. Snažím se prostě řešit problém tam, kde se nachází a ne jinde. Tj. problém konzistence(synchronizace) stavu obousměrné asociace mezi dvěma entitami vnímám jako problém celého modelu (minimálně modelu těchto dvou entit) a ne jako vnitřní problém těchto entit. Možná si to tím na jedné straně komplikuju, ale zase nijak strukturu modelu nedeformuji.
Každopádně díky za zajímavou diskusi.
@FilipKlimeš Nesnažím se propagovat anemický model. Jsem pro, aby logika byla řešena v entitách, ale jen jim podřízená logika, nikoli logika, která se týká (také) jejich okolí. Cokoli vnějšího by mělo být řešeno spíše přes systém událostí.
- Filip Klimeš
- Nette Blogger | 156
duke napsal(a):
@FilipKlimeš Nesnažím se propagovat anemický model. Jsem pro, aby logika byla řešena v entitách, ale jen jim podřízená logika, nikoli logika, která se týká (také) jejich okolí. Cokoli vnějšího by mělo být řešeno spíše přes systém událostí.
Omlouvám se, nenapsal jsem ten příspěvek úplně šťastně. Nebyl reakcí na Tvůj názor, ale celkovým obhájením přesunu logiky do entit, tak jak to radí @Tharos.
Editoval Filip Klimeš (27. 2. 2015 20:16)
- LuBoss
- Člen | 21
Za sebe (z pohledu méně zkušeného programátora) dávám palec nahoru upravenému řešení od @Tharos a to ze dvou čistě praktických důvodů:
- Je to přehledné, logické a hlavně jednoduché na pochopení včetně blbuvzdorného příznaku isReleased
- Když obsluhu vzájemných stavů mezi třídami (přesun předmětu mezi hráči) nenajdu v první třídě, tak ji prostě najdu v té druhé a nemusím přemýšlet, jak byla před pěti roky pojmenována nějaká třetí a že vůbec existuje
- Tharos
- Člen | 1030
Ahoj,
tak jak jsem slíbil, připravil jsem kompletní funkční ukázku:
Dovolil jsem si ale přece jen trošku rozšířit zadání, a to alespoň o placení za obchodované věci. Nikoliv proto, že by původní zadání byla „úplná nuda“, ale trivální bylo a přece jen by v něm nebylo možné ukázat jistou eleganci, s jakou demonstrovaný přístup dokáže pojmout netriviální doménovou logiku. V ukázce si pochopitelně entity hlídají, zda kupující má pro nákup předmětu dostatek peněz atp.
EntityListeners
, které v ukázce používám (nakonec se to
úplně obešlo bez Kdyby\Events), jsou v Doctrine až od verze
2.4. Důležitá poznámka je, že listenery jsou v té mé ukázce
automaticky navázané pouze na entity, které vyrobí
EntityManager
. Pokud bychom potřebovali mít navázané listenery
i na ručně vytvořených entitách, musela by se ještě přidat nějaká
factory, která by po vytvoření vlastní instance entity pomocí
ItemEventsInitializer
navázala i požadované listenery na
eventy.
Také jsem ukázce dal nějaké elementární UI, aby to „šlo hrát“. :)
Rád k ukázce zodpovídám jakékoliv dotazy, ať už praktické či návrhové. Za návrhem si dost stojím. Mám zkušenost, že přesně takový vede k aplikaci, kterou je radost dále rozvíjet, snese nemalý nárůst komplexity logiky a není by-design náchylná na chyby.
Editoval Tharos (7. 3. 2015 0:55)
- Filip Procházka
- Moderator | 4668
@Tharos nepřijde mi vhodné vypínat auto generování proxy tříd, kdyby/doctrine to nastavuje imho velice příčetně. Pokud to chceš mít natvrdo vyplé, měl bys alespoň zmínit že se ty proxy třídy mají vygenerovat commandem (což je ale imho zbytečná komplikace a jednodušší je prostě nechat to ve výchozí autodetekci).
Moc se mi ale nelíbí řešení s isReleased
. Chybou
v implementaci modelu (někdo do toho bude rejpat) to můžeš dostat do
nekonzistentního stavu.
Jedním řešením může být například, že vůbec nepoužiješ
obousměrnou vazbu. Díky tomu půjde drasticky zjednodušit
metoda changeOwner
public function changeOwner(Player $player)
{
$this->owner = $player;
}
Jediná nevýhoda tohohle přístupu dříve byla, že když jsi psal DQL, tak doctrina ti nedovolila s tím pořádně pracovat. Dnes už ale můžeš v pohodě filtrovat i pomocí entit na které nemáš relaci.
$em->createQueryBuilder()
->select('p')->from(Player::class)
->innerJoin(Item::class, 'i', Join::WITH, 'i.owner = p')
// ...
Připravíš se tím o možnost zavolat $player->getItems()
,
ale pořád nad tím můžeš přemýšlet jakože máš někde pole těch
věcí a vždy si je vyfiltruješ podle ownera
$items = $itemsRepository->findBy(['owner' => $player]);
a to že ti je filtruje repozitář už je implementační detail, protože stejně dobře můžeš udělat
$allItems = new ArrayCollection([
(new Item)->changeOwner($player1),
(new Item)->changeOwner($player1),
(new Item)->changeOwner($player1),
(new Item)->changeOwner($player2),
]);
$items = $allItems->filter(function ($i) use ($player1) {
return $i->owner === $player1;
});
// nebo
$items = $allItems->matching(
Criteria::create()->andWhere(Criteria::expr()->eq('owner', $player))
);
- Tharos
- Člen | 1030
Filip Procházka napsal(a):
@Tharos nepřijde mi vhodné vypínat auto generování proxy tříd, kdyby/doctrine to nastavuje imho velice příčetně. Pokud to chceš mít natvrdo vyplé, měl bys alespoň zmínit že se ty proxy třídy mají vygenerovat commandem (což je ale imho zbytečná komplikace a jednodušší je prostě nechat to ve výchozí autodetekci).
Good point, odebral jsem z ukázkového configu. Díky!
Moc se mi ale nelíbí řešení s
isReleased
. Chybou v implementaci modelu (někdo do toho bude rejpat) to můžeš dostat do nekonzistentního stavu.Jedním řešením může být například, že vůbec nepoužiješ obousměrnou vazbu. Díky tomu půjde drasticky zjednodušit metoda
changeOwner
Toho si jsem velmi dobře vědom a už se to tu i probíralo. :) Také by to například hodně zjednodušilo, kdybych v doméně umožnil, že předmět nemusí mít žádného majitele. Ale pointou je, že já jsem si pro demonstraci vybral asi nejkomplikovanější možné zadání záměrně.
V praxi totiž narazíš na to, že mít obousměrnou asociaci je prostě
občas výhodné a dává to dobrý smysl. Zároveň narazíš snadno
i na to, že se Ti její hodnota bude moci měnit v čase a nebudeš ji chtít
mít nullable
. Tuhle kombinaci naimplementovat není úplně
jednoduché, ale fígl isReleased
alespoň v rámci
možností řeší nerozbitnost takového modelu.
Určitě by šlo implementaci zjednodušit, ale už ruku v ruce se zjednodušením zadání. No a pro ukázkový případ je přece fajn vybrat si něco náročného, protože to je to pověsné těžko na cvičišti. :)
Dokonce bych i řekl, že ta ukázka vhodně demonstruje, proč se (mimo jiné) doporučuje obousměrným asociacím úplně vyhnout, pokud je to možné. Totiž že nejde jenom o problém s performance.
Editoval Tharos (7. 3. 2015 11:59)
- Tharos
- Člen | 1030
Abych byl upřímný, tak s ohledem na to, co se pravděpodobně chystáš vyvíjet, bych Ti doporučoval Doctrine. Lean Mapper by Tě u aplikace typu „RPG“ mohl příliš omezovat v rozletu.
Pokud jsi na něj ještě nenarazil, doporučuji Ti pročíst i tohle vlákno:
https://forum.nette.org/…-vs-doctrine
Pokud se rozhodneš pro Doctrine a budeš z ní chtít vytěžit maximum, zaměř se i na oblast objektově orientovaného návrhu. Ultra Ti doporučuji tenhle seriál (tuším) od @mystik:
http://www.zdrojak.cz/…neho-navrhu/
Dále Ti doporučuji v podstatě libovolné čtení od Martina Fowlera, který je zkušený a své názory obhajuje argumenty, nikoliv autoritou:
http://martinfowler.com/bliki/
http://martinfowler.com/articles.html
V podstatě povinnou četbou je zde již jednou odkázaný článek o anemickém doménovém modelu:
http://martinfowler.com/…inModel.html
No a na druhou stranu bych Ti nedoporučoval tento přístup (a to není absolutně nic proti jeho autorovi!):
http://www.phpguru.cz/…rstev-modelu
A to proto, protože ten vyloženě vede k anemickému modelu. Já jej navíc považuji za obzvláště zákeřný v tom, že je napsaný velmi profesionálně (není divu, viz autor), přesvědčivě a je obtížné prohlédnout, že popsaný přístup je prostě sub-optimální.
Editoval Tharos (7. 3. 2015 23:09)
- Tharos
- Člen | 1030
RedBeanPHP… Hmm, přemýšlím, jak bych to řekl taktně ;).
Nebrat. Tak jako řadu dalších obdobných „minimalistických“ ORM knihoven (Idiorm, Eloquent, je jich neskutečně hodně…). Některé z nich jsou vysloveně snůška bad practices. Než je, to už by Ti pravděpodobně udělal lepší službu Lean Mapper, který je sice svérázný, ale aspoň to není nějaké static hell.
S ohledem na to, co zjevně programuješ, se vážně zkus podívat na Doktrínu. Pročti si tutoriál, dokumentaci (vím, je velmi obsáhlá, ale tak aspoň hlavní části). Taky se můžeš podívat třeba na tuhle aplikaci. Nějaký čas by Ti také mohla ušetřit tato kniha, asi není špatná.
Editoval Tharos (9. 3. 2015 12:30)
- Magnus
- Člen | 65
Zkusím se tedy podívat na Doctrine 2. Projekt dělám už docela dlouho a je tam napsaných spousta řádků a nezbývá mi příliš času na dokončení. Teď nevím, zda to zkusit přepsat a dokončit s Doctrine (určitě je riziko, že bych si to ještě víc rozbil :D, a s tím časem to opravu nevidím moc dobře), nebo dokončit a případně to zkusit přepsat, až to bude hotové.
- Tharos
- Člen | 1030
No to víš, to je otázka. :) Ale podle toho, co píšeš, bych Ti spíše doporučit „přepsání“, až to bude hotové.
Křivka učení Doctrine není z těch nejstrmějších, to je tak nějak známá věc… Takže by sis mohl docela naběhnout.
Přepsání píšu v uvzovkách, protože Ty to můžeš pojmout jako následný průběžný refaktoring. Nemusíš to přepisovat od nuly. Aspoň se při tom pocvičíš i v refaktoringu. ;)
Například celé DámeJídlo.cz běželo původně nad Dibi modely a postupně se zmigrovalo na Doctrine.
Editoval Tharos (10. 3. 2015 0:36)
- Magnus
- Člen | 65
Ještě jsem narazil na článek architektury nad Doctrine 2:
http://www.zdrojak.cz/…-doctrine-2/
Je to staré cca 3 roky, jak je to aktuálně? Myslím, že článek chápu,
nicméně je to na způsob, který @Tharos o pár příspěvků výše
nedoporučuje.
V PHP dělám už nějakou dobu, ale v tomhle jsem v podstatě stále
začátečník. Je velmi obtížné zvolit si způsob. Jsem proto moc rád za
každý příspěvek, který sem někdo napíše!
- Tharos
- Člen | 1030
Ten článek na Zdrojáku je dobrý, z mého pohledu rozhodně lepší, než ten pětivrstvý model od Honzy Tichého. Na rozdíl od něj se totiž problémem anemického modelu alespoň okrajově zabývá.
Častým problémem těchto článků je, že se v nich řeší tak triviální zadání (blog za 5 minut, což je nejjednodušší možný CRUD), že tam z podstaty věci není možné ukázat nic pokročilejšího z objektového návrhu, protože to tam prostě není zapotřebí.
Vím, že začátečník potřebuje alespoň nějaké opěrné body. Představ si třeba krajní extrémy a zaměř se na to, proč jsou nevýhodné.
Pokud bys veškerou logiku řešil v entitách, stávaly by se Ti z nich snadno „God objekty“, určitě by měly spoustu zodpovědností a hlavně by musely vědět o vrstvách a částech aplikace, o kterých je zbytečné, aby vůbec věděly (prezentační vrstva, persistentní vrstva…). Zkrátka měl bys nezdravě vysokou míru provázanosti systému. To typicky zhoršuje refaktoring, ale i třeba znovupoužitelnost jednotlivých částí systému. Zkrátka přináší to samé nevýhody. Také bys narazil na potřebu injektovat do entit řadu služeb, což je skoro vždycky code smell.
Opačný krajní extrém je anemický model. Z entit si uděláš jen
přepravky na data a veškerý „business“ (OK, s výjimkou pár validací)
řešíš ve službách, které s těmi entitami pracují. Jak již bylo
řečeno, tohle je sice funkční, ale suboptimální. V podstatě to jde proti
jedné ze základích myšlenek OOP (spojit v objektech data a chování) a Ty
tak z OOP netěžíš naplno jeho výhody. Je to takový odklon
k procedurálnímu programování. Občas tomu někdo říká pejorativně
třídní programování, čímž má na myslí, že sice používáš
ve svých zdrojácích klíčové slovo class
, ale neprogramuješ
objektově. :) Tenhle styl má spoustu symptomů. Například to, že úplně na
každou property v entitách potřebuješ getter a setter. To je logické,
protože když ono chování přesouváš mimo objekty, potřebuješ mimo ně
dostat i jejich stav, aby s ním ta servisní vrstva pak mohla pracovat. Při
čistokrevném OOP naopak zjistíš, že řadu getterů a setterů v public API
objektů vůbec nepotřebuješ (tím Tě ale nechci navádět k dalšímu
extrému, kterým je princip tell, don't ask
).
Jak je asi zřejmé, ideál je někde mezi. Jak rozhodovat, co kam patří? Snaž se umisťovat chování co nejblíže k datům (stavu). Pak bude OOP hrát ve Tvůj prospěch a Tvá aplikace se bude sestávat z hezky zapouzdřených, smysl dávajících objektů. Na druhou stranu si, zejména v entitách, hlídej, aby sis systém příliš neprovazoval. Máš-li například takovou logiku v entitě, která operuje jen nad jejím stavem, je to naprosto OK. Máš-li takovou logiku, která volá metody entit, s nimiž je v asociaci (vzájemném vztahu), je to také OK. To proto, protože některé entity zkrátka tak jako tak ví jedna o druhé a pořád je nejvýhodnější tohle volání provést „pod pokličkou“, než ho vynést úplně mimo ty objekty (viz třeba ta má RPG ukázka). Máš-li takovou logiku, která zpoza entity pracuje se šablonou, už to zavání. Zvaž, jestli je nutné, aby entita vůbec věděla o nějaké prezentační vrstvě a jestli by to celé nešlo vyřešit nějak lépe (pravděpodobně ano).
Dobrým pomocníkem jsou eventy, dokáží to provázání elegantně uvolnit (viz třeba uvolnění vztahu mezi entitami a nějaký messengerem v té RPG ukázce).
Editoval Tharos (17. 3. 2015 9:32)
- Filip Klimeš
- Nette Blogger | 156
@Tharos ještě mě tak napadá, osvědčilo se vám v DJ používat dědičnost v entitách?
- Tharos
- Člen | 1030
@FilipKlimeš U entit ji máme v podstatě jenom na pár místech kvůli single table inheritance.
Dědičnost se nám obecně osvědčuje všude tam, kde jde o specializaci, a hodně s ní bojujeme všude tam, kde jde jen o hack, jak sdílet mezi třídami nějakou funkconalitu. :) Bohužel máme pár takových legacy míst, třeba občas někde „zprasenou“ hierarchii presenterů… V entitách naštěstí ne.
- Magnus
- Člen | 65
@Tharos Jak řešíš práci s uživatelem, když chceš např.
v šabloně zjistit, zda je přihlášen? Používáš k tomu objekt
Nette\Security\User, nebo vytvoříš doctrine entitu User, které předáš
potřebné informace, a tu poté v šabloně používáš?
Pokud chápu správně, pak by entita User neměla být jen přepravka pro data,
ale měla umět i něco provádět. Tudíž mi přijde vhodné vytvořit
entitu, kterou předám šabloně – ne pouze objekt, který v presenteru
automaticky vytváří Nette. V takovém případě jí ale musím předat
navíc informace, které se neukládají do/netahají z databáze – právě
třeba informaci o tom, zda je uživatel přihlášen.
Je tento postup správný, nebo se to dělá jinak?
- Jan Endel
- Člen | 1016
Proč chceš znásilňovat entitu user pro něco, na co není určena (zjišťování jestli je uživatel přihlášen)?
Nemusí být jenom hloupá přepravka na data, ale měl by mít spíše něco takového:
class User
{
private $roles = [];
public function __construct($roles)
{
$this->roles = $roles;
}
public function isAdmin()
{
return in_array("admin", $this->roles, TRUE);
}
}
- Šaman
- Člen | 2659
IMHO to, že šablona (a presenter) už automaticky obsahuje proměnnou
$user
je jeden z nejvíc WTF momentů Nette. Je to název
důležité entity každého většího systému s jakýmkoliv ORM a zdroj
chyb typu:
{foreach $users as $user}
… <!-- výpis stejný, jako u všech ostatních entit - nic podezřelého -->
{/foreach}
…
{if $user->isLoggedIn()} <!-- WTF? proč to nefunguje jako všude jinde? -->
Editoval Šaman (22. 3. 2015 22:47)
- Tharos
- Člen | 1030
@Magnus Je dobré uvědomit si rozdíl mezi třídou
Nette\Security\User
a nějakou Tvou Doctrine entitou
Application\Users\User
.
Ta třída z Nette reprezentuje uživatele, který sedí někde opodál ve vodách Internetu a Ty sním udržuješ session. Pro Tebe může být autentizovaný, tj. víš, o koho jde, anebo neautentizovaný. To vše Ti řeší Nette a jako bonus Ti přidává i jednoduchý management rolí a session-based úložiště dalších informací o tom uživateli.
Ta Tvá entita bude typicky reprezentovat nějaký uživatelský účet ve Tvé aplikaci.
Důležité je, že tyto dvě třídy se nerovnají, i když spolu dost
úzce souvisí. Souvisí proto, protože běžně budeš mít pro
autentizovaného „Nette uživatele“ k dispozici někde v databázi
odpovídající uživatelský účet. Pro lepší uchopení je skoro
výhodnější uvažovat o těch třídách jako o
Nette\Security\User
a o nějaké
Application\Users\UserAccount
. :) Tobě se pak typicky hodí mít
možnost z té první třídy snadno získat i odpovídající instanci té
druhé třídy. Jak toho lze elegantně docílit se můžeš podívat do již
odkazovaného Filipovo Archivistu, konkrétně na tuhle
(to je upravená Nette\Security\User
, určitě se podívej na
getUserEntity
), tuhle
(to je ten uživatelský účet) a tuhle
třídu (to je vlastní implementace IAuthenticator
). Filip má to
řešení poměrně komplexní, pro základní představu by se Ti asi hodilo
něco ještě očesanějšího, ale věřím, že to z toho pochopíš. :)
Tohle je osvědčený způsob, jak tenhle problém pojmout a vyřešit. Věřím, že existují i nějaká alternativní a také dobře funkční řešení.
Do jaké z těch tříd si pak umístíš jakou logiku už je na Tobě. Do
vlastní Nette\Security\User
bych osobně dával například
ACL-related věci, do Application\Users\UserAccount
věcí, které
nějak souvisí s uživatelským účtem.
Editoval Tharos (23. 3. 2015 21:11)