Railsに基づくWebサービスの全体像 〜REQLYを実例に〜
はじめに
REQLYでは、料理レシピ検索サービスをWeb上で提供しています。こうしたWebサービスはどういうように作られているのでしょうか。なかなかWebサービスの全体像というのは、外部からうかがい知ることはできないものです。そこで今回の記事では、REQLYがどのように成り立っているのかという技術構成を紹介します。
REQLYの技術基盤
REQLYでのレシピ検索の手順を簡単に説明すると、以下の手順になります。
すなわちREQLYは、以下の5つのサービスから構成されていることになります。
- Ruby on Railsは、REQLYを提供する基本的なバックエンドサーバです。クライアントからのリクエストを解釈し、処理し、適切なレスポンスを行います。
- PostgreSQLは、レシピのメタデータ(材料一覧やレシピサイト名など)を保存しておくためのデータベースです。
- Elasticsearchは、オープンソースの全文検索エンジンです。材料名やレシピ名をクエリとして検索すると、ヒットしたレシピが類似度スコアと共に返されます。
- ランキングサーバーは、Rustで独自に実装した、検索結果のさらなる絞り込みと並び替えを行うサーバーです。Elasticsearchの類似度スコア、検索フィルタとのマッチ度、人気度など複数の指標を総合的に考慮し、最終的な検索結果とその順番を決定します。
- フロントエンドの中で一部の動的なコンポーネントはReactで実装されています。
モノリシックなRailsサービスが、デーモンとして動いている各種サービスと適宜通信して、クライアントからのリクエストに応答しています。
Railsと各サービス間の連携
ここからは、上の図で示したそれぞれのサービスとRailsがどのようにして連携しているのか、具体的に掘り下げて見ていきましょう。
1. Elasticsearchはsearchkickを介して連携している
Elasticsearchは、Javaで実装された全文検索エンジンで、APIを通してリクエストを叩くと検索結果が返ってきます。
例えばHTTP APIでは、jsonをbodyとして渡してリクエストを発行すると、jsonが返ってくるという仕様になっています。ですから、APIの挙動を確認する最もシンプルな方法は、curl
コマンドを使って手書きのリクエストを送ってみることです。例えば、Elasticsearchがlocalhostの9200番ポートに立っていて、そのデータベース名がrecipes_developmentのときに、その中でタイトルに「寿司」が含まれるものを取得するクエリを実行するには、以下のように行います。
$ curl http://localhost:9200/recipes_development/_search?pretty -H 'Content-Type: application/json' -d '{"query":{"dis_max":{"queries":[{"match":{"name.analyzed":{"query":"寿司","analyzer":"searchkick_search"}}}]}}}'
Elasticsearchに対するクエリは、基本的にはjsonでコードできるドメイン固有言語で記述する必要があります*1。複雑なクエリを構成することで、検索結果に対する重み付けや絞り込み条件を付与することができますが、複雑なクエリを手書きするのはElasticsearch側の十分な知識が必要とされるので、ここでは必要最小限のrubyコードからよしなにElasticsearchのクエリを生成してくれるようなラッパーライブラリを利用しました。
HTTPリクエスト・レスポンスをrubyのメソッド呼び出しとしてラップしているElasticsearchのRuby APIに対して、searchkick
は、データベースに対応づけられたRubyのクラスとの対応付けを容易にした、より抽象度の高いラッパーライブラリです。searchkick
を用いて、以下のように設定しています。
- index時には、レシピidと、そのレシピに関連するデータベースのレコードをドキュメントとしてElasticsearchに取り込ませます。
- 検索時には、Elasticsearchに検索対象の文字列を投げ、類似度スコアとレシピidの順序付き集合を受け取ります。
Elasticsearchが返すのは、Elasticsearchのスコアに基づいたRecipeIDの順とスコアです。この情報をもとに、下のランキングサーバにレシピIDとスコアを渡しています。
2. ランキングサーバーをHTTPプロトコルで呼び出している
ランキングサーバーは、Rustで実装した独自のものです。これはHTTPで他のプロセスと通信するようなwebサーバとして実装しました。ランキングサーバーはjsonを受け取ってjsonを返すサーバーとして実装しているので、結果を得るためにPOSTメソッドでjsonを送ると、jsonがレスポンスとして返ってくるという形になっています。 ですから、これに対しては本質的にはcurlコマンドを叩くことを行えばよいので、Railsの内部においては薄いlibcurlのラッパーを利用しています。具体的には、Rubyの連想配列オブジェクトをjsonに変換してテキスト情報としてリクエストを送って、帰ってきたテキスト情報としてのjsonを再びRubyの連想配列に戻すということを行っています。こうしたサーバーをHTTPプロトコルに基づくWebサーバーとして実装したのは、HTTPがハイパーテキストで表現できるような情報をやりとりする汎用プロトコルであるからにすぎません。
ランキングサーバには、Elasticsearchによって絞り込まれたレシピIDと、検索フィルタの内容を送っています。ランキングサーバ内で、オンメモリの特徴量情報からレシピIDに対して再び総合的なスコアを振り、それをもとに絞り込みと並び替えを行い、結果としてレシピIDを返します。
3. PostgreSQLはActiveRecordを介して連携している
バックエンドのデータベースとして、RailsではPostgreSQLを利用しています。こうしたデータベースに入っているデータに対して、どのようにアクセスすればよいでしょうか。このときランキングサーバーのように、ほとんど生のAPIを触るようなプリミティブなラッパーを使うことをまずは考えてみましょう。
SQL.execute("INSERT INTO items VALUES #{values}")
itemsテーブルに問い合わせをするためにはこのようなsql文を書けばいいかもしれません。では、このvaluesという変数に入っている取得したい値の情報は、どのようにSQL文にマッピングすればよいでしょうか。更にいえば、SQLから返ってきたitemテーブルの要素は、どのようにruby側で処理すればよいでしょうか。
+----+-----------+-------------+----------------------------+-------------------------+-------------------------+----------+---------+ | id | name | description | url | created_at | updated_at | name_ja | name_en | +----+-----------+-------------+----------------------------+-------------------------+-------------------------+----------+---------+
こうしたデータベースのテーブルに対応したクラスがRuby側にあれば、DBのそれぞれのレコードが、そのクラスのインスタンスに対応するようにできると考えられます。
class Items attr_accessor :id, :name, :description, :url, :created_at, :updated_at, :name_ja, :name_en end
つまり具体的には、Rubyの側にSQLのレコードに対応した、受け取る結果を入れておくためのクラスを作り、返ってきたメッセージとそれとの対応付けを行うということを自動でやってほしいのです。なぜならばこの処理は、SQL側のテーブルのスキーマを決めることができれば、それとのrubyのオブジェクトの対応づけはある程度、機械的に行うことができるからです。
- Rubyの「クラス」と、DBの「テーブル」が対応するとする。これはExcelのシートに対応する。
- そのクラスの「インスタンス変数」が、DBの「カラム」に対応する。Excelにおける列に対応する。
- そのクラスのインスタンスオブジェクトが、DBの「レコード」に対応する。Excelにおける行に対応する。
こうした処理を行ってくれるライブラリのことを総称して、オブジェクトリレーショナルマッパー(ORM)と呼びます。Railsでは生のSQLラッパーよりも高機能に、RubyのオブジェクトとDBを対応づけるようなライブラリを利用しています。これはActive Recordと呼ばれるライブラリであり、SQLの1テーブルがRubyの1クラスに、SQLの1レコードがRubyの1クラスにおける1インスタンスになるように対応づけることで、あたかもRubyのオブジェクトであるかのようにデータベースの中身を扱うことができるのです。
Active Recordというgemは、{PostgreSQL, MySQL, SQLite3} などのデータベースサーバとソケット通信して、DSLによって記述されたクエリをSQL文に変換してSQLサーバにリクエストを送り、返ってきたSQLのレコードをRubyのクラスに対応づけることができます。例えば、REQLYの内部には、レシピを管理するためのRecipeクラスがあり、その中にすべてのメタデータが入っています。
[1] pry(main)> item = Recipe.where(time: 30).first Recipe Load (0.6ms) SELECT "recipes".* FROM "recipes" WHERE "recipes"."time" = $1 ORDER BY "recipes"."id" ASC LIMIT $2 [["time", 30], ["LIMIT", 1]] [2] pry(main)> item.expense => 300
このように、SQL文をラップするRubyのメソッドが提供されています。こうしたクエリをメソッドチェーンでつなげていくことで、SQL文を生で書かずに、複雑なクエリを構成することができ、そのクエリの戻り値をRubyのオブジェクトとして扱うことが可能になります。
PostgreSQLはデーモンとして動くSQLサーバーです。PostgreSQLではサーバとクライアント間の通信はINET Socket(TCP/IP)やUnix Socketを介して行われ、「フロントエンド/バックエンドプロトコル」と呼ばれる、その上で独自プロトコルで通信が行われています。Active Recordを用いると、こうした通信のプロトコルや生のAPIに触れることなく、開発者はSQLを抽象化して扱うことができるのです。
PostgreSQLとは、Unix Socketで接続することもできます。たとえば手元の開発環境ではUnix Socketで接続することもできますが、REQLYではdockerを用いてそれぞれのプロセスにつき1コンテナを設定しており、その間はTCP/IP通信でデータのやりとりをするようにしています。
Active Recordを利用してレシピIDの情報からPostgreSQLを介して、そのメタデータを得ることができます。抽象的には以下のようなコードで記述されます。
def filter_by_ids(ids) # ids: array Recipe.where(ids).limit(10) end
4. Railsとフロントエンドがreact-rails
を介して連携している
上のようにして、Active Recordを利用してレシピに関する情報をSQLからRails側にロードした後、その情報をHTMLとして書き出します。このときにREQLYでは、一部の動的なコンポーネントはReactを用いて描画しています。
Reactをなぜ選択したか、あるいはReactをどのようにRailsに統合すべきかについて行った技術選択については措きますが、このライブラリを利用すると、ReactとRailsを1つのプロジェクトルート内で開発することができます。
Reactにおける ReactDOM.render(element, container);
が、ビューの内部で呼ぶことのできる react_component(ComponentName, Props, Options)
というヘルパー関数に対応します。Railsのビューをエントリーポイントとしてpropsを渡すと、以後はreactの世界でコンポーネントを描画できるという形になっています。
実際に書いたReactのコードがどのように動作するかというと、webpack
を用いてクライアントサイドで動作するjavascriptにトランスパイルされます。そのjavascriptがクライアント上でレンダリングを行います。つまりクライアントに送られる情報はReactの内容がコンパクトになったjavascriptと、「どの場所にどういうPropsのReactのコードが埋め込まれるか」というHTMLデータが送られます*2。
まとめ
要点は以下です。