読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Rails4で楽観的ロックを実装する

Rails中級 Rails Model

ActiveRecordでは、lock_versionというカラムを追加するだけで楽観的ロックを利用できます。
レコード単位でlock_versionを保持しているため、レコード単位での更新が競合した場合、ActiveRecord::StaleObjectError例外が発生します。


動作確認

  • Rails 4.1
  • ActiveRecord 4.1

目次

  1. 楽観的ロックとは
  2. 楽観的ロック(ロックバージョン)の実装
  3. 楽観的ロックの使い方
  4. 楽観的ロックを画面から扱う


1. 楽観的ロックとは

楽観的ロックとは、基本的には変更が競合しないだろうという状況に向いたロック手法です。
テーブルにロックバージョンカラムを持たせ、レコードを変更するたびにロックバージョンを更新し、更新しようとしたときにロックバージョンが異なっている場合には、更新の競合が発生したと判断しレコードを更新しないようにします。

ActiveRecordでは、lock_versionというカラムを追加するだけで楽観的ロックを利用できます。




2. 楽観的ロック(ロックバージョン)の実装

次のScaffoldが実行されているという前提で話を進めます。

rails g scaffold Product name:string description:text price:integer

まず、テーブルにlock_versionを追加します。

rails g migration add_lock_version_to_product lock_version:integer

初期値を"0"にするdefaultオプションとNOT NULL制約のnullオプションを追加します。

# db/migrate/YYYYMMDDHHMMSS_add_lock_version_to_product.rb
class AddLockVersionToProduct < ActiveRecord::Migration
  def change
    add_column :products, :lock_version, :integer, default: 0, null: false
  end
end

マイグレートします。

rake db:migrate

これで、Proudctモデルで自動的に楽観的ロックが行われるようになりました。




3. 楽観的ロックの使い方

ActiveRecordによりlock_versionが自動で更新されます。
また、悲観的ロックではレコード単位での更新の競合が起きると、ActiveRecord::StaleObjectError例外が発生します。

# Productの作成
p = Product.create(name: "オレンジジュース", description: "おいしいよオレンジジュース", price: 100)
p
# => #<Product id: 1, name: "オレンジジュース", ... , lock_version: 0>
# lock_version は 0(デフォルト値)

# Productの読み込み
p1 = Product.find(p.id)
p2 = Product.find(p.id)

# あるユーザーが商品名 (name)を更新するとlock_versionが自動で更新される
p1.update(name: "静岡のオレンジジュース")
# => true
p1
# => #<Product id: 1, name: "静岡のオレンジジュース", ... , lock_version: 1>
# lock_version が 1 になっている

# もう一人のユーザーが値段(price)を更新するとコンフリクトが発生
p2.update(price: 200)
# => ActiveRecord::StaleObjectError: Attempted to update a stale object: Product

# もう一人のユーザーが商品を削除するとコンフリクトが発生
p2.destroy
# => ActiveRecord::StaleObjectError: Attempted to destroy a stale object: Product


4. 楽観的ロックを画面から扱う

楽観的ロックを画面から制御する場合には、フォームにHidden属性でlock_versionを追加する必要があります。

# app/views/products/_form.html.erb
<%= form_for(@product) do |f| %>
  <%= f.hidden_field :lock_version %>
  ...
<% end %>

そして、StrongParametersにlock_versionを追加します。

# app/controllers/products_controller.rb
def product_params
  params.require(:product).permit(:name, :description, :price, :lock_version)
end

また、悲観的ロックの競合が発生すると、ActiveRecord::StaleObjectError例外が発生するので、例外をキャッチし、バリデーションエラーを表示するようにします。

# app/models/product.rb

# コンフリクトが起こる可能性がある更新処理
def update_with_conflict_validation(*args)
  update(*args)
rescue ActiveRecord::StaleObjectError
  self.lock_version = lock_version_was
  errors.add :base, "この商品は、あなたが編集中に他の人に更新されました。"
  changes.except("updated_at").each do |name, values|
    errors.add name, "was #{values.first}"
  end
  false
end

そして、最後にコントローラーからこのメソッドを使うようにします。

# PATCH/PUT /products/1
# PATCH/PUT /products/1.json
def update
  respond_to do |format|
    if @product.update_with_conflict_validation(product_params)
      format.html { redirect_to @product, notice: 'Product was successfully updated.' }
      format.json { render :show, status: :ok, location: @product }
    else
      format.html { render :edit }
      format.json { render json: @product.errors, status: :unprocessable_entity }
    end
  end
end

では、画面から確認します。
編集画面を2つの画面から開きます。
(Safari)
f:id:nipe880324:20141125014949p:plain:w380
(Chrome)

f:id:nipe880324:20141125014955p:plain:w380


次に、Safariの画面から更新し、更新に成功します。
また、lock_version"1"に自動で更新されます。
[f:id:nipe880324:20141125015105p:plain:w380
]

今度は、Chromeの画面から更新します。こちらは、まだlock_versionが"0"のため、ActiveRecord::StaleObjectError例外が発生します。
その例外をキャッチする処理(update_with_conflict_validation)を書いていたので、バリデーションエラーとなります。バリデーションエラーのエラーメッセージに現在のDBの値が表示されるようになっています。
f:id:nipe880324:20141125015204p:plain:w380


このとき、注意しなければいけないのが、DBから値をロードしなおすのでlock_version"1"になっていることです。
そのため、もう一度Update Productボタンを押すと、競合を無視して上書いてしまいます。
f:id:nipe880324:20141125015225p:plain:w380



以上です。

参考文献