(この記事は、「Elixir or Phoenix Advent Calendar 2017」の9日目です)
fukuoka.exのメンバーにより連載されています。
昨日は、@piacere さんの「ExcelでElixirマスター2回目:「列の抽出」と「Web表示」でした
今日は、実際のプロダクト開発のお話2回目です。
はじめに
Elixirで実際にプロダクト開発した経験からサンプルコードを交えて解説する本連載
基本となるデータベース排他制御の後編は、
Webサービスなどでより一般的な楽観的ロックです。
悲観的ロックと楽観的ロックに関する一般的な解説は例によってこちらの記事をお勧めします。
-> 排他制御(楽観ロック・悲観ロック)の基礎
本連載の前回記事はこちら
|> ElixirでSI開発入門 #1 Ectoで悲観的ロック
Ecto.Changeset.optimistic_lock()で楽観的ロック
前回の悲観的ロックでは、Ecto.Multiによるトランザクション制御とReop.lock()による更新ロックを組み合わせて実装する必要がありましたが、最新のEcto v2.2系では標準で楽観的ロックがサポートされています。
Ecto 2.2.10 > Ecto.Changeset > optimistic_lock
公式にサンプルコードまで用意されているのですが、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
ルーティングを追加
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の慣例としてここでは引数で呼び分ける形としている)
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を追加する際にサンプル実装の様に第一引数で呼び分けてしまうと、デフォルトのupdate実装の様にパイプで処理を繋ぐことができなくなる為、第二引数以降を追加して呼び分けることをお勧めする。
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
一つ目のタブからコメントを編集モードで開く。
一つ目のブラウザで更新をSubmitする前に、二つ目のタブで同じコメントを編集し、Submitする。
先にSubmitした二つ目のタグでの更新が反映される
※この時lock_versionがインクリメントされていることが確認できる。
編集モードで開いておいた一つ目のタブでSubmitするとEcto.StaleEntryErrorが発生する。
これで楽観的ロック制御(つまりは先勝ち後負け)の実装が確認できた。
ちなみに、今回は入力項目にlock_versionをつけているので、ここでlock_version=2(二つ目のタブで更新後の値)を入力してしまえば二つ目のタブの更新内容を無視して無理やり更新することもできる。
Ecto.changeset.optimistic_lock()は引数で指定された項目の、更新する側が申告する値と現在保存されている値が一致しない場合に情報が古いと判断しているのだ。
※更新後の値より大きい値(例えばlock_version=3)を入力してもちゃんと叱ってくれる
まとめ
- 楽観的ロックはEcto.changeset.optimistic_lock()でサポートされている
- 対象となるスキーマに更新時用のchangesetの実装を追加する
- 更新処理で使用するchangesetを更新時用のものに変更する
- パイプを使う為、変数でchangesetを呼び分ける場合は第二引数以降で呼び分ける
いかがだったでしょうか。
2回に渡って排他制御の実装例をみてきました。
明日は、@twinbee さんの「ElixirのGenStageに入門する#2 バックプレッシャーを理解する」です