contributte / planette-site

💀 [DISCONTINUED] All the roads go through the Planette
https://planette.vercel.app
MIT License
9 stars 3 forks source link

2011-01-30: model-entity-repository-mapper #70

Open paveljanda opened 7 years ago

paveljanda commented 7 years ago

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;
        }
}

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

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();
$item = $repository->find(1);
$item->cost *= 2;
$repository->save($item);

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.

$repository = $this->context->shoppingCart; // $this === $presenter

$item = $repository->find(2);

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

public function renderDefault()
{
        // $this->shoppingCart instanceof ShoppingCartRepository

        $collection = $this->shoppingCart->getDataSource();

        $collection->where('user_id = %i', $this->getUser()->identity->id);
        $collection->orderBy(array('poradi_pridani_do_kosiku' => dibi::ASC));

        $this->template->shoppingCartItems = $collection;
}

V šabloně už je možné s výsledkem pracovat jako obvykle.

{forech $shoppingCartItems as $item}
        {$item->name}
{/forech}

Použití 2

Konkrétně příklad s uživatelem, by bylo vhodnější řešit takto:

ShoppingCartRepository

public function getDataSourceByUserId($userId)
{
        return new LazyCollection($this->mapper, $this->mapper->allDataSourceByUserId($userId));
}

ShoppingCartDibiMapper

public function allDataSourceByUserId($userId)
{
        return $this->conn->select('*')->from('shopping_cart')->where('user_id = %i', $userId);
}

Presenter

public function renderDefault()
{
        $collection = $this->shoppingCart->getDataSourceByUserId($this->getUser()->getIdentity()->id);

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živatelem

Zá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}}

paveljanda commented 7 years ago

author: Honza Kuchař (honza.kuchar@grifart.cz)