Doctrine 2:stavy entit a transakce

V rámci seriálu o Doctrine 2 dnes budeme pokračovat v tématech nakousnutých posledně. Podíváme se podrobněji na stavy entit v průběhu jejich života. Ukážeme si nejdůležitější fungování UnitOfWork i práci s transakcemi a zamykáním.
Seriál: Doctrine 2 (12 dílů)
- Doctrine 2: úvod do systému 21. 7. 2010
- Doctrine 2: základní definice entit 4. 8. 2010
- Doctrine 2: pokročilá definice entit 11. 8. 2010
- Doctrine 2: načítání, ukládání a mazání 26. 8. 2010
- Doctrine 2:stavy entit a transakce 9. 9. 2010
- Doctrine 2: asociace 23. 9. 2010
- Doctrine 2: práce s asociacemi 14. 10. 2010
- Doctrine 2: DQL 3. 11. 2010
- Doctrine 2: Query Builder a nativní SQL 18. 11. 2010
- Doctrine 2: události 9. 12. 2010
- Doctrine 2: událostní handlery 13. 1. 2011
- Architektura aplikace nad Doctrine 2 23. 2. 2012
Nálepky:
Stavy entit
Entity mohou být z pohledu EntityManageru v různých stavech, podle toho, jestli jsou nové, persistované nebo třeba smazané. Informace o stavu se uchovává mimo entitu, drží si ho sám EntityManager, přesněji řečeno UnitOfWork. Podle stavu každé entity se EntityManager rozhoduje, jak bude s entitou nakládat a co je s ní potřeba dělat.
Doctrine 2 rozlišuje celkem čtyři různé stavy entit – nová, spravovaná, odpojená a smazaná:
- Nová (NEW)
- Nová je taková entita, kterou jsme zrovna vytvořili operátorem
new
a dosud jsme na ni nezavolali$em->persist($entity)
, takže není pod kontrolou EntityManageru. - Spravovaná (MANAGED)
- Když persistujeme novou entitu nebo z databáze načteme entitu již existující, je ve stavu Spravovaná. Jakékoliv změny, které v ní provedeme, se při nejbližším zavolání
$em->flush()
promítnou do databáze. - Odpojená (DETACHED)
- Spravovanou entitu můžeme od EntityManageru odpojit zavoláním
$em->detach($entity)
. Daná instance entity sice v aplikaci nadále existuje až do konce požadavku, ale jakékoliv změny v ní provedené se nikam nepromítnou a po skončení běhu skriptu se ztratí. V databázi ale záznam existuje i nadále a lze jej kdykoliv načíst dalším zavoláním$em->find()
. Odpojenou entitu dostanete zpátky pod kontrolu EntityManageru zavoláním$em->merge($entity)
. - Smazaná (REMOVED)
- Pokud nad spravovanou entitou zavoláme
$em->remove($entity)
, přepne se do stavu Smazaná a při nejbližším zavolání$em->flush()
se vymaže i z databáze. V paměti ale její instance zůstane i nadále až do konce požadavku.
Aktuální stav libovolné entity ověříte zavoláním metody $em->getUnitOfWork()->getEntityState($entity)
. Vrací se hodnota jedné z následujících symbolických konstant:
UnitOfWork::STATE_NEW
UnitOfWork::STATE_MANAGED
UnitOfWork::STATE_DETACHED
UnitOfWork::STATE_REMOVED
Následuje krátká ukázka životního cyklu instance článku pro demonstraci jednotlivých stavů entity:
$uow = $em->getUnitOfWork(); $article = new Article; echo $uow->getEntityState($article); // STATE_NEW $em->persist($article); echo $uow->getEntityState($article); // STATE_MANAGED $em->detach($article); echo $uow->getEntityState($article); // STATE_DETACHED $article = $em->merge($article); echo $uow->getEntityState($article); // STATE_MANAGED $em->remove($article); echo $uow->getEntityState($article); // STATE_REMOVED
Ukázali jsme si jen nejčastější změny stavů entit. Pokud vás zajímají podrobně všechny povolené přechody, doporučuji si prostudovat příslušnou kapitolu oficiální dokumentace.
UnitOfWork a ukládání změn
Několikrát už padla řeč na UnitOfWork. Ten leží na pozadí EntityManageru a zjednodušeně si jej můžete představit jako frontu všech změn, které chceme uložit do databáze.
Pokud tedy persistujete novou entitu pomocí $em->persist($entity)
, uděláte nějakou změnu v již persistované entitě nebo třeba smažete entitu přes $em->remove($entity)
, neprovádí se hned v databázi příslušný SQL dotaz, ale vše se jen kupí v UnitOfWork. Do databáze se pak odešle vše najednou, když zavoláte $em->flush()
.
Takový přístup přináší několik výhod. První je v tom, že se všechny databázové operace provádějí v relativně krátkém časovém úseku, takže případné zamykání je použito jen na nezbytně nutnou dobu.
Doctrine 2 si při zavolání $em->flush()
umí lépe rozvrhnout a výkonnostně zoptimalizovat, co, jak a v jakém pořadí vlastně do databáze pošle. Což se systému daří někdy lépe, jindy hůře, občas to ale bohužel vede k vyloženě nešťastnému a nečekanému chování, se kterým je potřeba počítat.
Můžete také rozšiřovat Doctrine 2 o další dodatečnou funkčnost, na volání $em->flush()
můžete navěsit vlastní kód a ještě před samotným promítnutím naplánovaných změn do databáze něco udělat, něco změnit. O událostech se ale budeme bavit někdy jindy.
Další výhodou fronty v UnitOfWork je, že pokud si to v průběhu skriptu rozmyslíte, můžete vzít snadno všechny změny zpět. Buďto prostě vůbec nezavoláte $em->flush()
, anebo o něco čistěji zavoláte $em->clear()
.
Vyčištění a uzavření EntityManageru
Zmiňovaná metoda $em->clear()
způsobí, že se odpojí všechny entity pod kontrolou EntityManageru, vyčistí se IdentityMap i UnitOfWork a EntityManager je prázdný stejně jako na začátku běhu skriptu.
Kromě vyčištění existuje ještě i možnost EntityManager úplně zavřít metodou $em->close()
. Od toho okamžiku už nelze nad uzavřeným EntityManagerem provádět žádná další volání a pokud chcete ještě něco s daty dělat, musíte si instancovat nový EntityManager.
EntityManager se občas uzavře i automaticky, a to zejména v případech, kdy uvnitř něj dojde k vyhození jakékoliv výjimky. Typicky pokud pošlete do databáze nějaký chybný dotaz, porušíte třeba nějaký databázový constraint. Tohle Doctrine 2 sama nijak nekontroluje, databáze vyhodí PDOException
, uzavře se EntityManager a jakákoliv další volání EntityManageru vedou k chybě.
To je poměrně nepříjemné chování, se kterým musíte při návrhu své aplikace počítat. Primárně tedy psát entity tak, aby si všechna omezení kontrolovaly samy a do databáze posílaly pouze bezproblémové SQL dotazy. Sekundárně pak počítat s tím, že při vyhozené PDOException
se vám současný EntityManager uzavře, nejspíš budete muset instancovat nový a s ním dovést aplikaci do bezpečného fallbacku.
Implicitní transakce
Doctrine 2 automaticky využívá transakce ve všech databázích, které je podporují. Ve výchozí situaci se o transakci nemusíte vůbec starat. Doctrine 2 ji po zavolání $em->flush()
na začátku zpracování všech změn naplánovaných v UnitOfWork sama otevře a po úspěšném provedení commitne. V následujícím kódu se tak nový článek uloží do databáze v transakci:
$article = new Article; $article->setTitle('Lorem ipsum'); $em->persist($article); $em->flush();
Když během zpracování dat dojde k jakékoliv chybě, provede se nad databází rollback, uzavře se EntityManager a vyhodí se výjimka.
Explicitní řízení transakcí
Pokud ale chcete mít nad transakcemi kontrolu, můžete si je řídit sami. K dispozici pro to jsou následující metody:
$em->getConnection()->beginTransaction()
$em->getConnection()->commit()
$em->getConnection()->rollback()
Jakmile explicitně otevřete transakci zavoláním $em->getConnection()->beginTransaction()
, od toho okamžiku se už neprovádí implicitní commit ani rollback uvnitř metody $em->flush()
a musíte si vše řídit sami:
$em->getConnection()->beginTransaction(); try { $article = new Article; $article->setTitle('Lorem ipsum'); $em->persist($article); $em->flush(); $em->getConnection()->commit(); } catch (Exception $e) { $em->getConnection()->rollback(); $em->close(); throw $e; }
Výhodou je, že můžete v rámci jedné transakce postupně odeslat i více různých sad změn postupným voláním více různých $em->flush()
. Bez explicitního řízení transakcí se neobejdete třeba v případě, kdy chcete v rámci prováděných změn spouštět přímo nad databází nějaké vlastní DBAL operace.
Nikdy ale nesmíte zapomenout při vyhozené výjimce vedle rollbacku i uzavřít EntityManager zavoláním $em->close()
. Pokud to neuděláte, zůstane vám EntityManager otevřený v nekonzistentním „bordelózním“ stavu, kde není předvídatelné jeho další chování.
Alternativní možností, jak nezapomenout na závěrečný commit či rollback, je využití metody $em->transactional()
:
$em->transactional(function($em) { $article = new Article; $article->setTitle('Lorem ipsum'); $em->persist($article); });
Pesimistické zamykání
Doctrine 2 sama o sobě pesimistické zamykání nijak zvlášť neřeší a nechává to na konkrétní databázi a jejích mechanizmech. Nabízí pouze několik volání, jak si zamknutí od databáze vyžádat.
Zamykat lze samozřejmě jen uvnitř otevřené transakce. Tedy pouze pokud jste někdy předtím zavolali $em->getConnection()->beginTransaction()
. Pokud nemáte transakci otevřenou, vyhodí se výjimka.
Pro zamykání lze zvolit jeden ze dvou módů:
DoctrineDBALLockMode::PESSIMISTIC_WRITE
- Znemožní ostatním konkurenčním procesům číst nebo aktualizovat dotčené záznamy.
DoctrineDBALLockMode::PESSIMISTIC_READ
- Znemožní ostatním konkurenčním procesům aktualizovat dotčené záznamy nebo je zamknout v
PESSIMISTIC_WRITE
módu.
Samotné zamknutí pak lze vyžádat na některém z následujících míst:
- Při načítání entity z databáze:
$em->find('Article', 123, LockMode::PESSIMISTIC_WRITE);
- Zamčením nad již načtenou entitou:
$em->lock($article, LockMode::PESSIMISTIC_WRITE);
- Nastavením módu nad vytvářeným DQL dotazem:
$query->setLockMode(LockMode::PESSIMISTIC_WRITE);
Optimistické zamykání
Optimistické zamykání využijete v případě, že potřebujete zachovat konzistenci dat napříč více různými požadavky uživatele. Pro lepší pochopení použiji konkrétní příklad, kdy nechcete, aby danému uživateli někdo změnil data v databázi, zatímco je například bude sám upravovat ve formuláři.
Princip je takový, že entita má u sebe jakýsi counter, který se při každém uložení záznamu inkrementuje. Pokud našemu uživateli vykresluji formulář, zapamatuju si u něj i poslední hodnotu counteru, která byla v okamžiku vykreslení formuláře. Když pak uživatel formulář po nějaké chvíli odešle, zkontroluji, zda má entita v databázi stále stejnou hodnotu daného counteru. Pokud ano, data z formuláře klidně uložím, v opačném případě vyhodím výjimku a uživateli třeba zobrazím chybovou hlášku, že během jeho editace už záznam upravil někdo jiný.
V první fázi je tedy nutné v entitě definovat sloupec se zmíněným counterem. Pro ten se používá anotace @version
. Nenechte se zmást názvem této anotace, s versionable behaviour, jaké možná znáte z předchozí verze Doctrine, to nemá vůbec nic společného.
class Article { // ... /** @version @column(type="integer") */ private $version; /** Returns last counter number */ public function getVersion() { return $this->version; } // ... }
Místo typu integer
můžete teoreticky použít také datetime
, tam ale hlavně u hodně navštěvovaných aplikací riskujete, že se dva uživatelé trefí do stejného času a kontrola u nich selže.
Při vykreslování formuláře se na daný článek dotážete stejně jako kdykoliv jindy. Důležité ovšem je, abyste si uchovali hodnotu jeho version
, ať už v sessions nebo ve skrytém formulářovém poli:
$article = $em->find('Article', 123); echo ''; echo '';
To nejdůležitější pak přichází po odeslání formuláře. Ve skriptu si musíme samozřejmě nejprve načíst instanci daného článku pomocí metody find()
. A tady máme k dispozici další dva parametry. V prvním z nich určíme, že nám jde o optimistické zamykání, v druhém potom verzi záznamu, kterou očekáváme:
$id = (int)$_POST['id']; $version = (int)$_POST['version']; $article = $em->find('Article', $id, DoctrineDBALLockMode::OPTIMISTIC, $version); // tady jsou nějaké další změny v článku $em->flush();
Pokud je v tomto okamžiku aktuální hodnota version
v databázi jiná, než jakou měl doteď uživatel ve svém formuláři, vyhodí se výjimka OptimisticLockException
, kterou bychom následně měli nějak zpracovat.
Dobrý den, měl bych „dotaz“ k následující části:
„…občas to ale bohužel vede k vyloženě nešťastnému a nečekanému chování, se kterým je potřeba počítat.“
Nebyl by nějaký konkrétní příklad neočekávaného chování, na který jste při používání Doctrine2 UnitOfWork narazil?
Jinak díky za pěkný seriál. .)
UnitOfWork neprovádí dotazy v takovém pořadí, v jakém přišly, ale přeskupuje si je podle svého uvážení. Což je na jednu stranu fajn, protože to má pozitivní vliv na výkon. Na druhou stranu ale občas může na pořadí záležet a pokud se přeskupí, dostane se úplně jiný výsledek. Například když udělám:
tak to samozřejmě dopadne úplně jinak, než když udělám:
Pak je potřeba počítat s tím, že Doctrine 2 provádí po zavolání
flush()
jednotlivé aktualizace v následujícím pořadi:Takže ať bych napsal výše uvedený příklad v jakémkoliv pořadí, vždycky se nakonec provede ve své první podobě, tabulka s články bude tedy nakonec zcela prázdná.
Pokud by to někomu v konkrétní situaci opravdu vadilo, nezbývá, než si manuálně otevřít transakci a provést v rámci ní více sad změn a každou vždy potvrdit pomocí samostatného
flush()
.Díky za odpověď; myslel jsem, že mj. právě o správné pořadí dotazů by se měl Unit of Work starat.
On se o to pořadí stará. Občas ale prostě nelze objektivně určit, které pořadí je to správné, protože kritérií na „správnost“ může být více a mohou jít i proti sobě. Takže Doctrine 2 to aktuálně dělá tak, jak jsem popsal, což je asi lepší z hlediska výkonu, méně dobré je to v některých okrajových případech z hlediska „logiky“. I já osobně nepovažuji současné řešení za šťastné, ale jak říkám – na to není objektivní odpověď a z několika srovnatelných možností to Doctrine 2 prostě dělá zrovna takhle.
Dobry den.
Je mozne vygenerovat entity ak uz mam databazu? Aby som nemusel definovat entity pre tu spustu tabuliek :-)
Asi to půjde, stačilo krátce pohledat: http://www.doctrine-project.org/documentation/manual/2_0/en/introduction
Ale může se samozřejmě stát, že vygenerované entity nebudou úplně ono. Záleží to na konkrétním příkladě.
tam som hladal, ale ak myslis ORM:generate-entities, tak tam pisu, ze generuje entity z mapovacich informacii, takze predpokladam, ze to nebude priamo z DB, takze si musim nadefinovat xml alebo yaml, ak to teda dobre chapem
Ano, je to možné. Nejlepší bude odkaz přímo do dokumentace – http://www.doctrine-project.org/projects/orm/2.0/docs/reference/tools/en#reverse-engineering
Super, dakujem. Mam este jeden problem. Mam tabulku „users“ (PK:id_user), na ktoru robim JOIN tabulky „auths“, ktora ma kompozitny PK (PK:id_driver,loginname FK:id_user FK:id_driver), co je v pohode. Ale v tom istom dotaze chcem spravit JOIN aj na „auths“ a viazana tabulka je authdrivers (PK:id_driver), kde mi doctrine zahlasi: Exception: Single id is not allowed on composite primary key in entity UsersModuleEntitiesAuthdriver.
Neviem, kde moze byt chyba, asi to zle chapem…
blizsi popis problemu: http://forum.nette.org/cs/5291-doctrine-2-join-na-tabulku-s-kompozitnym-pk
Doctrine 2 měla dlouhodobě problémy s takovými kombinacemi kompozitních klíčů. Spoustu toho už před nedávnem opravili, některé kombinace ale tuším ještě zůstaly nefunkční. Neumím teď takhle z hlavy říct, jestli zrovna tohle je ten stále nefunkční případ, k otestování bych potřeboval kompletní kód. Pojďme ale pokračování téhle diskuze přesunout přímo do odkazovaného Nette fóra, kde to snad vyřešíme až do konce.
Dobrý den, jde ječte to načtení entit z databáze již vytvořené?jsem začátečník s Doctrine, takže jsem ráda za každou radu
Používa entity manager pri spracovaní transakcie zámky na načítané záznamy????
Ide o to či sa viacero súčasných procesov nad jedným záznamom nepobije.
Tohle je věc, kterou si musí každý databázový systém řešit nejlépe sám. PostgreSQL to řeší přes MVCC (http://wiki.postgresql.org/wiki/MVCC), MySQL to na InnoDB řeší AFAIK pomocí zamykání tabulek. Jak je to implementované nad MyISAM netuším. Oracle IMHO transakce podporuje stejně jako PostgreSQL. Záleží tedy, jaký databázi používáte.