Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rails REST APIで実現するオプティミスティックロック(楽観的ロック)の基本と実装

次のようなシナリオを想像してみてください。賃貸物件管理システムで、従業員Aが物件Xの連絡先情報を編集し始め、電話番号をいくつか追加します。ほぼ同じタイミングで、従業員Bがまさにその物件Xの連絡先情報に誤字を見つけ、修正のための更新を行います。数分後、従業員Aが新しい電話番号を含む連絡先情報で物件Xを更新すると……なんと、誤字を修正したあの更新が消えてしまいました!

これは明らかに好ましい状況ではありません。しかも、これはごく単純な例にすぎません。もし同様の競合が金融システムで発生したらどうなるでしょうか。想像するだけで恐ろしいですね。

このようなシナリオを将来にわたって回避することはできるのでしょうか?幸いなことに、答えは「イエス」です。こうした問題を防ぐには、同時実行制御(コンカレンシー保護)とロック、具体的にはオプティミスティックロック(楽観的ロック)が必要です。

それでは、Rails REST APIにおけるオプティミスティックロックについて詳しく見ていきましょう。

「失われた更新(Lost Update)」とオプティミスティックロック vs ペシミスティックロック

先ほどのシナリオは、「失われた更新(Lost Update)」と呼ばれる問題の一種です。2つの並行トランザクションが同じ行の同じカラムを更新すると、後から実行された方が先の変更を上書きしてしまい、最初のトランザクションが最初から存在しなかったかのようになってしまいます。

通常、この問題は以下の方法で対処できます。

  • 適切なトランザクション分離レベルを設定する——データベースレベルで問題を処理します。
  • ペシミスティックロック(悲観的ロック)——並行するトランザクションが同じ行を更新できないようにします。2番目のトランザクションは、1番目のトランザクションが完了するまでデータを読み取ることすらできません。大きなメリットは、古いデータ(ステールデータ)を操作することが不可能になる点です。一方、大きなデメリットは、特定の行からのデータ読み取り自体もブロックされてしまうことです。
  • オプティミスティックロック(楽観的ロック)——更新時点での行の状態が読み取り時点と異なる場合、その行の変更を阻止します。

今回の問題は、同時に実行されるデータベーストランザクションというより、いわばビジネストランザクションの問題なので、最初の解決策はあまり当てはまりません。つまり、選択肢として残るのはペシミスティックロックとオプティミスティックロックの2つです。

ペシミスティックロックを使えば、仮説のシナリオで失われた更新がそもそも発生するのを防げます。しかし、データへのアクセスを長時間ブロックすると、ユーザーにとって使い勝手が悪くなります(あるフィールドを読んで編集するのに30分以上かかる場合を想像してみてください)。

一方、オプティミスティックロックならはるかに制約が緩く、複数のユーザーが同時にデータへアクセスできます。ただし、複数のユーザーが同時に編集を開始した場合、操作を実行できるのは1人だけです。残りのユーザーには「古いデータに対して操作を行ったため、再試行が必要」というエラーが表示されます。理想的ではありませんが、適切なUX設計をすれば、それほど苦痛にはならないかもしれません。

それでは、架空のRails REST APIでオプティミスティックロックをどのように実装できるのか見ていきましょう。

REST APIにおけるオプティミスティックロック

実際のRailsアプリでの実装に入る前に、一般的なREST APIの文脈でオプティミスティックロックがどのようなものになるか考えてみましょう。

前述のとおり、読み取り時のオブジェクトの元の状態を追跡し、更新時にその後の状態と比較する必要があります。最後に読み取ったときから状態が変わっていなければ操作は許可され、変わっていれば失敗します。

REST APIの文脈で解決すべき課題は次の3つです。

  • あるリソースのデータを読み取るとき、オブジェクトの現在の状態をどのように表現し、レスポンスとしてコンシューマ(API利用者)に返すか?
  • コンシューマは更新時に、リソースの元の状態をAPIにどのように伝えるべきか?
  • 状態が変化していて更新が不可能な場合、APIはコンシューマに何を返すべきか?

朗報なのは、これらの疑問はすべてHTTPのセマンティクスで回答・処理できるということです。

リソースの状態を追跡するには、Entity Tags(ETag)を活用できます。リソースのフィンガープリント/チェックサム/バージョン番号を専用のETagヘッダーでAPIコンシューマに返し、コンシューマはそれを後のPATCHリクエストで送り返します。さらにIf-Matchヘッダーを使えば、APIサーバー側でリソースが変更されたかどうかを簡単に確認できます。やることは、チェックサムやバージョン番号など、ETagとして選んだ値を比較するだけです。

現在のETagIf-Matchの値が一致していればリクエストは成功します。一致しなければ、APIはあまり使われない412 Precondition Failedステータスで応答します。この用途に使える最も適切で表現力のあるステータスコードです。

もう1つのシナリオもあります。ETagを比較できるのは、APIコンシューマがIf-Matchヘッダーを提供している場合だけです。提供しなかったらどうしましょう?同時実行保護を無視してオプティミスティックロックを諦めることもできますが、それは理想的とは言えません。もう1つの解決策は、If-Matchヘッダーの提供を必須にし、提供されない場合は428 Precondition Requiredステータスを返すことです。

これで、REST APIにおけるオプティミスティックロックの仕組みについて確かな全体像が得られました。次はRailsで実装してみましょう。

Railsにおけるオプティミスティックロック

ここでも朗報です。Railsはオプティミスティックロックを標準機能として備えています。ActiveRecord::Locking::Optimisticが提供する機能をそのまま使えます。モデルにlock_versionカラムを追加すれば(別の名前にすることも可能ですが、その場合はモデルレベルでロック用カラムを定義する追加の宣言が必要です)、ActiveRecordは変更のたびにこの値をインクリメントし、現在割り当てられているバージョンが期待どおりのものかを検証します。古くなっている(stale)場合は、update/destroyの試行時にActiveRecord::StaleObjectError例外が発生します。

APIでオプティミスティックロックを扱う最も簡単な方法は、lock_versionの値をETagとして使うことです。架空のRentalsControllerで、まずこの部分から実装してみましょう。

class RentalsController
  after_action :assign_etag, only: [:show]
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  private
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
end

もちろん、これは非常に簡略化されたコントローラーです。ここでは認証や認可などの概念ではなく、オプティミスティックロックに必要な部分だけに関心があります。これだけで、コンシューマに適切なETagを公開できます。次に、コンシューマが送信できるIf-Matchヘッダーに対応しましょう。

class RentalsController
  after_action :assign_etag, only: [:show, :update]
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  def update
    @rental = Rental.find(params[:id])
    @rental.update(rental_params)
    respond_with @rental
  end
 
  private
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
 
  def rental_params
    params
      .require(:rental)
      .permit(:some, :permitted, :attributes).merge(lock_version: lock_version_from_if_match_header)
  end
 
  def lock_version_from_if_match_header
    request.headers["If-Match"].to_i
  end
end

実は、これだけで最小限のオプティミスティックロックが動作します!ただし、競合が発生したときに500レスポンスを返したくはないでしょう。そこで、すべての更新に対してIf-Matchを必須にもします。

class RentalsController
  before_action :ensure_if_match_header_provided, only: [:update]
  after_action :assign_etag, only: [:show, :update]
 
  rescue_from ActiveRecord::StaleObjectError do
    head 412
  end
 
  def show
    @rental = Rental.find(params[:id])
    respond_with @rental
  end
 
  def update
    @rental = Rental.find(params[:id])
    @rental.update(rental_params)
    respond_with @rental
  end
 
  private
 
  def ensure_if_match_header_provided
     request.headers["If-Match"].present? or head 428 and return
  end
 
  def assign_etag
    response.headers["ETag"] = @rental.lock_version
  end
 
  def rental_params
    params
      .require(:rental)
      .permit(:some, :permitted, :attributes)
      .merge(lock_version: lock_version_from_if_match_header)
  end
 
  def lock_version_from_if_match_header
    request.headers["If-Match"].to_i
  end
end

これで、これまで説明してきた機能の実装に必要なものはほぼすべて揃いました。レスポンスコードだけでなく追加のエラーメッセージを返すなど、さらに改善できる点はたくさんありますが、それは本記事の範囲外とします。

まとめ:Rails APIにおけるオプティミスティックロックの重要性

REST APIを設計する際、同時実行制御(コンカレンシー保護)は見落とされがちですが、それが深刻な結果につながることもあります。

とはいえ、本記事で示したとおり、Rails APIへのオプティミスティックロックの実装はかなり簡単で、潜在的に重大な問題の回避に役立ちます。

それでは、楽しいコーディングを!

P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、Ruby Magicニュースレターを購読してください。投稿を見逃すことはありません!

  1. Rails開発者が知るべきセキュリティ脅威:インジェクション攻撃の仕組みと対策を徹底解説

    ユーザーデータを扱うアプリケーションを開発するなら、そのデータを確実に保護しなければなりません。しかし、セキュリティが初めての方にとっては、難しく、退屈で、複雑に感じられるかもしれません。 本記事は、Webアプリケーションにおける代表的なセキュリティ脆弱性と、それらがRails開発に与える影響を学ぶシリーズの第1回目です。道案内として活用するのは、OWASP Top 10(Web Application Security Risks)です。 OWASPとはOpen Web Application Security Project(オープンWebアプリケーションセキュリティプロジェクト)の略

  2. Rails5でのAngularの使用

    あなたは前にその話を聞いたことがあります。分散型で完全に機能するバックエンドAPIと、通常のツールセットで作成されたフロントエンドで実行されているアプリケーションがすでにあります。 次に、Angularに移動します。または、AngularをRailsプロジェクトと統合する方法を探しているだけかもしれません。これは、この方法を好むためです。私たちはあなたを責めません。 このようなアプローチを使用すると、両方の世界を活用して、たとえばRailsとAngularのどちらの機能を使用してフォーマットするかを決定できます。 構築するもの 心配する必要はありません。このチュートリアルは、この目的のた