Webフロントエンドエンジニアの権守です。 今回は、iQONのWebアプリのAPIリクエスト部分の仕組みを改善したことについて紹介します。
前提
このブログでも何度か紹介していますが、iQONでは、ネイティブアプリとWebアプリの両方で、共通のAPIを利用して開発を行っています。 そのため、通常のRailsアプリケーションと異なり、iQONのWebアプリ版のモデル部分では、DBへのアクセスを行わずAPIへのアクセスを行い、データを取得します。 こういった形式を扱うGemとしてはherなどがありますが、iQONでは、完全にREST形式でない、並列でリクエストを行いたいなどの理由から自前で実装しています。
問題
しかし、このモデル部分には次の二つの問題がありました。
- APIリクエストの依存関係を記述できないため、実行タイミングを制御する必要がある
- APIリクエストのリクエスト処理とデータの取得処理を同時に記述できない
一つ目はパフォーマンスに、二つ目は可読性に問題があります。 擬似コードを例に問題について説明します。
旧リクエスト方式の擬似コード
Item.find(params[:id], :item) Item.recommend_items(params[:id], :recommend_items) IqonModel.execute @item = get_result(:item) # ... @itemを使う処理 ... @recommend_items = get_result(:recommend_items) Item.search({brand_id: @item[:brand_id]}, :brand_items) # itemのリクエストに依存 IqonModel.execute @brand_items = get_result(:brand_items)
上のような記述を行った場合、APIリクエストは次の画像に示すタイムラインのようになりえます。
この場合、依存関係を適切に考慮すると、次のようなタイムラインにでき、パフォーマンスは向上します。
この問題は偏に、実行タイミングをコードで制御していることが原因です。 理想的には、依存関係を考慮しつつ、並列度を最大化すべきです。
また、上の擬似コードでは大して気になりませんが、利用するAPIの量が増えるほど、各APIリクエスト処理と結果の取得処理などのコードの分離による可読性の劣化は著しくなります。
結果
上で述べた問題を解決し、次のように記述できるようにしました。
Item.find(params[:id], :item) do |find_results| @item = find_results.first # ... @itemを使う処理 ... Item.search({brand_id: @item[:brand_id]}, :brand_items) do |results| @brand_items = results end end Item.recommend_items(params[:id], :recommend_items) do |results| @recommend_items = results end IqonModel.wait_all_requests # 全スレッドの処理完了を待つ
また、実際に問題を解決したことで、ページによってはサーバーサイドの処理時間が40msほど短縮されました。
実装について
コールバック
APIリクエストの各メソッドにコールバックを設定できるようにしたことによって、リクエスト処理と結果取得処理が分離しなくなっただけでなく、APIリクエストの依存関係を明示的に記述できるようになりました。 各APIリクエストのメソッドに渡されたブロックがコールバックに相当します。
スレッド
旧リクエスト方式では、IqonModel.executeが実行されたタイミングで、それまでに呼び出されたリクエストをまとめて、並列にリクエストするというものでした。 しかし、今回の改善では、明示的なexecuteを利用しない代わりに、各リクエスト時にスレッドを生成するようにしました。これによって、並列にリクエストしつつ、executeのタイミングを制御する必要がなくなりました。 一方で、スレッドの完了を制御する必要があります。 これについては、IqonModel.wait_all_requestsメソッドでリクエストによって生成されるスレッドに対してjoin処理を実行することで制御しています。
APIリクエスト部分を簡単化したコード
class Item def self.find(id, label, &block) IqonModel.request(label, "/item/#{id}", block: block) end end
class IqonModel def self.init @request_threads = [] end def self.request(label, path, params: {}, method: 'GET', block: nil) @request_threads << Thread.new do body = request_api(label, path, params, method, timeout, must) results = body[:results].present? ? body[:results] : [] block.call(results, body[:info], body) if block # コールバックに相当 end end def self.wait_all_requests @request_threads.each(&:join) end end
このコードで注意すべき点は、コールバック内で再びAPIリクエストが行われた際には、@request_threadsに新たなスレッドが追加された後に、そのスレッドが終了することです。それによって、wait_all_requestsは全てのAPIリクエストの完了を待つことができます。
複雑な依存関係
ページによっては、複数のAPIリクエストに依存する処理も存在します。そのような場合にも対応できるように以下のような処理を実装しました。
IqonModel.register_callback([:item, :recommend_items]) do # :itemと:recommend_itemsのAPIリクエストに依存する処理 end
class IqonModel def self.init # ... @statuses = {} @callbacks = {} @callback_mutex = Mutex.new end def self.request(label, path, params: {}, method: 'GET', block: nil) @statuses[label] = :run # ... fire_callback(label) end def self.register_callback(labels, &block) @callbacks[labels] = block end def self.fire_callback(label) @callback_mutex.lock @statuses[label] = :done @callbacks.select { |labels, _v| labels.include? label }.each do |labels, block| block.call if labels.all? { |l| @statuses[l].present? && @statuses[l] == :done } end ensure @callback_mutex.unlock end end
指定されたAPIリクエストが全て完了した時点で渡されたブロック内の処理が実行するために、 Mutexを用いて、並列で実行されるAPIリクエスト処理を確実に一つずつ終了状態に切り替えています。
まとめ
Web APIを用いたアプリケーションで問題になりがちなリクエストの効率化について取り組みました。スレッドを使った並列化は場合によってはパフォーマンスの劣化につながることもありますが、今回のように、依存関係を適切に処理することで、パフォーマンスを向上させることができます。
最後に
VASILYでは、iQONをよりよくするために新しい仕組みを一緒に作っていけるような仲間を募集しています。少しでもご興味のある方は以下のリンク先をご確認ください。