Co je to vlastně ten Model? To je přeci nějaká třída, která pracuje s databází. Nebo ne? Cest, jak si napsat model, je hodně. Ale jak to napsat, abych mohl snadno měnit databáze (to je tak blbý argument, kdo z Vás každý týden mění databázi?), abych mohl snadno přidávat funkčnost a abych tím, pokud možno, nic nebořil a nemusel přepisovat už napsaný kód, když chci přidat funkci, která se klientovi zdá jako "detail".
Předem bych chtěl upozornit na článek Pět vrstev modelu, aby jste mi věřili, že si to nevymýšlím :)
Vzorově po vzoru
Entity
Je třeba si napsat entitu, třeba nějakou jednoduchou.
class ShoppingCartItem
{
private $id; // nastavovat v mapperu reflexí, aby "nešlo" změnit
public $name; // jméno položky
public $cost; // kolik stojí
// .. další vlastnosti
public function getId()
{
return $this->id;
}
}
Entita by měla být vyloženě hloupoučká, je to jenom obálka na data, načtená z úložiště (databáze, session), jediné feature, které může mít, je že kontroluje typy hodnot, které jí vnucujeme. Třeba pomocí properties.
Víceméně je to něco jako DibiRow (což je chytřejší ValueObject), ale má properties nadefinované tak, aby odrážely strukturu z databáze (né nezbytně, protože entita může být složená i z více tabulek v DB).
Zpětně s tímto tvrzením nesouhlasím. To co zde popisuji se jmenuje Anémický Model a je po považováno za anti-pattern. Entity můžou obsahovat složitější logiku a dokonce by měly.
Repository
Repozitář by měl umět pracovat s daným interface mapperu.
class ShoppingCartRepository extends Nette\Object
{
private $mapper;
public function __construct(IShoppingCartMapper $mapper)
{
$this->mapper = $mapper
}
public function save(ShoppingCartItem $item)
{
// pokud ukládám novou, může mapper automaticky nastavovat ID
$this->mapper->save($item);
return $item;
// a pak můžu s ID pracovat
// $item = $repository->save($item);
// echo $item->getId();
}
public function find($id)
{
return $this->mapper->find($id);
}
// metody jako findByName patří spíše sem
public function findByName($name)
{
return $this->mapper->findBy(array(
'name' => $name
));
}
// další metody
}
Žádná metoda v repozitáři by neměla přistupovat přímo k uložišti, tak by byla existence Data Mapperu, který má právě tomuto zabránit, zbytečná.
Mapper
Je vhodné si nadefinovat základní interface, který by by měly mít všechny mappery. Je to určitá záruka kompatibility a lépe se to testuje (mockovat se mají prý interface a né třídy).
interface IMapper
{
/** uložit entitu, sám si rozhodne, jestli aktualizuje, nebo ukládá novou */
function save($entity);
/** najít entitu s ID */
function find($id);
/** předáš tomu pole hodnot, podle kterých má hledat. Vrátí entity co odpovídají */
function findBy(array $values);
/** předáš tomu pole hodnot, podle kterých má hledat. Vrátí jednu entitu */
function findOneBy(array $values);
/** vrátí všechno */
function findAll();
// popř. si můžeš napsat další funkce, které budou umět nějaké velice specifické funkce
// ale to spíše až v tom konkrétnějším mapperu
}
Potom takový mapper (třeba ShoppingCartDibiMapper) implementuje tento interface. Repozitář už pak jenom pracuje s tímhle mapperem a je mu jedno, jestli to ukládá do databáze (ShoppingCartDibiMapper) nebo někam do session (ShoppingCartSessionMapper).
Protože není možné měnit parametry jednou nadefinované metody v interface, je nutné interface doslova zkopírovat a né dědit. Konkrétně je problémem metoda save a jí podobné, které přijímají jednu entitu. Jinak by program skončil s Compile error.
Jednoduchý mapper by pak mohl vypadat takto:
class ShoppingCartDibiMapper extends Nette\Object implements IShoppingCartMapper
{
private $conn;
public function __construct(DibiConnection $conn)
{
$this->conn = $conn;
}
public function save(ShoppingCartItem $item)
{
if ($item->getId() === NULL) { // insert
$data = $this->itemToData($item); // vytáhne data z entity a vrátí jako pole
$id = $this->conn->insert('shopping_cart', $data)->execute();
$this->setIdentity($item, $id);
} else { // update
$data = $this->itemToData($item); // vytáhne data z entity a vrátí jako pole
// tady se velice hodí logika, která porovná v jakém stavu byla entita při načtení
// a v jakém je teď, aby se nemuselo posílat všechno, ale to jsou hodně pokročílé funkce
// a optimalizace se má dělat až když je potřeba, že :)
$this->conn->update('shopping_cart', $data)
->where('id = %i', $item->getId())->execute();
}
}
public function find($id)
{
$data = $this->conn->select('*')->from('shopping_cart')->where('id = %i', $id)->fetch();
return $this->load($data);
}
public function findAll()
{
return $this->conn->select('*')->from('shopping_cart')->fetchAssoc('id');
}
private function load($data)
{
$item = new ShoppingCartItem;
$this->setIdentity($item, $data->id);
unset($data['id']);
foreach ($data as $prop => $val) {
$item->$prop = $val;
}
return $item;
}
private function setIdentity($item, $id)
{
$ref = Nette\Reflection\ClassReflection($item);
$idProp = $ref->getProperty('id');
$idProp->setAccessible(TRUE);
$idProp->setValue($item, $id);
return $item;
}
}
Tento mapper je opravu hodně jednoduchý, neumí řadit, limity, složitější věci, atd. A tohle je právě docela otrava dělat pro každou entitu, ale je to nejlepší možné řešení, protože je to pak krásné oddělené a moc hezky se s tím pracuje.
Použití
$repository = new ShoppingCartRepository(new ShoppingCartDibiMapper(dibi::getConnection()));
$item = new ShoppingCartItem();
$item->name = "Ponožky";
$item->cost = 20;
$repository->save($item);
echo $item->getId();
Je možné si pak trochu usnadnit práci a repozitář si nadefinovat jako službu, udělat si na něj nějakou továrničku a pracovat s tím zase o něco jednodušeji.
Tenhle přístup používá i Doctrine 2 a o všechny repozitáře a mappery se postará za Vás :)
Někde na půl cesty
Uvedené příklady vůbec neřeší nějaké stránkování, řazení a pokročilejší filtrování. To je funkcionalita, která proti původnímu MVC návrhu vytváří paradox, uvedené řešení z článku, ale není ultimátní.
Takové věci, ale není problém implementovat někde napůl v repozitáři a mapperu. Mapper může poskytovat nějaké interface, kterým si přes repozitář (né však přímo) může programátor takové věci konfigurovat.
public function findBy(array $values, array $orderBy = array(), $limit = 0, $offset = 0)
{
// ...
}
Podle mých zkušeností, většinou nic takového ani není potřeba, prostě se napíše metoda do mapperu, která trošku ohne výsledek jiné Mapper::findByNecoOrderByTamto(), samozřejmě se tím ztěžuje vyměnitelnost takového mapperu.
Částečným řešením je, spojit funkcionalitu repozitáře a mapperu do jednoho složitějšího modelu. Odpadá pak potřeba psát metody jako
public function findByNecoOrderByNeco(array $values)
{
return $this->mapper->findByNecoOrderByNeco($values);
}
Řešení pro dibi s vlastním DataSource
Další, lepší, možností je, udělat si vlastní implementaci pro IDataSource, která nebude obalovat dotazy do subqueries (ach ta MySQL) ale bude pracovat s DibiFluent a zvýší tím výkon v MySQL databázi.
Na získávání více záznamů, by se pak z repozitáře volalo něco jako:
ShoppingCartRepository
public function getDataSource()
{
// konkrétní implementace je nepodstatná, ale mohlo by to být něco jako:
return new LazyCollection($this->mapper, $this->mapper->allDataSource());
// předám mu mapper, aby mohl pro všechny záznamy volat metodu load z mapperu
// a taky dataSource, který ovšem nebude tak docela datasource, ale jenom fluent
}
ShoppingCartDibiMapper
public function allDataSource()
{
return $this->conn->select('*')->from('shopping_cart');
}
Trochu jiný DataSource
LazyCollection pak může vypadat jednoduše takto
class LazyCollection implements IDataSource // z dibi
{
private $mapper;
private $query;
private $limit;
private $offset;
public function __construct(IMapper $mapper, DibiFluent $query)
{
$this->mapper = $mapper;
$this->query = $query;
}
/**
* vyžadováno interfacem IteratorAggregate
* je možné pak procházet jako foreach ($datasource as $row) { ...
*/
public function getIterator()
{
$result = $this->query->getIterator($this->limit, $this->offset);
$data = array();
foreach ($result as $row) {
// vyžaduje load jako veřejnou metodu
$data[$row->id] = $this->mapper->load($row);
}
return new \ArrayIterator($data);
}
public function where($cond)
{
if (func_num_args() > 1) {
$cond = func_get_args();
}
$this->query->where('%ex', (array)$cond);
return $this;
}
public function applyLimit($limit, $offset)
{
$this->limit = $limit;
$this->offset = $offset;
return $this;
}
public function orderBy(array $order)
{
$this->query->orderBy($order);
}
// IDataSource vyžaduje ještě další metody, ale ty pro názornost nejsou nutné
}
Použití 1
Takový datasource se pak nemusím stydět vrátit z repozitáře Presenteru, ten si může vyfiltrovat výsledky pomocí metody where, na základě nějakých filtrů z parametrů, může si seřadit výsledky jak je potřeba a předá pak LazyCollection (DataSource) šabloně.
Co je to vlastně ten Model? To je přeci nějaká třída, která pracuje s databází. Nebo ne? Cest, jak si napsat model, je hodně. Ale jak to napsat, abych mohl snadno měnit databáze (to je tak blbý argument, kdo z Vás každý týden mění databázi?), abych mohl snadno přidávat funkčnost a abych tím, pokud možno, nic nebořil a nemusel přepisovat už napsaný kód, když chci přidat funkci, která se klientovi zdá jako "detail".
Předem bych chtěl upozornit na článek Pět vrstev modelu, aby jste mi věřili, že si to nevymýšlím :)
Vzorově po vzoru
Entity
Je třeba si napsat entitu, třeba nějakou jednoduchou.
*) reflexe Nette\Reflection\ClassType
Entita by měla být vyloženě hloupoučká, je to jenom obálka na data, načtená z úložiště (databáze, session), jediné feature, které může mít, je že kontroluje typy hodnot, které jí vnucujeme. Třeba pomocí properties. Víceméně je to něco jako DibiRow (což je chytřejší ValueObject), ale má properties nadefinované tak, aby odrážely strukturu z databáze (né nezbytně, protože entita může být složená i z více tabulek v DB).
Zpětně s tímto tvrzením nesouhlasím. To co zde popisuji se jmenuje Anémický Model a je po považováno za anti-pattern. Entity můžou obsahovat složitější logiku a dokonce by měly.
Repository
Repozitář by měl umět pracovat s daným interface mapperu.
Žádná metoda v repozitáři by neměla přistupovat přímo k uložišti, tak by byla existence Data Mapperu, který má právě tomuto zabránit, zbytečná.
Mapper
Je vhodné si nadefinovat základní interface, který by by měly mít všechny mappery. Je to určitá záruka kompatibility a lépe se to testuje (mockovat se mají prý interface a né třídy).
Potom takový mapper (třeba
ShoppingCartDibiMapper
) implementuje tento interface. Repozitář už pak jenom pracuje s tímhle mapperem a je mu jedno, jestli to ukládá do databáze (ShoppingCartDibiMapper
) nebo někam do session (ShoppingCartSessionMapper
).Protože není možné měnit parametry jednou nadefinované metody v interface, je nutné interface doslova zkopírovat a né dědit. Konkrétně je problémem metoda save a jí podobné, které přijímají jednu entitu. Jinak by program skončil s
Compile error
.Jednoduchý mapper by pak mohl vypadat takto:
Tento mapper je opravu hodně jednoduchý, neumí řadit, limity, složitější věci, atd. A tohle je právě docela otrava dělat pro každou entitu, ale je to nejlepší možné řešení, protože je to pak krásné oddělené a moc hezky se s tím pracuje.
Použití
Je možné si pak trochu usnadnit práci a repozitář si nadefinovat jako službu, udělat si na něj nějakou továrničku a pracovat s tím zase o něco jednodušeji.
Tenhle přístup používá i Doctrine 2 a o všechny repozitáře a mappery se postará za Vás :)
Někde na půl cesty
Uvedené příklady vůbec neřeší nějaké stránkování, řazení a pokročilejší filtrování. To je funkcionalita, která proti původnímu MVC návrhu vytváří paradox, uvedené řešení z článku, ale není ultimátní.
Takové věci, ale není problém implementovat někde napůl v repozitáři a mapperu. Mapper může poskytovat nějaké interface, kterým si přes repozitář (né však přímo) může programátor takové věci konfigurovat.
Podle mých zkušeností, většinou nic takového ani není potřeba, prostě se napíše metoda do mapperu, která trošku ohne výsledek jiné
Mapper::findByNecoOrderByTamto()
, samozřejmě se tím ztěžuje vyměnitelnost takového mapperu.Částečným řešením je, spojit funkcionalitu repozitáře a mapperu do jednoho složitějšího modelu. Odpadá pak potřeba psát metody jako
Řešení pro dibi s vlastním DataSource
Další, lepší, možností je, udělat si vlastní implementaci pro IDataSource, která nebude obalovat dotazy do subqueries (ach ta MySQL) ale bude pracovat s DibiFluent a zvýší tím výkon v MySQL databázi.
Na získávání více záznamů, by se pak z repozitáře volalo něco jako:
ShoppingCartRepository
ShoppingCartDibiMapper
Trochu jiný DataSource
LazyCollection pak může vypadat jednoduše takto
Použití 1
Takový datasource se pak nemusím stydět vrátit z repozitáře Presenteru, ten si může vyfiltrovat výsledky pomocí metody
where
, na základě nějakých filtrů z parametrů, může si seřadit výsledky jak je potřeba a předá pakLazyCollection
(DataSource) šabloně.V šabloně už je možné s výsledkem pracovat jako obvykle.
Použití 2
Konkrétně příklad s uživatelem, by bylo vhodnější řešit takto:
ShoppingCartRepository
ShoppingCartDibiMapper
Presenter
Z takovéto instance
LazyCollection
už by pak neměla jít "vykuchat" podmínka pro uživatele a vybrané záznamy souvisí proto vždy s daným uživatelemZávěrem
Pamatujte, že nikdy neexistuje jedné správné řešení. Je třeba se správně rozhodnout, co se nejvíce hodí na konkrétní problém :)
{{tags: cookbook}}
{{author: Filip Procházka|2118}}