はじめに
Doctrineは自分の思ったように動かないことが多々ありますが、
それはDoctrineの根本を自分が理解していないのでは?ということを考えました。
以下のドキュメントを読んでみることが、理解に役に立つのではと思い、英語を自分なりに意訳してみました。
Working with Objects
https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/working-with-objects.html#working-with-objects
Working with Objectsのドキュメントについて
ページの最初にもありますが、このドキュメントはEntityManagerとUnitOfWorkの理解を助けるためのページとのこと。
EntityManagerはDoctrineのORマッパーとしての中心的な役割を果たすクラスであり、
コネクションの取得からfindなどのオブジェクト取得、flushなどのクエリ発行を担っています。
UnitOfWorkとは、Doctrine固有の概念ではなく、Martin Fowler氏のPatterns of Enterprise Application Architectureというパターンについての書籍に記載されたパターンのようです。
ユニットオブワーク
https://bliki-ja.github.io/pofeaa/UnitofWork/
Doctrineのドキュメントでは、UnitOfWorkはオブジェクトレベルのトランザクションのようなもの、と記載されています。
このオブジェクトレベルのトランザクションはEntityManagerが作成されたまたはflushが呼ばれたあとから開始し、flushが呼ばれると終了します。
各章の説明
Entities and the Identity Map
"Identity Map"パターンというパターンがあるようで、
一意マップ
https://bliki-ja.github.io/pofeaa/IdentityMap/
ロードしたオブジェクトをマップに保存して、オブジェクトが一度だけロードされることを保証する。オブジェクトが参照されたときは、マップを使って探し出す。
とパターンのページには解説がありますが、
Doctrineでもこの考え方を採用していて、find(id)で一度取得したものはマップに保存しておき、再度find(id)で取得しても同じインスタンスを返すということのようです。
なので、コード例にある
<?php
$article = $entityManager->find('CMS\Article', 1234);
$article->setHeadline('Hello World dude!');
$article2 = $entityManager->find('CMS\Article', 1234);
echo $article2->getHeadline();
の最後のgetHeadline()は'Hello World dude!'を返し、
<?php
if ($article === $article2) {
echo "Yes we are the same!";
}
はtrueとなるというわけです。
findをしたら毎度DBに値を取得しに行くものだと思っていたのでびっくりです。
Entity Object Graph Traversal
Graph Traversalとはグラフというデータ構造の走査に関するアルゴリズムのようで、ここではエンティティの関連を走査する方法について書かれているようです。
エンティティの関連についてはいくらでも辿れるよ、と言っている感じです。
例としてArticleクラスにManyToOneでauthorが紐付いてたり、OneToManyでcommentsが紐付いていた場合に、find(id)で取ってくるときはarticleテーブルにselectを発行するだけだけど、Articleエンティティからはauthorやcommentsのデータを取得できるよ、とのこと。
この関連を取ってくるやり方はlazy loading patternで行われるとのこと。
レイジーロード
https://bliki-ja.github.io/pofeaa/LazyLoad/
レイジーロードとは、必要になったときにDBからデータを読み込むというやり方で、
上記ページには以下のように記載されています。
Lazy Load (遅延ロード)ではロード処理をしばらく保留し、オブジェクト構造にしるしをつけておくことで、データが必要になった時に初めてロードするようにする。物事を後回しにしておくと、それをやらなくてよくなったときに得をする。
なので、Articleエンティティからauthorやcommentsにアクセスするときに初めてデータベース読み込みが発生します。authorを使わない処理ではデータベースアクセスは発生しません。
authorの読み込みはDoctrineのプロキシインスタンスというクラスを介して行われます。
(authorがUserという型だったとして)Article->getAuthor()で得られるのはUserクラスではなくUserを取得するためのUserProxyクラスであり、以下のコードはイコールではありません。
if ($article->getAuthor() instanceof User) {
// a User Proxy is a generated "UserProxy" class
}
ちなみにエラーなんかのときのスタックトレースにこんな感じで表示されてます。
/path/to/cache/doctrine/proxies/__CG__XxxEntityYyy.php
レイジーロードはSQLの発行回数を爆発的に増やす場合があります。
(Articleオブジェクトが1ページに1000個表示されて、それぞれのcommentやauthorを取得したら3000回のSQLが発行されますね。)
パフォーマンスの問題がある場合は、DQLで必要なデータをjoinして取得しましょう、とのこと。
Persisting entities
EntityManager#persist($entity)について説明されています。
persistメソッドはエンティティをMANAGED(管理された)状態にします。
何に管理されているかというと、EntityManagerのよって管理された状態になります。
その結果、EntityManager#flush()が呼ばれたときに、管理されたエンティティはデータベースに同期されます。
persistを呼んでも直ちにSQLのinsert文が発行されるわけではありません。
Doctrineはトランザクション後書きと呼ばれる戦略を取っています。
EntityManager#flush()が呼ばれるまで、SQLの実行は遅延され、flushが呼ばれて初めてデータベースにデータが同期されます。
このようにすることでデータの整合性を保つ簡易的なトランザクションとすることができます。
つまりDoctrineではSQLレベルでのトランザクションでなく、flushされるまでSQLの発行を遅延させることで、flushするまでの間を簡易的なトランザクションとして扱っているのです。
まさにこれがUnitOfWorkなんですね。
最初にこれを知るまでは、なんでコードにトランザクションがないのか、と思っていました・・・。
Doctrineはflushが成功しないと、エンティティのPKを得ることができません。
persistしただけでは、新たなエンティティのPKは取得できません。
persistの作用は以下の通りです。
- newされたエンティティをMANAGED状態にして、flush時にDBに登録する。
- すでにMANAGEDの状態のエンティティにpersistを発行しても無視される。しかし、このエンティティから他のエンティティへの関連がcascade=PERSISTまたはcascade=ALLの場合は他エンティティがまだpersistされていない場合はMANAGEDになる。例えば、ArticleがすでにMANAGEDでcommentsにまだMANAGEDになっていないものがあった場合、commentsの関連にcascaade=PERSISTが設定されていれば、persist(article)したときにcommentsも同時にpersistされる。
- removeされたエンティティをMANAGED状態にする
- detatchされたエンティティをpersistするとflush時に例外が発生する。
ときどき、newしてないエンティティにpersistしているコードを見かけますが、関連にcascade=PERSISTがない限りは意味がないということですね。
Removing entities
エンティティの削除について書かれています。
EntityManager#remove($entity)で削除を行うことができます。
removeするとエンティティはREMOVED(削除済み)の状態になります。
例によってflushが呼ばれるまでは実際にデータベースからデータが削除されず、
flushが呼ばれるとデータベースからデータが削除されます。
removeの作用は以下の通りです。
- newされたエンティティに対してremoveを発行しても無視される。しかし、このエンティティから他のエンティティへの関連の設定がcascade=REMOVEまたはcascade=ALLだった場合には関連先のエンティティにはremoveが適用される。
- MANAGED状態のエンティティに対してremoveを発行するとREMOVED状態になる。このエンティティから他のエンティティへの関連の設定がcascade=REMOVEまたはcascade=ALLだった場合には関連先のエンティティにもremoveが適用される。
- detatchされたエンティティをremoveするとInvalidArgumentExceptionがスローされる。
- removeされたエンティティをremoveすると無視される。
- removeされたエンティティはflushするとデータベースから削除される。
エンティティ自体はremoveを呼び出してもID以外は普通に参照できます。
エンティティにManyToManyの関連がある場合、関連先のエンティティも自動的に削除されます。その動作は関連の定義のjoinColumnのonDelete属性によって決定されます。
- 関連がcascade=REMOVEだった場合はDoctrine2ではその関連先もすべてremoveします。もし関連がcollectionだった場合には、そのcollectionをloopしてさらにそのひとつひとつを再帰的にremoveするため、大きなオブジェクトだとかなりのコストがかかってしまいます。cascade=REMOVEはDoctrine側でdelete文を発行するための仕組みです。
- オブジェクトが大きいときはDQLでdelete文を書くことがパフォーマンス的には有効です。
- onDelete="CASCADE"はcascade=REMOVEとは異なりデータベースに用意されているFKによるcascadeです。アプリケーション側で削除を管理できないのでトリッキーではありますが、データベース側ですべて処理が行われるためパフォーマンスはとてもいいです。cascade=REMOVEをつけてしまうとonDelete="CASCADE"は無意味になります(・・・自信ないですが多分)
Detaching entities
detachとは切り離すという意味ですが、EntityManagerの管理下から外すという意味かと思います。
EntityManager#clear()メソッドが呼ばれた後は、エンティティに変更を加えてもそれがデータベースに同期されることはありません。
clearの作用は以下の通りです。
- MANAGED状態のエンティティにたいしてclearを実行するとdetachされます。
- newしたエンティティにたいしてclearを実行しても無視されます。
- REMOVED状態のエンティティに対してclearを実行するとdetach状態のため削除されなくなります。
Synchronization with the Database
UnitOfWorkの考え方に基づいてEntityManagerでオブジェクトレベルのトランザクションを実現してるよ、flushしたときにデータベースに同期するよ、同期するときは関連も全部登録されるし、MANAGEDやREMOVEDなオブジェクトも全部見て同期するよ、という今までのまとめ的な話。
Effects of Database and UnitOfWork being Out-Of-Sync
とにかくflushを呼ばない限りはデータベースに同期されないよっていう話がまた書かれていて
- REMOVED状態のエンティティでもflushを呼ぶ前なら参照可能だし、データベースからもDQLなどで取得可能
- persistされたエンティティもflushを呼ぶ前だとクエリでは取得できない
- 変更されたエンティティはデータベースからの取得結果で上書きされない、これはIdentity Mapが何が最新の状態であるか把握しているため
で、flushは自動では呼ばれないこと、必ず手動で呼ぶことが書かれています。
Synchronizing New and Managed Entities
flushによってMANAGED状態のエンティティには以下の作用があります。
- 少なくとも管理されているフィールドの1つが変更されている場合に限りSQLのUPDATEが実行される
- 何も変更されていない場合は何も実行されない
flushによってnewされたエンティティには以下の作用があります。
- SQLのinsertが実行される。
関連については以下のとおり(ちょっと何言ってるかわからない状態)
- もしnewされたエンティティXが別のエンティティにcascade=PERSISTの設定がされている場合は、別のエンティティがpersistされたら、Xもpersistされる
- もしnewされたエンティティXが別のエンティティにcascade=PERSISの設定がされていない場合は、別のエンティティがpersistされた場合に、エラーが発生する
- もしremoveされたエンティティXが別のエンティティにcascade=PERSISの設定がされている場合は、別のエンティティがpersistされた場合に、エラーが発生する
- もしdetachされたエンティティXが別のエンティティにcascade=PERSISの設定がされている場合は、別のエンティティがpersistされた場合に、エラーが発生する
Synchronizing Removed Entities
removeもflush時にデータベースに同期されます。
flush時に関連するcascadeオプションはなく、CASCADE=REMOVEはEntityManager#remove($entity)実行時にもう実行されています。
ちょっと長くなってきたので
明日に続く!