イミュータブルデータモデルへの取り組み with Ruby on Rails
こんにちは。リサーチ・アンド・イノベーションの中村(konk303)と申します。
いわゆる「railsおじさん」的な立場で、主にサーバーサイドの開発をしています。
Introduction
本稿ではQiitaのイミュータブルデータモデルと webアプリケーションにおける現実解にインスパイアされて、弊社でのイミュータブルデータへの取り組み(とその苦しみ)を紹介したいと思います。 qiita.com
イミュータブルデータモデルとは?
まるっと引用。
イミュータブルデータモデルと webアプリケーションにおける現実解 - Qiita
詳細はリンクに譲りますが、「履歴を全て残すようなデータ設計にし、 UPDATE を廃することで情報の追跡可能性を確保、堅牢な設計にする」モデリング手法です。 原則この手法に従うと、そうそう汚いモデルにはならないという優れもの(雑)
です。イベントが起こる度に新規レコードを積む方式。
実現したいこと
あるユーザーの特定イベント発生当時の属性情報を保存・閲覧可能にする
弊社のアプリCODEにはレシート・バーコードのスキャンを使った買物登録機能が、
またアンケートプラットフォームのMycommentにもアンケートに回答する機能があるのですが、
どちらも 「買物登録・アンケート回答当時のユーザー属性情報を使ってデータ分析をしたい」というオーダーが強くあります。
あるユーザーの2年前の買物は、現在の年齢でなく2年前当時の年齢でグループ化して分析したい、と。
これの実現のために、「ユーザー属性の情報を変更の度に履歴として保存する」仕組みにしています。
あまり重要視していないメリット
一方でこの辺はあまり意識していません。
UPDATEが廃止でき、レコードのsaveが素直になる
これが本来一番のメリットなのですが、
rails (というかrailsについてくるどんなテーブルでもPKはid:intに決まり
な割り切り)
に身を任せていると、普段それほどツラくないところではあります。
履歴が全て残せる
これも大きなメリットではあるのですが、
結局履歴が全部ある
が役に立つ場面はそれ程出てこない印象です。
ユーザーサポートで、特定のユーザーがいつからいつまで問題のあったバージョンのアプリを使っていたか
みたいな時に使われる程度。
実装
今回は単純にユーザーと住所(都道府県)の関係で説明します。
よく出てくるオーダーとしては
- 特定イベント(買物登録・アンケート回答)に紐づくイベント当時の住所を使ってデータ分析をしたい (使用量多い)
今現在東京に住むユーザーを抽出したい
(該当ユーザーにメッセージを表示したり) (使用量非常に多い)- 特定ユーザーの属性遷移履歴を確認したい (たまにのオーダー)
履歴を保持する必要は絶対にあるのですが、「大抵の場合必要なのは最新値のみ」も一方では現実で、
どう両立させるかを試行錯誤しています。
使用用途が分析なので、sqlレベルで解決したい
という気持ちもあります。
住所1カラムならUserにcurrent_prefecture
をキャッシュ値として保存してしまえば良いのですが、
実際の属性は何項目もあってキリがない状態です。
パターン1: まじめにイミュータブルにする・「最新の状態」はsubqueryで取得
subqueryを使って該当ユーザーの現在の住所
を取得するassociationを追加で定義しています。
user.current_address
でユーザーの現在の住所が取得できます。
modelの使い勝手としては非常に良いのですが、 データ数が増えると加速度的に遅くなるという重大な問題を抱えています (実際困ってます 😓 )。
class User < ActiveRecord::Base # id: integer has_many :user_addresses has_one :current_address, -> { latests }, class_name: 'UserAddress' has_many :purchase_events end class UserAddress < ActiveRecord::Base # id: integer # user_id: integer # prefecture: string belongs_to :user scope :latests, -> { where(id: unscoped.group(:user_id).select(arel_table[:id].maximum)) } end class PurchaseEvent < ActiveRecord::Base # id: integer # user_id: integer # user_address_id: integer belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address`かも belongs_to :user_address end
お題: 現在東京に住むユーザー一覧
User.joins(:current_address).where(user_addresses: { prefecture: 'tokyo' }) => "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` AND `user_addresses`.`id` IN (SELECT MAX(`user_addresses`.`id`) FROM `user_addresses` GROUP BY `user_addresses`.`user_id`) WHERE `user_addresss`.`prefecture` = 'tokyo'"
お題: 特定の買物登録の登録時のユーザー住所
PurchaseEvent.find(12345).user_address.prefecture
パターン2: 「最新の状態」と履歴でテーブルを分ける・最新の状態更新時に履歴側も書き込み
これは全然イミュータブルじゃないですね…。
ですが、履歴を残す
こそが実現したいことの場合は、結局コレが一番見通しがよく感じてます。
after_saveのcallbackにロジックを隠せたことで 「実際のビジネスロジックに履歴保存にまつわるノイズを残さない」と 「間違いなく1transaction内でデータの更新と履歴の登録を行う」が実現できてます 😎。
class User < ActiveRecord::Base # id: integer has_one :user_address has_many :user_address_logs, through: :user_address has_many :purchase_events end class UserAddress < ActiveRecord::Base # id: integer # user_id: integer # prefecture: string belongs_to :user has_many :user_address_logs after_save :create_log! private def create_log! user_address_logs.create!(slice(:prefecture)) if changed? end end class UserAddressLog < ActiveRecord::Base belongs_to :user_address has_one :user, through: :user_address end class PurchaseEvent < ActiveRecord::Base # id: integer # user_id: integer # user_address_id: integer belongs_to :user # strictにやるなら `belongs_to :user, through: :user_address_log`かも belongs_to :user_address_log end
お題: 現在東京に住むユーザー一覧
User.joins(:user_address).where(user_addresses: { prefecture: 'tokyo' }) => "SELECT `users`.* FROM `users` INNER JOIN `user_addresses` ON `user_addresses`.`user_id` = `users`.`id` WHERE `user_addresss`.`prefecture` = 'tokyo'"
お題: 特定の買物登録の登録時のユーザー住所
PurchaseEvent.find(12345).user_address_log.prefecture
まとめ
全然イミュータブルデータモデルの話じゃなく 「履歴を保存しつつ現在の状態をサクッと取得するのにどう苦労しているか」の話になってしまいましたが、 良い設計とパフォーマンスとのバランスを取るべく毎日試行錯誤しています。
リサーチ・アンド・イノベーションでは、railsを乗りこなしてやりたいことをサクッと実現していくエンジニアと
「そんなのは不毛だ・イミュータブルこそ未来だ・今すぐclojureとdatomicに移行しよう」と
熱く説得してくれるエンジニアの両方を募集しています 😏。
こちら からご応募お待ちしております。
参考
次世代バックグラウンドジョブシステム Faktory を試す
リサーチ・アンド・イノベーションの浜田(hamadu)と申します。 いつものお買い物がちょっとお得に、家計簿にもなるポイントアプリ「CODE」のサーバサイド、およびAndroidアプリの開発を担当しています。
序
CODE ではバックグラウンドジョブシステムとして、Sidekiq を採用しています。その作者、Mike Perham 氏が新しい仕組みを作っていました。その名も Faktory。Sidekiq と違いワーカーが言語に依存せず、また本体はGoで書かれているためスケールするのがウリのようです。まだまだ開発中で、プロダクションで使うには厳しい印象を受けますが、今後Sidekiqを置き換えうるプロジェクトになるのではと思います。
本稿では簡単に仕組みの紹介をして、Rubyでジョブを投げるClientと、ジョブを処理するワーカーをそれぞれ実装してみました。 ソースコードは faktory-ruby-sample にあります。「御託はいいから試したい」という人は README.md に従って手を動してみてください。
仕組み
一般にバックグラウンドジョブシステムは非同期に処理したいお仕事(本稿では ジョブ と表記)を受け付けて管理するサーバと、それらを処理するワーカーから構成されています。ワーカーは一般に複数あり、並列で仕事ができるようになっています。
ジョブがClientから飛んでくると、まずタスクキューに溜まります。ワーカーは暇な(処理しているジョブが無い)時、サーバに対してリクエストを投げ、キューに溜まっているジョブを取ります。
優先度、項目別にキューイング
Faktoryは(Sidekiqもですが)ジョブの種類ごとにラベル(優先度)が付けられるようになっています。ワーカーは処理するジョブのラベルを限定できるので、例えば優先して処理したいジョブは複数のワーカーで、そうでもないジョブは暇な時に適当なワーカーで、といったことができます。上の図で言うと Task Queue
に当たるものが複数あり、各ワーカーが取得するキューを選べるように設定できるイメージです。
ジョブのリトライ
ジョブの実行に失敗した場合、そのジョブはリトライ用のキューに積まれます。各ジョブごとにリトライできるまでの待機時間が与えられ、しばらく待たないと再実行できません。さらに、この時間は指数的に増えていきます。
Sidekiqと比べて何が良いか
外部に依存しないデータストア
Sidekiq はジョブを管理するデータストアとして Redis に依存しており、プロセスの起動に動いている Redis が必須でした。Faktory は 内部で RocksDB というkey-valueストアを用いていて、本体は1バイナリを走らせるだけで完結します。運用が楽ですね。
言語に依存しないワーカー
Sidekiq は Rubygem として提供されていて、良くも悪くも Ruby に依存していました。Rails との相性はいいのですが、他の言語からだと原則使えないのが残念です。Faktoryのサーバは規定の形式でTCP上で喋るようになっているので、どんな言語でもワーカーが書けます。公式では現在 Ruby と Go が提供されていますが、他にもサードパーティ製のワーカー が公開されています。
使ってみる
早速使ってみます。今回はRubyで、公式で提供されている gem を使ってバックグラウンドジョブを書いてみます。例としてURLの文字列を引数として受けると、そこへGETリクエストを投げ、結果を標準出力に書き出すものを作ってみます。
Faktoryのインストールと起動
Docker image が提供されている ので、それをまるっと使わせてもらいましょう。
# Docker imageをpullして $ docker pull contribsys/faktory # 起動させます。これはデバッグ用で、インメモリDBを使う設定になっています。 # 本番で使うときはジョブを保存するデータのパスを指定します。 $ docker run --rm -it -p 127.0.0.1:7419:7419 -p 127.0.0.1:7420:7420 contribsys/faktory:latest
Faktoryはデフォルトで7419、7420のポートを受け付けます。7419番はサーバとやり取りするAPIの入り口、7420番はWeb UIです。
Web UIを見てみる
ブラウザで localhost:7420
にアクセス。すると、Sidekiq を使ったことがある人には馴染み深い画面が表示されます。
サンプルジョブとクライアントを書く
早速ジョブとクライアントを書いてみましょう。今回はRailsのプロジェクトの中に組み込むのではなく、生のRubyで実装してみます。公式gemの faktory_worker_ruby を使います。本稿のサンプル実装は faktory-ruby-sample に置いてあります。
ジョブを書く
まず処理したいジョブを記述します。Rubyの場合、Faktory::Job
を include した上で本体である perform(*args)
を実装します。*args
はジョブ の引数です。Sidekiqを使ったことがあれば馴染み深いでしょう。
# fetch_url_worker.rb require 'faktory_worker_ruby' require 'open-uri' class FetchURLWorker include Faktory::Job def perform(*args) puts "Hello, I am #{jid} with args #{args}" url = args[0] open(url) do |f| puts f.readlines.join("\n") end end end
jid
はジョブの識別子で、後述するクライアントでジョブを投げる時に生成されます。
ジョブを投げるクライアントを書く
次にジョブを投げるクライアントを実装します。先程書いた FetchURLWorker
を require
して #perform_async
もしくは #perform_in
を呼ぶだけです。
#perform_async
は指定のジョブを即実行します。 #perform_in
は指定秒数後にジョブを実行します。
# client.rb require './fetch_url_worker' # ジョブを即実行 FetchURLWorker.perform_async('https://r-n-i.jp/') # 1分後にジョブを実行 FetchURLWorker.perform_in(60, 'https://notexist.r-n-i.jp/')
実行してみる
まずは手元でワーカーを起動します。
$ bundle exec faktory-worker -r fetch_url_worker.rb
すると、Web UIの「実行中」のところにプロセスが現れます。
この状態で、クライアントのスクリプトを走らせます。
$ bundle exec ruby client.rb
ジョブが2つ作成され、1つは即実行されます。もう1つはキューにたまった状態になり、Web UIの「予定」のところに入ります。
ジョブの失敗
遅延して実行される方のジョブはあえて失敗させてます。(URLが存在しません。) ジョブが失敗すると、「再試行」のキューに入ります。
暫く経つと再度走りますが、失敗し続けるはずです。その度に試行回数が増えていき、再実行までの時間が長くなるようになっています。このあたりもSidekiqと同じですね。
まとめ
Sidekiqと同じ使用感で回せそうだ、と分かりました。まだ本番運用するには危ないですが、Rubyではない趣味プロジェクトで人柱になってみるのはありかなぁと思ってます。ちなみに、Faktoryはtech系podcast:The ChangeLog で紹介されていたので知りました。Faktoryの回は こちら。バックグラウンドジョブシステムの設計思想、Amazon SQSとの住み分け、Sidekiqの裏話などが聞けるので興味があればぜひ。
リサーチ・アンド・イノベーションでは、プロダクト開発だけでなく、技術の情報収集にも積極的なエンジニアを募集しています。こちら からご応募お待ちしております。
参考URL
Clojureでブログやスマホアプリを作ってみる part1
こんにちは。リサーチ・アンド・イノベーションの小川(J-ogawa)と申します。
iOSアプリの開発とサーバサイドの開発をやっています。
私の記事はClojureがテーマです。
Clojure
なぜClojureなのか。
恐縮ながら個人的趣味に基づきます。Clojureはシンタックスが一貫しているのと、表現力が高い所がとても好きです。他にも色々といい点はあると思いますが、好きで採用しています。
数年前にClojure作者のRich Hickeyのプレゼンを見て、感銘を受け、勉強を始めた思い出があります。
まだまだClojureはマイナーで、弊社RNIでも主流はRuby, Ruby on Railsですが、Clojureを採用することも視野に入っていたりいなかったり。
- clojure.org翻訳ページ Clojureについて書かれてます。
- simple made easy Clojure作者 Rich Hickeyの2012年のプレゼン
- シンプルさの重要性 プレゼンの翻訳
Clojureフレームワークre-frameでブログを作成
Clojureを使ってブログを作成してみました。
フレームワークにre-frame
というSPAパターンを使用しています。
複数回に分けて、ブログのソースを追いながらClojure(re-frame)の解説を行いたいと思います。 Clojure製アプリのノリを少しでもお伝えできたらと思います。
また、ここから派生してスマートフォンアプリを作ることも予定しています。
ソースはコチラ https://github.com/r-n-i/blog (解説時は20170601タグ地点)
解説記事予定
全4回に分けて解説を行いたいと思います。
re-frame
re-frameというSPAパターンを使っています(内部でreactインタフェースのreagentを使用) 公式のdocsがとても充実していて、読み物としてもとても面白いです。
re-frameにおいて、画面表示の流れは以下となります。
dispatch -> update db(単一のデータ) -> subscribe -> update view
今回はこの流れについて解説します。
日本語での情報としては、こちらの解説もとてもわかりやすいです。
では、re-frame製ブログのソースの方を追っていこうと思います。
ビルドツール
Clojureでは、leiningenというビルドツールが主に使われます。 プロジェクト直下のproject.cljにはその設定を書きます。 また、このプロジェクトはleiningen templateのre-frame-templateより生成しています。
lein new re-frame <project-name>
で、re-frame用のプロジェクトの雛形が生成されます。railsでいうrails new
です。
起動
作成したブログのソースについては以下手順で起動を試していただけます。
- MySQLで、blogデータベースとblog@localhostユーザを作成
git clone https://github.com/r-n-i/blog
cd blog
lein migratus migrate
(マイグレーション)lein figwheel
サーバが立ち上がりhttp://localhost:3449で確認できます。 figwheelは自動でソースリロードしてくれる便利なツールです。
フォルダ構成
以下のようになっています。
├── Procfile ├── README.md ├── project.clj ├── resources │ ├── migrations │ └── public │ ├── css │ ├── index.html │ └── vendor └── src ├── clj │ └── blog │ ├── core.clj │ ├── handler.clj │ └── server.clj └── cljs └── blog ├── config.cljs ├── core.cljs ├── db.cljs ├── events.cljs ├── subs.cljs └── views.cljs
src
フォルダの中にソースを書いていきます。
clj
フォルダとcljs
フォルダがあります。
サーバサイドの方はclj(Clojure)、クライアントの方はcljs(ClojureScript)です。
今回はクライアントサイドについて解説をします。 具体的なコードが続きますが、要点だけ押さえていただければと思います。
クライアントサイド
re-frameはwebページとしてresource/public/index.html
を返します。
resource/public/index.html
<!doctype html> <html lang="en"> <head> <meta charset='utf-8'> <link rel="stylesheet" href="css/bulma.css"> </head> <body> <div id="app"></div> <script src="js/compiled/app.js"></script> <script>blog.core.init();</script> </body> </html>
cljs以下に書いたClojureScriptコードはコンパイルされて一つの.jsファイルになります。
<script src="js/compiled/app.js"></script>
でその.jsファイルを読み込み、blog.core.init()
を実行して画面をレンダリングしています。
これはsrc/cljs/core.cljsのinit関数を呼ぶ事に相当します。見てみましょう。
src/cljs/core.cljs
.... (defn ^:export init [] (re-frame/dispatch-sync [:initialize-db]) (dev-setup) (mount-root) (re-frame/dispatch [:get-entries]) (re-frame/dispatch [:auth]))
initにはいろいろ書いてますが、この中のmount-root
がviewを生成しています。
View
src/cljs/core.cljs
.... (defn mount-root [] (re-frame/clear-subscription-cache!) (reagent/render [views/main-panel] (.getElementById js/document "app"))) ....
このmount-root
内でレンダリング関数reagent/render
が<div id="app"></div>
の初期DOMに対してview/main-panel
をレンダリングしています。
(関数がhoge/fuga
となっている場合のhogeはnamespaceです)
画面についてのコードはsrc/cljs/views.cljsです。 大部分はhiccupというhtmlテンプレートで記述しています。re-frameはこのシンプルなhiccupを直感的に使えるのが魅力です。
最下部の関数main-panel
を見てください。
src/cljs/views.cljs
(defn main-panel [] (fn [] (let [error @(re-frame/subscribe [:error]) mode @(re-frame/subscribe [:mode]) show-login-modal @(re-frame/subscribe [:show-login-modal])] [:div [nav] [header] (when error [:div.notification.is-warning error]) (when (= mode :edit) [editor]) [entries] (when show-login-modal [modal]) ] )))
この関数がこのblogの全体部を構成しています。 div以下を見ていただくとなんとなく各パーツが配置されているんだなという感じがすると思います。
Subscribe
main-panel
のlet句でsubscribeをしています。
re-frameでは単一のデータを元にUIを管理しています。
そのデータについて、db
と呼んでいます。(紛らわしい気もしますね・・)
dbの変化を購読(監視)するのがsubscribe
です。それぞれのパーツが購読したイベントに関してだけ、データの変化を受け取ります。
main-panelは:error
, :mode
, :show-login-modal
の3イベントを購読しています。
イベントにはdbから値を返す関数が設定されていて、dbの変化に伴ってイベントが返す値も変化するときに、購読しているパーツに値(を持つatom)を返します。その際にパーツを再描画します。
ちょっとごちゃごちゃ何言ってるかわかりづらいですが、要はmain-panelは再描画される要因となるデータが3つあります
- db(単一のデータ)のerror部が更新されたら再描画
- エラー表示|非表示
(when error [:div.notification.is-warning error])
- エラー表示|非表示
- db(単一のデータ)のeditモード部が更新されたら再描画
- 記事編集モードの表示|非表示
(when (= mode :edit) [editor])
- 記事編集モードの表示|非表示
- db(単一のデータ)のログインモーダル表示モードが更新されたら再描画
- ログインモーダル表示|非表示
(when show-login-modal [modal])
- ログインモーダル表示|非表示
という感じです。
これら子供のパーツ(editorなど)もそれぞれが独自にsubscribeをできます。これによって、動的に変化する部分のスコープを狭くして把握しやすくできます。
次に、記事一覧パーツのentries
を見ていきます。
(defn entries [] (fn [] (let [entries @(re-frame/subscribe [:entries])] [:div (for [entry- entries] ^{:key entry-} [entry entry-])])))
これはentriesイベントを購読しています。 記事一覧もdbのentries領域が更新されることでentriesが再描画をして表示されています。
実は、このサイトは画面を一旦描画した後にentriesをAPIで取得して、取得後に画面更新が行われています(サーバサイドレンダリングを行なっていないため)
ブログの更新をしてみると表示で記事が一瞬遅れていると思います。
画面描画後に 記事一覧取得
-> dbのentries部更新
が行われています。
このようなdbに変化をもたらす処理はdispatchです。
Dispatch
dispatchについて、上記で紹介したblog.core.init()
(最初に呼ばれる関数)をもう一度見てみます。
src/cljs/core.cljs
.... (defn ^:export init [] (re-frame/dispatch-sync [:initialize-db]) (dev-setup) (mount-root) (re-frame/dispatch [:get-entries]) (re-frame/dispatch [:auth]))
mount-root
でUIレンダリングを行った後、(re-frame/dispatch [:get-entries])
というのをやっていると思います。このdispatchが、dbに変更をもたらす処理となります。画面に変更を起こすにはdispatchが必ず必要です。
dispatch
-> update db
-> subscribe
-> update view
となります。
src/cljs/views.cljsを見ると、domのon-clickの際にいろいろとdispatchしているのが見てもらえると思います。
というわけで、足早でしたがざっくりとした解説でした。
まとめ
dispatch
-> update db
-> subscribe
-> update view
がre-frameの基本的な流れだということをおさえていただければと思います。
シリーズもので恐縮ですが、次回はdispatchとsubscribeについてもう少し詳細に解説したいと思います。
リサーチ・アンド・イノベーションでは、新技術にアンテナを張り、プロダクトの改善にトライするエンジニアを募集しています。こちら からご応募お待ちしております。
リサーチ・アンド・イノベーション 開発者ブログはじめます
はじめに
リサーチ・アンド・イノベーション 開発部です。
「開発部」が名刺に刷られた正式部署名のはずなんですが
内部では「技術部」あるいは「System Development Division (略してSDD)」と呼ばれることが多いです。
名前なんて、別にいいですね。我々はエンジニアの集団です。
一方、弊社自体はエンジニア集団だけが集った会社ではありません。
色んな才能を持ったメンバーが寄り集まってそれぞれの知見を持ち寄り、以下のサービスを作っています。
Mycomment
ユーザが、クライアントから提供された様々なアンケートに答えて報酬を受け取るWebサービスです。
Ruby on Railsで開発しています。
アンケート内容は1問だけの簡単なものから90問ぐらいのヘビー級のものまで様々です。
(ヘビー級のアンケートは、報酬をがっつりGETできます)
Webであること活かして写真や動画をコメントと一緒に提出したりするアンケートもあります。
CODE
買物を登録するとポイントが貯まるスマートフォン向けアプリ(iPhone,Android対応)です。
レシートを撮影してバーコードをスキャンすると「いつ、何を買ったか」という買物情報が記録でき、バーコードの数に応じてポイントゲットのチャンスがあります。
CODEの面白いところは、「クエスト」と呼ばれるアンケートの仕組みです。
ユーザが買い物登録をすると、その商品に応じた「クエスト」がもらえることがあります。
「クエスト」はメーカーさんが自社商品等に設定することを想定していて、その商品の確実な購買者に対してアンケートを投げることができます。
ユーザは、このアンケートに答えてもポイントをゲットできます。
こうして「クエスト」で集めたデータはメーカーさんのマーケティングに活用し、商品の改善に役立てて頂いています。
SDDでは、CODEのサーバサイドをRuby on Railsで、iPhoneとAndroidのアプリをObjective-CとJavaで開発しています。 特にアプリに関してはそれぞれ Swift、Kotlin への移行が視野にあるので、 その辺りのことも今後本ブログでお話できればなと思っています。
これからさまざまな情報を発信していきたいと考えております。よろしくお願いいたします。