※この記事は、2024 Speee Advent Calendar11日目の記事です。 昨日の記事はこちら
こんにちは。株式会社Speee DX事業本部でエンジニアをしている川田と申します。 24新卒で、普段は主にRailsアプリケーションの開発業務にあたっています。
この記事では、所属するチームにおいてViewComponentというライブラリを導入し、一部改造して運用しているお話をします。 技術選定からその改造までを、開発の特性に合わせて行った例として、主に就活中の学生さんなどが開発業務への理解を深める助けになれば幸いです。
開発の特性と、純粋なRailsとのミスマッチ
私が所属するチームで行っている開発は、検証のサイクルが速いという特性があります。
我々の運営するサービス内の、特定のページに対して、Bizメンバーが様々な観点で施策を持ち込み、その実装をエンジニアが行います。
持ち込まれる施策は検証段階のものが多く含まれるため、後で差し戻したり差し替えたりの対応が頻繁に発生します。 そして、その施策の数も多いため、リリースの速度を高く保つ必要があり、 度重なる変更の中で負債を蓄積させて開発効率を落とすようなことはあってはなりません。
しかし、純粋なRailsが推奨するMVCモデルでは、コンテンツを作っては捨てて、を繰り返す中で、開発速度も品質も担保することが困難でした。 MVCモデルとは、アプリケーションを、データを処理するModel、コンテンツを表示するView、それらを適切に組み合わせて実行するControlerの3つの責務に分けて作ることでコードをシンプルに保てるという意見、と理解しています。 この原則に則ると、我々は、コンテンツを作る度にModel・View・Controlerのすべてに手を入れることとなります。 ところが、我々のサービスはそれなりに歴史が深く、影響範囲が広いと開発速度の低下やエラー発生を招きやすいです。
ViewComponentの導入
そこで、我々はViewComponentを導入することで、この問題を解決しました。 ViewComponentとは、テンプレートやヘルパの中に散らかりがちなViewの責務を持った実装を、コンポーネントとして切り出して取り扱うことができるライブラリです。
本来想定されている用途はあくまでViewの整理ですが、我々のチームではあえてMVCモデルを逸脱してデータフェッチなどの処理も盛り込み、一つのコンテンツの関心を一箇所にまとめることができる仕組みとして使用しています。
我々の運用における、単純なViewComponentの例を示します。
# app/components/otameshi/component.rb class Otameshi::Component < ViewComponent::Base def initialize # コンポーネントの引数の受け渡しなど end def otameshi_items Otameshi.limit(10) end end
<% # app/components/otameshi/component.html.erb (いつもはslim使ってます) %> <dl> <% otameshi_items.each do |item| %> <dt><%= item.name %></dt> <dd><%= item.description %></dd> <% end %> </dl>
<% # app/views/適当な/index.html.erb %> ... <%= render Otameshi::Component.new %> ...
コンポーネントとして、ViewComponent::Baseを継承したクラスを定義し、 render関数にそのインスタンスを渡すことで、 コンポーネント内のテンプレート(component.html.erb)が、そのクラス内部のスコープでレンダリングされます。
パーシャルと違い、完全に分離したスコープを持つため、よほど意図的でなければこのクラスのインスタンスメソッドを、このコンポーネント内部の処理以外から呼び出すことは起こりません。
そのため、このコンポーネントに依存した処理がアプリケーションに散らばることを予防し、 コンポーネントの追加・削除が用意に行えるようになります。
予期せぬエラーへの対策
コンテンツの開発速度を高く保つなかで、不具合を起こさないよう丁寧に作り込みつづけることは、努力すべきではあるものの現実的には困難です。
実装量が多く、コンテンツの幅も広いため、エッジケースを拾いきれていないままリリースしてしまうことがあります。 その一方で、どれか一つのコンポーネント内の処理でエラーを起こしたら、きめ細かくエラーハンドリングできていないと、500エラーとなってページ全体が表示されなくなってしまいます。
そのため、エラーが発生しても、なるべく小さな単位で処理を切り離し、ページ全体としてコンテンツを可能な限り表示できることが理想です。 また、そのエラーを早急に解消できるよう、状況がわかるようなログは残す必要があります。
この課題に対し我々は、ViewComponent::Baseを継承したクラスにエラーハンドリングの仕組みを組み込み、各コンポーネントでそれを継承することとしました。
# このクラスをViewComponent::Baseの代わりに継承する class ApplicationComponent < ViewComponent::Base # override def render_in(view_context, &block) safe_render do # 本来のレンダリング処理 super(view_context, &block) end end private def safe_render(&block) block.call rescue StandardError => e # 原因究明しやすい感じでエラーをログに残す '' end end
ViewComponent::Baseのインスタンスは、render関数に渡されることで、メンバ関数のrender_inが呼び出され、レンダリング結果のhtmlを返します。 (参照: https://viewcomponent.org/api.html#render_inview_context-block--string )
このrender_in関数をオーバーライドして、任意のエラーをrescueしてしまうことで、このコンポーネントの描画が失敗しても、他のコンテンツは引き続き描画されることが達成できました。
「コンポーネントキャッシュ」でページ読み込み速度を改善
開発が進むにつれ、コンテンツの数が増えていき、ページの読み込み速度が悪化しました。 各コンテンツはそれぞれ異なるデータソースを持っており、 その読み込みを中心として、ページ全体の表示までに必要な処理に時間がかかるようになっていきました。
最終的に、遅いところでTTFB(Time To First Byte=クライアントがリクエストを送信してから、サーバーからのレスポンスの最初の1バイトがクライアントへ到達するまでの時間)が3秒以上と、 ユーザー体験を著しく損ねるレベルに遅くなっていました。 そのため、パフォーマンス改善を行う必要がありました。
パフォーマンス改善にあたり、いくつか考慮すべきことがありました。 まず、コンテンツのデータソースによって、情報の更新頻度が異なっていました。 大きく分類すると、日次で更新するもの、四半期ごと更新するもの、更新タイミングが読めないものが存在していました。 そのため、例えば日次更新のコンテンツを週次でキャッシュする、などしてしまうと要件を満たせません。
また、先述の通りコンテンツの入れ替わりが激しいため、コンテンツ一つ一つの開発にあまり時間をかけられませんでした。
これらの条件から、 まず、ページ全体をキャッシュするアプローチは、CDNなどの設定だけで実現できるため手軽ではあるものの、コンテンツ個別の更新性の違いに対応できないため没としました。
また、更新頻度の高いコンテンツなどを、後から非同期で読み込むようにする案もありました。 しかし、読み込み時間によってユーザー体験を損ねないためには、結局コンテンツごとにパフォーマンスチューニングが必要になり、コンテンツ一つ一つに時間をかけないという制約に反していたため没にしました。
そして最終的に、コンポーネントごとにレンダリング結果をキャッシュし、個別にTTLを調整する対応をとることとしました。 以下に実装イメージを示します。
# このクラスをViewComponent::Baseの代わりに継承する class ApplicationComponent < ViewComponent::Base # override def render_in(view_context, &block) render_with_cache(view_context) do # 本来のレンダリング処理 super(view_context, &block) end end private def render_with_cache(view_context, &block) return block.call if cache_key.blank? Rails.cache.fetch(cache_key_common(view_context) + cache_key, expires_in: cache_expires_in) do block.call end end # キャッシュキーを返す 各コンポーネントでオーバーライドする def cache_key nil end # おまけ:キャッシュキーにコンポーネントのクラス名とPC/SPの判定は # だいたい必要になるのでデフォルトで含めている def cache_key_common(view_context) "#{self.class.name}/#{view_context.request.variant}/" end # キャッシュの有効期間を返す 各コンポーネントでオーバーライドする def cache_expires_in 1.week end end
これによって、cache_keyとcache_expires_inをオーバーライドするだけで簡単にキャッシュを導入できます。
遅かったページ内のコンポーネントに導入したところ、TTFBが3秒程度あったページが0.5秒程度にまで改善しました。
キャッシュを多く使用するという欠点がありますが、既存のシステムではキャッシュをあまり使用していなかったため、想定される容量を使っても余裕があり、問題ないと判断しました。
また、上のような実装だと、デプロイをまたいでキャッシュが共有され、変更が適用されないことが発生します。 これは、イメージのビルド時などに、適当な文字列を生成し、キャッシュキーに含めることで解決できます。 ただし以前のデプロイで作成された古いキャッシュが有効期限の満期まで残るため、デプロイにフックして削除する処理も必要に応じて実装するとよいでしょう。
目的を見失わずに、意味のあるチャレンジをする
一連の開発の中で、我々は常に、この機能・改善が何を目的として行っているのか、その手段は目的に対して適当であるか、を常に話し合えていました。 一人で開発していると、どうしても自分の開発の正当性を信じたいという思惑が生じて、目的を見失いやすいと私は考えています。 一方で、同じ目的を共有するチームメンバー全員が、互いの開発を客観視して意見を交換できていれば、目的を見失いづらいように思います。
目的に対してまっすぐ向き合うことで、チャレンジは価値を持ち続けます。 Speeeのエンジニアは、強固な目的意識を持った集団であること、 それによって価値あるチャレンジをし続けられていることが強みであると、私は思います。
Speeeでは一緒にサービス開発を推進してくれる仲間を大募集しています!
新卒の方はこちらより本選考に申し込みが可能です!
キャリア採用の方はこちらのFormよりカジュアル面談も気軽にお申し込みいただけます!
Speeeでは様々なポジションで募集中なので「どんなポジションがあるの?」と気になってくれてた方は、こちらチェックしてみてください!もちろんオープンポジション的に上記に限らず積極採用中です!!!