30
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Laravelで楽観的ロックをつくってみる

Last updated at Posted at 2019-10-08

Laravelには悲観的ロックの機能は備わってるけど、楽観的ロックの機能が用意されていないっぽいのでつくってみました。

そもそも悲観的ロック・楽観的ロックとは??

書きたい事から外れるので、ざっくりというと、、

  • 悲観的ロック:自分がデータを取ってきた時点でロックをかけて、他の人にはそのデータを取ってこれないようにする。
  • 楽観的ロック:誰でもデータを取ってこれるけど、先に更新した人が勝ちで、更新前のデータを後から更新しようとしてもダメ。

わかりやすく丁寧に説明してくれている記事はこちら↓

ということで、今回は「楽観的ロック」を作ってみます。
たまに見かける「他の人が変更してるので、画面更新して最新の情報を表示してね」的なメッセージが出るアレです。

今回は管理画面で「タイトル」と「本文」だけがある記事を更新するケースを考えてみます。

シナリオケース

仕組み

ざっくりとした仕組みはこんな感じ。

  1. 編集画面を表示した時に<hidden>で最終更新日時を隠し持っておく
  2. 更新処理の直前に現在のレコードをselectして最終更新日時を取得する
  3. 「hiddenの値 < 現在のレコードの値」だった場合は他の人が先に更新してるのでエラーにする

※処理1:最終更新日時ではなくレコードのバージョンを保存するカラムを作って、更新のたびにインクリメントしていく方法もあります。

※処理2〜3:レコードを更新する時の条件としてhiddenの値を指定して、更新結果が0件の場合はエラーとする方法もあるかと思います。

update blog set
    title = ?,   -- 入力値
    body = ?,  -- 入力
    edited_at = ?  -- CURRENT_TIMESTAMP()
where
    id = ?
    and
    edited_at = ? -- hiddenの日時

環境

  • php7.3
  • laravel5.8
  • mysql5.7

構成

テーブル

CREATE TABLE `blog` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(30) DEFAULT NULL,
  `body` text,
  `edited_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

ディレクトリ構成

※必要なものだけ記述しています。

├── app
│   ├── Http
│   │   └── Controllers
│   ├── Models
│   │   └── Blog.php
│   ├── Observers
│   │   └── OptimisticLockObserver.php
│   ├── Services
│   │   └── BlogService.php
│   ├── Traits
│   │   └── OptimisticLockObserverTrait.php
├── resources
│   └── views
│       ├── input.blade.php

ではでは、具体的な処理へ。

1. 編集画面を表示した時に<hidden>で最終更新日時を隠し持っておく

/resources/views/input.blade.php
<form action="/save" method="post">
    @csrf
    <input type="hidden" name="edited_at" value="{{old('edited_at', $blog->edited_at??null)}}" />
    <input type="text" name="title" value="{{old('title', $blog->title??null)}}">
    <textarea name="body">{{old('body', $blog->body??null)}}</textarea>
</form>

これは単純にhiddenを作りましたというだけ。

2. 更新処理の直前に現在のレコードをselectして最終更新日時を取得する

更新処理

/app/Services/BlogService.php
<?php

namespace App\Services;

use App\Models\Blog;

class BlogService{

    /**
     * 保存
     *
     * @param array $values
     * @param Blog|null $blog
     */
    public function save(array $values, Blog $blog=null)
    {
        $input = collect($values);

        $model = $blog ?: new Blog();
        $model->title = $input->get('title');
        $model->body = $input->get('body');
        $model->setEditedAt($input->get('edited_at'));

        $model->save();
    }
}

$model->setEditedAt($input->get('edited_at'));で画面のhiddenに保持していた更新日時をモデルに設定しています。
引数がNullでも動くようにしているので、新規作成時でも上記の記述で大丈夫です。

楽観的ロック制御のための仕組み

/app/Models/Blog.php
<?php

namespace App\Models;

use App\Traits\OptimisticLockObserverTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;


class Blog extends Model
{
    use SoftDeletes;
    use OptimisticLockObserverTrait;

    /**
     * 日付へキャストする属性
     *
     * @var array
     */
    protected $dates = [
        'created_at',
        'updated_at',
        'edited_at',
        'deleted_at',
    ];
}

use OptimisticLockObserverTrait;でトレイトを使ってBlogモデルに2つのfunctionを定義しています。

/app/Traits/OptimisticLockObserverTrait.php
<?php

namespace App\Traits;

use App\Observers\OptimisticLockObserver;
use Carbon\Carbon;

trait OptimisticLockObserverTrait
{
    protected static function bootOptimisticLockObserverTrait()
    {
        self::observe(OptimisticLockObserver::class);
    }

    public function setEditedAt($editedAt){
        $this->{OptimisticLockObserver::OPTIMISTIC_LOCK_CHECK_COLUMN} = $editedAt? Carbon::parse($editedAt): null;
    }
}

setEditedAt()

前述のBlogService#saveで呼び出していて、これを呼び出すとモデルオブジェクトに楽観的ロックチェック用の一時プロパティを追加して最終更新日を設定しています。
(更新処理でこのfunctionを呼び出さなければ楽観的ロックは行わないう様になります。)

bootOptimisticLockObserverTrait()

このfunctionですが、

  • 修飾子がstaticであること
  • function名が「boot【+クラス名】」であること

というのがポイントで、このルールに則って定義していると、Modelクラスのコンストラクタでこのfunctionを実行してくれるというものです。

そして、functionの中身のself::observe(OptimisticLockObserver::class);は、Modelクラスが読み込んでるConcerns\HasEventsトレイトのfunctionに定義されていてい、コメントに

Register observers with the model.

と書いてあるので、オブザーバをサービスプロバイダではなく、モデルで登録する機能のようです。

そしてこの機能を利用して登録しているオブザーバクラスはこちらです。

3. 「hiddenの値 < 現在のレコードの値」だった場合は他の人が先に更新してるのでエラーにする

/app/Observers/OptimisticLockObserver.php
<?php

namespace App\Observers;


use App\Exceptions\ExclusionException;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

class OptimisticLockObserver
{
    /**
     * 楽観的ロックチェック用の一時プロパティ
     */
    const OPTIMISTIC_LOCK_CHECK_COLUMN = 'edited_at_optimistic_lock_check_column';


    /**
     * INSERT前
     *
     * @param Model $model
     */
    public function creating(Model $model){
        if (!$this->checkPropExists($model)) return;

        $this->unsetOptimisticLockColumn($model);
        $model->edited_at = Carbon::now();
    }

    /**
     * UPDATE前
     *
     * @param Model $model
     */
    public function updating(Model $model){
        if (!$this->checkPropExists($model)) return;

        $this->check($model, 'update');
        $model->edited_at = Carbon::now();
    }

    /**
     * 論理削除前
     *
     * @param Model $model
     */
    public function deleting(Model $model){
        if (!$this->checkPropExists($model)) return;

        $this->check($model, 'delete');
        $model->edited_at = Carbon::now();
    }

    /**
     * 物理削除前
     *
     * @param Model $model
     */
    public function restoring(Model $model){
        if (!$this->checkPropExists($model)) return;

        $this->check($model, 'delete');
        $model->edited_at = Carbon::now();
    }

    /**
     * 楽観的ロックチェック
     *
     * @param Model $model
     */
    private function check(Model $model, $msgType){
        //楽観的ロックチェック用の一時プロパティがあった時だけチェックする
        if (!$this->checkPropExists($model)) return;

        //現時点でのDBのデータを取得
        $currentMe = $model->withTrashed()->find($model->id);
        $currentEditedAt = $currentMe->edited_at;
        //更新されているかチェック
        if($model->{self::OPTIMISTIC_LOCK_CHECK_COLUMN} != $currentEditedAt){
            //楽観ロック
            throw new ExclusionException(trans('error_message.exclusion.'.$msgType));
        }

        $this->unsetOptimisticLockColumn($model);
    }

    /**
     * 楽観的ロック用の一時プロパティを削除する
     * これをしないと存在しないカラムに値を登録しようとしてsave()時にエラーになる。
     *
     * @param Model $model
     */
    private function unsetOptimisticLockColumn(Model $model){
        unset($model->{self::OPTIMISTIC_LOCK_CHECK_COLUMN});
    }

    /**
     * 楽観的ロック用の一時プロパティがあるかチェックする
     *
     * @param Model $model
     * @return bool true:ある
     */
    private function checkPropExists(Model $model){
        return array_key_exists(self::OPTIMISTIC_LOCK_CHECK_COLUMN, $model->getAttributes());
    }
}

Eloquentモデルのイベント処理を記述しています。レコードの登録・更新・削除前に実行されます。

処理内容は、

  1. モデルオブジェクトに楽観的ロックチェック用のプロパティが存在したら
  2. 対象レコードのedited_atカラムの値をselectする
  3. 「プロパティの値 < edited_atカラム」の場合はExclusionException(自作のException)をスローする
  4. 全部OKなら、モデルオブジェクトのedited_atに現在日時を追加する

となっています。あとはEloquentの流れに乗って勝手に更新される。

ここら辺のオブザーバ周りは「Laravelのeloquentのeventでcreated_byとかupdated_byとか更新するobserverとtrait」をまるっと参考にさせてもらいました。

参考

最後にもう一度、参考にさせて頂いた記事をまとめておきます。

30
38
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
38

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?