Závislost jednoho modelu na jiných

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

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
+
+27
-

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
+
+3
-

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)

Tharos
Člen | 1030
+
+3
-

Rozděleno do dvou příspěvků kvůli zhůvěřilému počtu znaků :)

Magnus
Člen | 65
+
0
-

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:

  1. 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.
  2. 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:

  1. Hráč na stránce klikne na odkaz „koupit předmět“
  2. Zpracuje se signál pomocí metody handleBuyItem()
  3. 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.)
  4. 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
+
0
-

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
+
0
-

@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
+
+2
-

@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
+
0
-

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:

  1. v entitách
  2. 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
+
+1
-

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ří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.

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 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. :-)

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
+
+7
-

@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
+
0
-

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
+
0
-

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 č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.

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 a Player 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
+
+9
-

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
+
+3
-

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ě, …)) a message (id, recipientId, content).
Cílem je, že:

  1. Hráč na stránce klikne na odkaz „koupit předmět“
  2. Zpracuje se signál pomocí metody handleBuyItem()
  3. 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.)
  4. 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
+
0
-

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
+
0
-

@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
+
0
-

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
+
0
-

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
+
+11
-

Ahoj,

tak jak jsem slíbil, připravil jsem kompletní funkční ukázku:

https://github.com/Tharos/RPG

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
+
+2
-

@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
+
0
-

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)

Magnus
Člen | 65
+
0
-

Moc pěkná ukázka, Tharosi, moc díky. Asi se kouknu po nějakém ORM, možná Doctrine, nebo tvůj Lean mapper bude pro začátek lepší, co myslíš? Pokud je stále aktuální.

Tharos
Člen | 1030
+
+4
-

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)

Magnus
Člen | 65
+
0
-

Děkuji za odkazy. Asi se pokusím podívat na Doctrine, ale určitě to pro začátek jednoduché nebude. Máš nějakou zkušenost s RedBeanPHP?

Tharos
Člen | 1030
+
+3
-

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
+
0
-

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
+
+1
-

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
+
0
-

A mohl bys mi, nebo kdokoliv jiný, ještě poradit, v čem dělají začátečníci v Doctrine nejčastěji chyby? Jinými slovy, na co si v začátcích dát pozor?

Jinak moc děkuji za všechny rady!

Magnus
Člen | 65
+
0
-

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
+
+4
-

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
+
0
-

@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
+
0
-

@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
+
0
-

@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
+
0
-

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);
	}
}
Magnus
Člen | 65
+
0
-

V podstatě jen proto, abych nemusel šabloně předávat další proměnnou $isLoggedIn. Jen mě zajímalo, zda se to do entity nacpat může, nebo to není správné řešení.
Díky za odpověď.

bazo
Člen | 620
+
+1
-

ved sablona uz obsahuje Nette\Security\User $user premennu. tak ju pouzi. a nevymyslaj sialenosti

Magnus
Člen | 65
+
0
-

Jen se ptám, co se do entity dát může a co ne. O šílenostech mi nemluv.
Pokud je $user výchozí proměnná šablon (stejně jako třeba $basePath), pak pardon, moje neznalost. Nicméně to moc vliv na předchozí otázku nemá.

Editoval Magnus (22. 3. 2015 0:24)

Šaman
Člen | 2634
+
+4
-

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
+
+4
-

@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)

Magnus
Člen | 65
+
0
-

@Tharos Super vysvětlení, jako vždy! Moc děkuji