ElixirでSI開発入門 #2 Ectoで楽観的ロック

（この記事は、「[Elixir or Phoenix Advent Calendar 2017](https://qiita.com/advent-calendar/2017/elixir-or-phoenix "Elixir or Phoenix Advent Calendar 2017")」の9日目です）

fukuoka.exのメンバーにより連載されています。

昨日は、@piacere さんの[「ExcelでElixirマスター2回目：「列の抽出」と「Web表示」](https://qiita.com/piacere/items/b7787580fce5f148242f)でした
今日は、実際のプロダクト開発のお話２回目です。

# はじめに

Elixirで実際にプロダクト開発した経験からサンプルコードを交えて解説する本連載
基本となるデータベース排他制御の後編は、
Webサービスなどでより一般的な楽観的ロックです。

悲観的ロックと楽観的ロックに関する一般的な解説は例によってこちらの記事をお勧めします。
-> [排他制御（楽観ロック・悲観ロック）の基礎](https://qiita.com/NagaokaKenichi/items/73040df85b7bd4e9ecfc "排他制御（楽観ロック・悲観ロック）の基礎")

本連載の前回記事はこちら
|> [ElixirでSI開発入門 #1 Ectoで悲観的ロック](https://qiita.com/tuchiro/items/7b04b0661cfa4a7af179)

---

# Ecto.Changeset.optimistic_lock()で楽観的ロック

[前回の悲観的ロック](https://qiita.com/tuchiro/items/7b04b0661cfa4a7af179)では、Ecto.Multiによるトランザクション制御とReop.lock()による更新ロックを組み合わせて実装する必要がありましたが、最新のEcto v2.2系では標準で楽観的ロックがサポートされています。

[Ecto 2.2.10 > Ecto.Changeset > optimistic_lock](https://hexdocs.pm/ecto/Ecto.Changeset.html#optimistic_lock/3)

公式にサンプルコードまで用意されているのですが、phoenixで使う為には若干アレンジが必要です。

---

# 実装の前提

今回は以下の実装を例に考えます

- コメントを投稿するモデルを実装する
- 同時に複数のタブで更新した場合、後からの更新で編集内容が上書きされないように楽観的ロック制御を行う必要がある

また、以下の環境で実装しました

- Elixir     v1.6.1
- Phoenix v1.3.2
- Ecto      v2.2.10
- PostgreSQL v10.2

---

# 開発手順　
## プロジェクト〜モデルの作成

PhoenixプロジェクトとDBを作成

```
> mix phx.new ecto_optimistic_lock_sample --no-brunch
> cd ecto_optimistic_lock_sample
> mix ecto.create
```

モデルを作成

```
> mix phx.gen.html Posts Comment comments name comment lock_version:integer
```

---

ルーティングを追加

```Ruby:lib/ecto_optimistic_lock_sample_web/router.ex

defmodule EctoOptimisticLockSampleWeb.Router do
  use EctoOptimisticLockSampleWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", EctoOptimisticLockSampleWeb do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
    resources "/comments", CommentController # <-- 作成したモデルのルーティングを追加
  end

end
```

---

## 楽観的ロックの実装を追加

作成したモデルのスキーマ実装を修正する
※追加するchangesetの実装は引数で呼び分けても関数名を変えてもどうちらでも良い
（Elixirの慣例としてここでは引数で呼び分ける形としている）

```Ruby:lib/ecto_optimistic_lock_sample/posts/comment.ex
defmodule EctoOptimisticLockSample.Posts.Comment do
  use Ecto.Schema
  import Ecto.Changeset

  schema "comments" do
    field :comment, :string
    field :lock_version, :integer, default: 1 # <-- ロック制御用のカウンターに初期値1を設定
    field :name, :string

    timestamps()
  end

  @doc false
  def changeset(comment, attrs) do
    comment
    |> cast(attrs, [:name, :comment, :lock_version])
    |> validate_required([:name, :comment, :lock_version])
  end

  def changeset(comment, attrs, :update) do # <-- 更新時用のchangesetの実装を追加
    comment
    |> cast(attrs, [:name, :comment, :lock_version])
    |> validate_required([:name, :comment, :lock_version])
    |> optimistic_lock(:lock_version) # <-- Ecto.Changeset.optimistic_lock()を追加
  end

end
```

---

更新関数を修正する。
※スキーマにchangesetを追加する際に[サンプル実装](https://hexdocs.pm/ecto/Ecto.Changeset.html#optimistic_lock/3)の様に第一引数で呼び分けてしまうと、デフォルトのupdate実装の様にパイプで処理を繋ぐことができなくなる為、第二引数以降を追加して呼び分けることをお勧めする。

```Ruby:lib/ecto_optimistic_lock_sample/posts/posts.ex
defmodule EctoOptimisticLockSample.Posts do
  @moduledoc """
  The Posts context.
  """

  import Ecto.Query, warn: false
  alias EctoOptimisticLockSample.Repo

  alias EctoOptimisticLockSample.Posts.Comment

  〜中略〜

  def update_comment(%Comment{} = comment, attrs) do
    comment
    |> Comment.changeset(attrs, :update) # <-- 更新用に追加したchangesetを使用する
    |> Repo.update()
  end

  〜中略〜
end
```

---

# 動かしてみる

マイグレーションを実行して、Phoenixサーバーを起動する。

```
> mix ecto.migrate
> mix phx.server
```

---

ブラウザから作成したページURLにアクセスし、コメントを新規登録する
http://localhost:4000/comments

<img src="https://qiita-image-store.s3.amazonaws.com/0/243163/9454fd56-eae9-7b51-6405-ea54a88563d2.png" width=50% border="2px" >

---

一つ目のタブからコメントを編集モードで開く。
<div style="border:#ff0000 solid 5px;">
<img src="https://qiita-image-store.s3.amazonaws.com/0/243163/75cd3bda-e0b0-2afd-13f6-c7e7ab35c01d.png" width=50% ></div>

---

一つ目のブラウザで更新をSubmitする前に、二つ目のタブで同じコメントを編集し、Submitする。
<img src="https://qiita-image-store.s3.amazonaws.com/0/243163/e778037d-10b4-8e52-bf58-f76856c3e3eb.png" width=50% >
先にSubmitした二つ目のタグでの更新が反映される
※この時lock_versionがインクリメントされていることが確認できる。
<img src="https://qiita-image-store.s3.amazonaws.com/0/243163/af60dcee-03ac-c837-202c-4ee668e51524.png" width=50% >

---

編集モードで開いておいた一つ目のタブでSubmitするとEcto.StaleEntryErrorが発生する。
<img src="https://qiita-image-store.s3.amazonaws.com/0/243163/608d725f-f056-73a5-11e1-0eef96bca989.png" width=50% >

---

これで楽観的ロック制御（つまりは先勝ち後負け）の実装が確認できた。
ちなみに、今回は入力項目にlock_versionをつけているので、ここでlock_version=2(二つ目のタブで更新後の値)を入力してしまえば二つ目のタブの更新内容を無視して無理やり更新することもできる。

---

Ecto.changeset.optimistic_lock()は引数で指定された項目の、更新する側が申告する値と現在保存されている値が一致しない場合に情報が古いと判断しているのだ。
※更新後の値より大きい値（例えばlock_version=3）を入力してもちゃんと叱ってくれる

---

# まとめ

-　楽観的ロックはEcto.changeset.optimistic_lock()でサポートされている
-　対象となるスキーマに更新時用のchangesetの実装を追加する
-　更新処理で使用するchangesetを更新時用のものに変更する
-　パイプを使う為、変数でchangesetを呼び分ける場合は第二引数以降で呼び分ける

いかがだったでしょうか。
２回に渡って排他制御の実装例をみてきました。

明日は、@twinbee さんの「[ElixirのGenStageに入門する#2 バックプレッシャーを理解する](https://qiita.com/twinbee/items/eda7f70fa9e4651f2248)」です