React x Railsでデータの同時編集ロック機能を実装する
検討
競合する可能性が大いにあり、かつデータの整合性が非常に重要な部分の実装なので、今回の場合は楽観的ロックではつらそう。では悲観的ロックではどうか。
悲観的ロックとは、
悲観的ロックとは、DBMSの行ロック機能(SELECT FOR UPDATE句)を利用して、並行した更新作業を制限する方法です。
ロックはレコード取得時にかかるので、同時にレコードを取得しようとした場合は他方がロック解除になるまで待機するため、同時に同じレコードを取得できないようになります。
今回ロックしたいデータはReact componentからAjaxでAPIを叩いているので、その点を考慮しながら悲観的ロックの実装を行う必要がある。
だけど、今回はロックをかけたいレコードがモデルをまたがるような構造になっているので、行ロックをテーブルをまたがって複数かけるひつようがありそうだ。
あと、SELECT FOR UPDATEが解除されるのがどのタイミングか調べてもいまいちわからなかったので、updateしたタイミングだとしたらユーザがupdateせずブラウザを離れる場合などもあり得るので、ちょっと怖いなとなった。
そして、そのページの中で、さらに紐付いてるデータも独立してばんばんcreateされることも考慮しないといけない。。。これは行ロックでは実現できない。
うーーんどうするか。。。
そこで今回は、ボタンをおした後に他のトランザクションが走っていることに気づく、という仕様ではなく、そもそも他の人の編集中には#editアクションのpathにアクセスできない、という仕様にすることにしました。
ですが、こちらの方法にも課題が合って、Webの仕組みというかさがというか、クライアントがそのページを離れたかどうかって、どうしてもWebの世界の仕組み上確証をとれないんですよね。。。
beforeunloadだってメッセージを表示するために特化した関数なのでそれでごちゃごちゃしようとした場合かなりブラウザごとに挙動の違いが出てくるみたいだし、そもそもPCの電源が落ちたときなど、このイベントさえ呼ばれないということだって全然ありえるだろうし。。
と、どうしようか悩んだのですが、Wordpressがこの方式を取っていることに気付き、ソースコードをWordpressのミラー版がGitHubに公開されていたので参考にしてみることにしました。以下のコードあたりがそれっぽい。
heartbeatって名前が秀逸だな。。。
30秒ごとに脈を打って編集情報を更新してるみたい。
この方法とてもよい!と思い、この方法をRails + Reactで実装してみることにしました。
実装
今回の想定は、記事データを扱うPostというモデルの同時編集をロックするというもので実装していきます。Reactを使います。
編集情報を扱うモデルを作成
まずは編集情報を扱うPostUpdatingというモデルを作成します。
$ bin/rails g model PostUpdating user_id:integer post_id:integer
そしたら、PostとPostUpdatingは1:1の関係になるように各モデルのファイルとspecを実装しておいて下さい。(手抜きすみません。。)
APIを実装
続いてこのモデルのAPIを実装します
$ bin/rails g controller Api::V1::PostUpdating
class Api::V1::PostUpdatingController < ApplicationController def show post_updating = PostUpdating.find_by(id: params[:id]) render json: post_updating end def update post_updating = PostUpdating.find_by(id: params[:id]) # Update updated_at post_updating.touch if post_updating.update_attributes(post_updating_params) render json: post_updating, status: :ok else render json: post_updating.errors, status: :unprocessable_entity end end private def post_updating_params params.require(:post_updating).permit("user_id") end end
ひとつつまずいたのは、Railsはインスタンスが更新されていなかったらupdate_attributesなどでsaveされてもupdated_at
を更新しない、ということでした。
これは post_updating.touch
など、touchを使うことで解決できます。
React Componentを実装
setIntervalを使えば自動的にPostUpdatingの更新情報をupdateすることができそうです。
ReactでのsetIntervalの実用例はこちらとか参考になりました。
http://stackoverflow.com/questions/36299174/setinterval-in-a-react-app
const YourComponent = React.createClass({ ... componentWillMount() { var result = this.check_if_multiple_user_editing(); if(result == false){ this.heartbeat(); // 30 seconds setInterval(this.heartbeat, 30000); } }, // 記事の更新情報をupdateする heartbeat() { var result = $.ajax({ url: "/your/api/url/" + this.props.post_updating_id + "/", dataType: 'json', type: 'PATCH', timeout: 10000, data: { post_updating: { user_id: this.props.user_id, updated_at: Date.now() } }, success: function(result) { // console.log("throb!"); // debug }.bind(this), error: function(_xhr, status, err) { console.error(this.props.url, status, err.toString()); }.bind(this) }).responseJSON; return result; }, fetchPostUpdating(post_updating_id) { var result = $.ajax({ url: "/your/api/url/" + post_updating_id + "/", async: false, dataType: 'json', type: 'GET', timeout: 10000, success: function(result) { // console.log("Success"); }.bind(this), error: function(_xhr, status, err) { console.error(this.props.url, status, err.toString()); }.bind(this) }).responseJSON; return result; }, check_if_multiple_user_editing() { var result = this.fetchPostUpdating(this.props.post_updating_id); editing_date = new Date(result["updated_at"]); now = Date.now(); var diff = now - editing_date.getTime(); // 秒 var diff_second = diff / 1000; // 10秒以内にheartbeatにより更新されていて、編集ユーザが異なる場合は // 警告を出して前のページにリダイレクト if(diff_second < 10 && this.props.user_id != result["user_id"]) { alert("このページは他ユーザによって編集されています"); location.href = "/go/ahead/your/favorite/url"; } }, ... render: function() { return ( <div> <YourComponent /> </div> ); } });
これで、このComponentをmountしてるViewで、他の人が同時にアクセスすることを防ぐ機能を実装できました。わーーーい。
参考資料
Rails4で楽観的ロックを実装する - Rails Webook
Reactでロード中indicatorを表示するcomponentをRailsに組み込む
react-spinkitを使ってみます。
以下にデモも用意してくれてます。
classnamesとobject-assignというライブラリを使用しているので、こちらをnpmでインストールしておくのを忘れないように。
$ npm install classnames object-assign --save
ソース中ではclassnamesはcx, object-assignはassignとして使われてるので、どこかでrequireしておきます。
cx = require("classnames"); assign = require("object-assign");
こちらのソースから直接componentをassetsのディレクトリにぶち込みました。
https://github.com/KyleAMathews/react-spinkit/blob/master/src/index.jsx
そして、必要なcssファイルも一通りRailsのasset pipelineに乗るようにしておきます。
https://github.com/KyleAMathews/react-spinkit/tree/master/css
ここまでくれば後は、Viewを呼び出すだけです。hamlから直接このComponentを呼び出すなり、子componentとして呼び出すなり。
私の今回の場合だと、render()メソッドでreturnする内容をstateによって切り替えて、load中ならSpinner componentが挿されるような感じで使いました。
これでloadingのanimationを簡単に実装することができました。便利!
Railsで標準クラスへメソッドを追加してNumericの桁数を求めれるようにする
Numericクラスに、桁数を返すメソッドを追加したいと思ったので、Railsに乗っていて標準クラスをオーバーライドするプラクティスあるのかなーと思って調べてみましたが、特に決まったものはなさそうです。
ruby on rails - Railsで既存クラスへのメソッド追加をする時のファイルの置き場所 - スタック・オーバーフロー
In Ruby on Rails, to extend the String class, where should the code be put in? - Stack Overflow
ruby - adding a method to built-in class in rails app - Stack Overflow
ruby - In Rails, how to add a new method to String class? - Stack Overflow
In Ruby on Rails, to extend the String class, where should the code be put in? - Stack Overflow
ということで lib/core_ext/
以下にどんどんつくっていくような感じにしてみます。
# lib/core_ext/numeric.rb class Numeric def length self.zero? ? 1 : Math.log10(self.abs).to_i + 1 end end
# config/initializers/core_ext.rb Dir[File.join(Rails.root, "lib", "core_ext", "*.rb")].each {|l| require l }
数値の桁数を求めるメソッドはこちらを参考にさせていただきました。
これで桁数を返せるようになりました!わーーーい
pry> 3.length => 1 pry> 10000.length => 5