環境
- Laravel 5.5
- MySQL5.7(トランザクション分離レベル:REPEATABLE READ)
準備
- テーブルに「version」というカラムを用意する
前提条件
- EloquentORMを使って更新している
Eloquentのイベントをフックするためのオブザーバを作る
それぞれ関数名をフックしたいイベントの名称にする。
MySQLでトランザクション分離レベルを「REPEATABLE READ」に設定している場合は、「lockForUpdate」などで悲観ロックをかければ、そのレコードに対して他トランザクションの更新後の値が取得できるようになる(「READ COMITTED」のような動きをする)ため、いい感じに楽観ロックをしてくれる。
class OptimisticLockObserver
{
public function created(Model $model)
{
$version = $model->getAttribute($model->getVersionColumn());
if (!isset($version)) {
$model->setAttribute($model->getVersionColumn(), 0);
}
}
public function updating(Model $model)
{
$version = $this->optimisticLockCheck($model);
// バージョンを1加算する
$model->setAttribute($model->getVersionColumn(), ++$version);
}
private function optimisticLockCheck(Model $model) : int
{
// 楽観ロックチェックに使う値の取得
$version = $model->getAttribute($model->getVersionColumn());
$id = $model->getKey();
// バージョン番号設定されていないとエラー
if (!isset($version)) {
throw new OptimisticLockException();
}
// 主キーがなければエラー
if (!isset($id)) {
throw new OptimisticLockException();
}
// DBから対象のレコード取得
if (!$model->isRestoringFlg()) {
$dbModel = $model->getMorphClass()::lockForUpdate()->find($id);
} else {
// restoreは削除済みレコードを取得する必要があるため「withTrashed()」してからレコード取得
$dbModel = $model->getMorphClass()::withTrashed()->lockForUpdate()->find($id);
}
// 対象のレコードが取得できない場合はエラー
if (!isset($dbModel)) {
throw new OptimisticLockException();
}
// DBに設定されているバージョン番号取得
$dbModelVersion = $dbModel->getAttribute($dbModel->getVersionColumn());
// バージョン番号が一致しなければエラー
if ($version != $dbModelVersion) {
throw new OptimisticLockException();
}
return $version;
}
public function deleting(Model $model)
{
$this->optimisticLockCheck($model);
}
public function restoring(Model $model)
{
// リストア時はUPDATEも入るためmodelのrestoringFlgをtrueにする
$model->setRestoringFlg(true);
$this->optimisticLockCheck($model);
}
public function restored(Model $model)
{
// リストアが完了したらmodelのrestoringFlgをfalseに戻す
$model->setRestoringFlg(false);
}
}
Modelにオブザーバを登録するトレイトを作る
OptimisticLockObserverで使いたいプロパティとかもここに置いておく。
(versionColumnを書き換えてしまえばとりあえずはカラム名の変更はできる)
trait OptimisticLocks
{
protected $restoringFlg = false;
protected $versionColumn = 'version';
public static function bootOptimisticLocks()
{
self::observe(OptimisticLockObserver::class);
}
public function isRestoringFlg(): bool
{
return $this->restoringFlg;
}
public function setRestoringFlg(bool $restoringFlg)
{
$this->restoringFlg = $restoringFlg;
}
public function getVersionColumn(): string
{
return $this->versionColumn;
}
public function setVersionColumn(string $versionColumn)
{
$this->versionColumn = $versionColumn;
}
}
独自例外を作る
class OptimisticLockException extends Exception
{
}
Modelにトレイトを追加する
通常使う時は特に意識せずに楽観ロックがかかるようにするため、Modelを複数に分けて通常名には楽観ロックのトレイトを追加し、Repositoryにモデル規約を記載する。
class User extends UserRepository
{
use OptimisticLocks;
}
class UserRepository extends Model
{
use SoftDeletes;
protected $table = 'users';
protected $guarded = array('id', 'created_at', 'updated_at');
}
使い方の例
楽観ロック有り
画面上でversionの値を保持してその値を送信する。(HTMLは省略)
その値をModelに設定することで、楽観ロックのチェックを行う。
DB::transaction(function () {
$user = User::find($id);
$user->name = $name;
$user->version = $version;
$user->save();
});
楽観ロック無し
RepositoryのModelを使うことで、楽観ロックのトレイトが適用されなくなる。
バッチ処理等で楽観ロックを必要としない場合や、大量のデータ更新が必要で処理速度重視にする場合はこっちを利用。
DB::transaction(function () {
$user = UserRepository::find($id);
$user->name = $name;
$user->save();
});
できないこと
- 複数更新の場合、Eloquentのイベントが発行されないため、楽観ロックが動作しない
- 主キーが複数カラムで構成されているテーブルは正常に動作しないはず(試していない)
- versionのカラム名をいい感じにModelに指定して上書き
参考にさせていただいた記事
Laravelで独自例外処理を実装する(楽観的排他制御andトランザクション処理)
https://qiita.com/sakuraya/items/a511f0e615717a6b7628
Laravelのeloquentのeventでcreated_byとかupdated_byとか更新するobserverとtrait
https://qiita.com/maimai-swap/items/6597c04721adbc48fec2