Cakephp の afterSaveCommit
こんにちはSGKです。
今回は Cakephp の afterSaveCommit が思ったように動かないのでブログにまとめておこうと思います。
検証した Cakephp のバージョンは 4 ですが、3でも恐らく同じです。
目次
まず afterSaveCommit とは何か?
App\Model\Tableクラスに記述するトリガーメソッドで、save を実行したときに同期的に実行されます。
似たメソッドとして afterSaveCommit の他に beforeSave、afterSave などがありますので、これらも合わせて説明します。
※beforeRules、afterRules もありまあすが今回は省きます。
例えば src\Model\Table\UsersTable.php に
public function beforeSave($event, $entity, $options){
echo 'beforeSave';
}
と書いておけば $tableUsers->save( $entityUser ); などと実行した場合に、「beforeSave」と表示されます。
save の他、saveOrFail 時にも実行され、 beforeSave、afterSave、afterSaveCommit の順番で実行されます。
これをどう使うかというと「データベースと同期させてローカルファイルを保存する場合」に有用だと思います。
このメソッド群を見つけたとき、特に afterSaveCommit については、
「トランザクションコミットに合わせて実行されるトリガーメソッドがあるんだ!!」と歓喜したのですが、
残念、、とりあえずこの3つのメソッドの特性を以下で説明します。
beforeSave
- buildValidator と、buildRules が通っていれば実行される
- $event->stopPropagation() または throw で save を中断可能
- このメソッドの引数 $entity で保存内容を参照したり、変更することもできる
afterSave
- buildValidatorと、buildRules、データベース保存時に throw が無く終了していれば実行される
- データベース保存は終わっているので $event->stopPropagation() は効かないし、throw で save を中断しても意味が無い
- id のようなのシリアル値、$entity->id が参照可能になり、idに紐づいたファイルの保存ができるようになります。
- $entity に対する参照は可能だが、変更を行ってもデータベースへの保存はされない、ただしエンティティの値は変更はされるので注意。
しかも値の変更は保持されるが、この afterSave を抜けるとエンティティの変更フィールドに対する Dirty マークがクリアされる($entity->clean()が実行される)ので、どのフィールドを変更したか保持されていない状態になる。
なのでこのメソッド内で $entity を変更するようなコードは書いてはいけない。
afterSaveCommit
- 基本 afterSave と同じだがトランザクション開始されてたら実行されない。
- 名前からするに save 後にトランザクションコミットをトリガーとして起動するかと思ったのですが、トランザクションコミットしても起動しない。
例えば以下のようなコード
$connection = \Cake\Datasource\ConnectionManager::get('default');
$connection->begin();
try{
~
$tableUsers->saveOrFail( $entityUser ); // ← この save では起動しない、、
$connection->commit(); // ← だったらこの commit で afterSaveCommit が起動して欲しい!!
}catch( \Exception $e ){
$connection->rollback();
}
「commit で afterSaveCommit が起動して欲しい!!」のですが起動しません。
afterSave、beforeSave は「$tableUsers->saveOrFail( $entityUser );」の正常実行で同期的に実行されたとしても、
afterSaveCommit は動きません。
と、3つを説明して最後 afterSaveCommit の動作が思い通りに動かない事がお分かりいただけたでしょうか?
よく分からないのでマニュアルを読んでみよう。
https://book.cakephp.org/4/ja/orm/table-objects.html#aftersavecommit
Cookbook には以下のように記されています。
Model.afterSaveCommit
イベントは、保存処理がラップされたトランザクションが コミットされた後に発行されます。
データベース操作が暗黙的にコミットされる非アトミックな保存でも 引き起こされます。
イベントは、 save() が直接呼ばれた最初のテーブルだけに引き起こされます。
save が呼ばれる前にトランザクションが始まっている場合、イベントは起こりません。
ざっと読むとトランザクションがコミットされたときに実行されるんじゃないか? バグか?と思う。
しょうがなくいつも通り Cakephp の Table クラスのソースを読むと真実がわかりました。
どうやら save を実行するときに暗黙的なトランザクションを実行しているようで、、
「保存処理がラップされたトランザクション」
→ これは save 時にアトミックな実行の場合に暗黙的に発生するトランザクション(_executeTransaction())の実行後、という意味を指しており、任意に実行したトランザクションの事ではないようだ、、
「save が呼ばれる前にトランザクションが始まっている場合」
→ これが任意に実行したトランザクションの事で、まさしく前記のコードのような場合は afterSaveCommit は実行されない!と明記されていた、、、
要するに任意に実行したトランザクションがある場合は「保存処理がラップされたトランザクション」=「暗黙的に発生するトランザクション」がコミットされないから afterSaveCommit は実行されないという事らしい。
ちなみに アトミック:atomic とは、
$table->save($entity, [‘atomic’=>true or false]); のようにsaveの第二引数のオプションでセットできる値の事。
デフォルトはtrue
※日本語的には原子爆弾が思い浮かんじゃうけど「原子」と訳すより、「極小」と訳して考えると良いです。
true の場合は実行したテーブルのみ更新され、エンティティに含まれる別テーブルのデータ更新は行わない。
false の場合は関連する別テーブルのデータ更新も実行する
例えばユーザーのエンティティにユーザーのお気に入り($entUser->favorites)のエンティティが格納されていたとする。
$tblUsrs->save($entUser, [‘atomic’=>true]);
だと favorites テーブルのデータは保存しない。
$tblUsrs->save($entUser, [‘atomic’=>false]);
とすると favorites テーブルに対する保存も実行する
加えて、atomic が false の場合は save 時の暗黙的なトランザクションが発生しない。
なので afterSaveCommit も実行されないという仕組みになっています。
まとめ
結論 afterSaveCommit は使えないです。
加えて Cakephp には他の手段でトランザクションコミットやロールバック時に同期的に任意の処理を行うシステムは用意されていないようです。
なので、冒頭に書いた「データベースと同期させてローカルファイルを保存する場合」はbeforeSave を使い、ロールバック実行時は自前でロールバック処理を実行するようにしましょう。
というのが答えです。
ただしこの答えには一つ問題があり、トランザクションが多重化されていた場合に、大外のトランザクションに同期させて処理したい場合に対応していません。
これに対応するには ConnectionManager を拡張するしかないかと思いますが、この問題に当たったプロジェクトではそこまで必要な要件ではなかったので、踏み込んではいません。
あわよくば Cake が対応してくれることを祈っているのですが、、、
以上、afterSaveCommit 他、save 時に実行される トリガーメソッド beforeSave、afterSave についてでした。