Share
Embed
リリース8年目のサービスの1800個のERBファイルをViewComponentに移行した方法とその結果
Kaigi on Rails 2024発表資料
私たちはリリースから8年目になるRailsアプリケーションを運用しており、その過程で1800個ものERBビューファイルを持つ規模に成長しました。
Partial ERBによる運用は、パラメータ定義の曖昧さや、テンプレート内に多くのロジックが記述されること、記述の一貫性が低いことで、実行時エラーの発生や開発効率の低下を招いていました。
私たちはPartial ERBをViewComponentに移行する決断をし、既存のビューファイルを自動変換スクリプトにより、移行しました。
このセッションでは、私たちの具体的な移行戦略とその成果を共有し、同様の課題を抱える開発者にとって役に立つ情報を提供します。
まず私たちの抱えていた課題について紹介します。
Partial ERBとViewComponentの比較をしながら、ViewComponent gemについて解説します。
続いて、1800個のERBファイルをViewComponentに移行するために、移行スクリプトを実装した過程について紹介します。
最後に、ViweComponentの導入によって得られた効果や、レンダリング時間の計測結果について紹介します。
https://kaigionrails.org/2024/talks/katty0324/
片岡 直之
October 25, 2024
Transcript
67 slides
-
SIROK, Inc. 自己紹介 片岡 直之 / Naoyuki Kataoka ( katty0324)
株式会社シロク CTO Excel VBAでプログラミングを始める 大学院生の頃に出会った仲間と株式会社シロクを立ち上げ アプリ、SaaS、広告事業などを経て、現在はブランド事業 昨年からコーチングに興味をもってトレーニング中 先月に2人目の子どもが生まれて、「技術×事業×育児」で奮闘中 2 -
SIROK, Inc. パラメータ定義の曖昧さ あるビュー要素を使う時、どのようなパラメータを渡して使用するのかが不明瞭 <%= render 'elements/common/headline', var1: xxx, var2:
xxx %> どんなパラメータを指定すべきか分からない 7 再利用できそうなビュー要素があるが... -
SIROK, Inc. パラメータ定義の曖昧さ: どのように判別するか? 呼び出し時に必要なパラメータを把握するために、erbファイルを読む必要 # app/views/elements/common/headline.html.erb <div class="pcArticleEyecatchWrap"> <div
class="pcArticleEyecatch"> <% if overlay %> <div class="ArticleEyecatchOverlay"></div> <% end %> <div class="ArticleEyecatchContent"> <h1 class="ArticleEyecatchTitle"><%= title %></h1> <% if description %> <span class="ArticleEyecatchDescription"><%= description %></span> <% end %> </div> <%= image_tag(image_url, alt: "#{title} アイキャッチ画像") %> </div> </div> これらが渡すべき変数 8 -
SIROK, Inc. 一貫性のないパラメータの渡し方 Railsのerbは、インスタンス変数で渡すことも、ローカル変数で渡すこともできる 結果、インスタンス変数やローカル変数の使用が混在 <%= render 'store_skin_questions/step4', f: form
%> # app/views/store_skin_questions/_step4.html.erb ... <div class="mb-96px"> <% @step4_options.each_with_index do |question, index| %> <div class="form-radio mb-24px"> <%= f.radio_button 'step4', index, class: 'form-radio__input', id: index %> <%= f.label index, question, class: 'form-radio__label' %> </div> <% end %> </div> ... ローカル変数 インスタンス変数 10 呼び出し時に変数を渡している それとは別に、インスタンス変数も 使う実装になっている -
SIROK, Inc. テンプレート内のロジックの多さ 複雑なロジックがテンプレート内に埋め込まれており、可読性・保守性が低下 <% browser = ContextManager.get_browser acquisition =
browser.latest_acquisition partner = acquisition.partner %> <% if partner.present? %> <meta name="acquisition-partner" content="<%= partner %>" /> <% end %> ロジック 12 ビューの先頭で処理をしている例がある -
SIROK, Inc. テンプレート内のロジックの多さ: 引き起こされる問題 その結果、特定の条件でエラーの発生 ビュー要素のロジックのテストをすることは容易ではない 全パターンを手動で検証したり、実行コストの高いE2Eテストで保証 <% browser =
ContextManager.get_browser acquisition = browser.latest_acquisition partner = acquisition.partner %> <% if partner.present? %> <meta name="acquisition-partner" content="<%= partner %>" /> <% end %> acquisition = nilでエラー 13 -
SIROK, Inc. ViewComponentの使い方: ビュー要素の実装の方法 ビュー要素ごとにクラスとテンプレートを記述 # app/components/message_component.rb class MessageComponent <
ViewComponent::Base def initialize(name:) @name = name end end # app/components/message_component.html.erb <h1>Hello, <%= @name %>!<h1> 17 テンプレートだけでなく、クラス を定義する 必要なパラメータを初期化引数で渡す -
SIROK, Inc. ViewComponentの使い方: レンダリングの方法 必要なパラメーターを渡して、ViewComponentインスタンスを初期化 renderメソッドで描画 使い方は、とても簡単 # app/views/demo/index.html.erb <%=
render(MessageComponent.new(name: "World")) %> <h1>Hello, World!</h1> 18 -
SIROK, Inc. ERB PartialsとViewComponentの比較 ViewComponentを使わず素のerbを使うことを「ERB Partials」と呼んで比較 ViewComponentのメリット • ロジックの分離 •
テストの容易さ • インタフェースの明示 • パフォーマンスの向上 19 -
SIROK, Inc. ロジックの分離 (ERB Partials) ERB Partials: テンプレート内にロジックが混在 <% color_class
= case color when 'red' 'border-red text-red' when 'black' 'border-black text-black' end %> <span class="border border-solid p-1 text-xs <%= color_class %>"><%= text %></span> ロジック 20 CSSのクラス属性を生成するロジックがビュー内に書かれている -
SIROK, Inc. ロジックの分離 (ViewComponent) ViewComponent: クラスファイルにロジックを書き、テンプレート内はメソッドの 呼び出しのみ class Common::Label::Component <
ViewComponent::Base def initialize(text:, color:) @text = text @color = color end def color_class case @color when 'norganic-red' 'border-red text-red' when 'black' 'border-black text-black' end end end <span class="border border-solid p-1 text-xs <%= color_class %>"><%= @text %></span> ロジック 21 テンプレートはメソッドや変数を呼び出すだけになる -
SIROK, Inc. テストの容易さ (ERB Partials) ERB Partials: コンポーネント単位のテストは可能 describe 'common/_label.html.erb',
type: :view do it do render locals: { color: 'red', text: 'hello world' } expect(rendered).to match /border-red text-red/ expect(rendered).to match /hello world/ end end 22 実は私は知らなくて、今回のセッションのために調べていて、ERBのテストを書けると知りました…。 -
SIROK, Inc. テストの容易さ (ViewComponent) ViewComponent: コンポーネント単位だけでなく、メソッド単位でのテストも可能 describe Common::Label::Component, type: :component
do it do render_inline(described_class.new(color: 'red', text: 'hello world')) expect(page).to have_css '.border-red.text-red' expect(page).to have_text 'hello world' end end 23 describe Common::Label::Component, type: :component do it do color_class = described_class.new(color: 'red', text: 'hello world').color_class expect(color_class).to eq 'border-red text-red' end end ViewComponentは、Rubyオブジェクトなので、一般的な単体テストの手法が応用可能 ロジック -
SIROK, Inc. インタフェースの明示 (ERB Partials) ERB Partials: どのような変数が必要かは実装を読まないと分からない インスタンス変数でのパラメータの受け渡しは暗黙的なので、下位層まで見る必要 必要なパラメータの正確な判別は困難
<% color_class = case color when 'red' 'border-red text-red' when 'black' 'border-black text-black' end %> <span class="border border-solid p-1 text-xs <%= color_class %>"><%= text %></span> 渡すべき変数 24 -
SIROK, Inc. インタフェースの明示 (ViewComponent) ViewComponent: コンストラクタ引数でインタフェースが明示される 初期化引数を見れば十分 必要なパラメータは、これ以上でもこれ以下でもない module Common
module Label class Component < ViewComponent::Base def initialize(text:, color:) @text = text @color = color end end end end 25 -
SIROK, Inc. パフォーマンスの向上 (ERB Partialsの検証コード) ViewComponentのサイトには10倍高速という記述 大量の要素を表示する計測を行った 10,000個のビュー要素の呼び出し # app/views/common/_text.html.erb
<%= text %> <%= render 'common/text', text: 'hello world' %> <%= render 'common/text', text: 'hello world' %> <%= render 'common/text', text: 'hello world' %> # ...10,000個の要素 26 -
SIROK, Inc. パフォーマンスの向上 (ViewComponentの検証コード) 同じ表示をViewComponentを使って構築 <%= render Common::Text::Component.new(text: 'hello world')
%> <%= render Common::Text::Component.new(text: 'hello world') %> <%= render Common::Text::Component.new(text: 'hello world') %> # ...10,000個の要素 # app/components/common/text/component.html.erb <%= text %> # app/components/common/text/component.rb module Common module Text class Component < ViewComponent::Base def initialize(text:) @text = text end end end end 27 -
SIROK, Inc. パフォーマンスの向上: 検証の結果 大量のビュー要素を表示する計測を行った結果 ERB Partials: 401ms ViewComponent: 57ms
ViewComponentの方がレンダリング時間が大幅に短縮された # ERB Partials Completed 200 OK in 401ms (Views: 401.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 61.2ms) # ViewComponent Completed 200 OK in 57ms (Views: 56.7ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 7.7ms) 28 -
SIROK, Inc. 既存のERB PartialsをどうやってViewComponentに移行するか? ViewComponentの利用はメリットが多いことは分かった しかし、ビューファイル数は膨大 既存のコードベースをどのように移行するか? 29 $ find
app/views -name '*erb' | wc -l 1812 -
SIROK, Inc. 自動生成スクリプト 実際に構築した自動生成スクリプトの概要について紹介します 解析のフェーズ 生成のフェーズ erbの一覧化 erbのパース renderメソッドの探索 パラメータの認識
rubyファイルの生成 erbファイルの生成 rspecファイルの生成 テストデータの生成 33 -
SIROK, Inc. erbの一覧化 app/views 配下の .html.erb を探索すればよい partial_collection = PartialCollection.new
Dir.glob(Rails.root.join('app/views/**{,/*/**}/*')).uniq.each do |f| next unless f.end_with?('.html.erb') partial_collection.add(Partial.new(f)) end 34 検索して見つかったerbファイルをコレクションクラスに追加してリスト化する -
SIROK, Inc. erbの一覧化: ファイル名の正規化 一覧のファイル名と、renderの呼び出しは、必ずしも一致しない 相対パスで指定する場合や、ファイル名にアンダースコアがあるため そこで、erb名を正規化して管理する <%= render 'page/button'
%> <%= render 'button' %> ファイル名 正規化後 app/views/page/index.html.erb page/index app/views/page/_button.html.erb page/button 呼び出し 正規化後 render 'page/button' page/button render 'button' page/button 35 ファイルや呼び出しのerbを正規化して扱う これによって、ファイルと呼び出しの同一性が取れる -
SIROK, Inc. erbのパース erb内でrenderメソッドを呼ぶ位置を特定する 当初は正規表現でマッチさせていたが、ブロックをもつ場合などはマッチが困難 Ruby Parserを用いて解析 node = Parser::CurrentRuby.parse(code)
(begin (lvasgn :color_class (case (send nil :color) (when (str "red") (str "border-red text-red")) (when (str "black") (str "border-black text-black")) nil)) (lvar :color_class) (send nil :text)) 36 抽象構文木を得られる -
SIROK, Inc. erbのパース: パースするための前処理 erbそのものは、Rubyコードとしてパースできない 事前処理としてHTMLタグを除去することで、Rubyコード部分のみをの取り出す HTMLタグ部分はコメントに置換して、処理後に逆変換できるようにしておく #html_code:1 color_class =
case color when 'red' 'border-red text-red' when 'black' 'border-black text-black' end #html_code:2 color_class #html_code:3 render 'common/text', text: text #html_code:4 <% color_class = case color when 'red' 'border-red text-red' when 'black' 'border-black text-black' end %> <span class="border border-solid p-1 text-xs <%= color_class %>"> <%= render 'common/text', text: text %> </span> 37 HTMLタグはコメントに -
SIROK, Inc. renderメソッドの探索 抽象構文木から、renderの位置を特定 前処理したerbをRuby Parserで抽象構文木に変換する #html_code:1 color_class = case
color when 'red' 'border-red text-red' when 'black' 'border-black text-black' end #html_code:2 color_class #html_code:3 render 'common/text', text: text #html_code:4 (begin (lvasgn :color_class (case (send nil :color) (when (str "red") (str "border-red text-red")) (when (str "black") (str "border-black text-black")) nil)) (lvar :color_class) (send nil :render (str "common/text") (hash (pair (sym :text) (send nil :text))))) renderを探索 38 -
SIROK, Inc. renderメソッドの探索: 具体的な探索のプログラム 抽象構文木のノードを再帰的に探索してrenderを特定 見つかった場合は、コード中の位置情報を返し、後の処理に利用する # renderメソッドの位置を探索するメソッド def self.search_render_node(node,
parent_node = nil) if node.is_a?(Parser::AST::Node) if node.loc.is_a?(Parser::Source::Map::Send) && node.children[0].nil? && node.children[1] == :render return [ # renderの開始位置、メソッドの終了位置 (ブロックの開始位置 )、ブロックの終了位置、を配列にして返す [ node.loc.expression.begin_pos, node.loc.expression.end_pos, parent_node&.type == :block ? parent_node.loc.expression.end_pos : nil ] ] elsif node.respond_to?(:children) return node.children.flat_map.with_index { |child_node, i| search_render_node(child_node, i == 0 ? node : nil) } end end return [] end 39 -
SIROK, Inc. erbの依存関係の把握 ビューの依存関係や、どのようなパラメータを渡しているかを把握する必要 renderを呼び出す側、呼び出される側を把握してツリーを作る この際に、どこからも呼ばれていないerbが発見され削除 # あるビューのツリー構造 dashboard/products/index └
dashboard/elements/breadcrumbs/product │ └ dashboard/elements/breadcrumbs/base └ dashboard/dashboard_tags/search_box └ dashboard/elements/daterange └ dashboard/elements/pager └ dashboard/elements/reports/head 40 もっと巨大なツリーになるビューもある -
SIROK, Inc. パラメータの認識 各ViewComponentの初期化引数に渡すパラメータを特定する必要 erbファイルで使用されているインスタンス変数や、renderメソッドで渡されている ローカル変数を把握 # パラメータを特定するメソッド def arguments
(local_variables + instance_variables + child_variables).uniq end def child_variables @child_partials.flat_map { |child_partial| child_partial.instance_variables + child_partial.child_variables }.uniq end 41 インスタンス変数、ローカル変数に加えて、子要素で使うパラメータも必要になる -
SIROK, Inc. rubyファイルの作成 解析結果を元に、必要なコードを生成 必要なパラメータをキーワード引数として持ち、インスタンス変数に代入するとい うメソッド # 初期化メソッドを生成するコード arguments_code =
@partial.arguments.map do |argument| "#{check_reserved_parameter(argument)}: nil" end.join(", ") initialize_code = @partial.arguments.map do |argument| "@#{argument} = #{check_reserved_parameter(argument)}" end.join("\n") constructor_code = <<~EOT def initialize(#{arguments_code}) #{indent(initialize_code)} end EOT 42 -
SIROK, Inc. 一部のビューは追加の考慮が必要 • ブロックを持つビュー要素の場合は、Slots(renders_one)の設定を追加 • erb内でHelperのメソッドを用いている場合は、delegateを追加 • ViewComponentはcontentという引数を予約しているので、contentというパ ラメータは改名が必要
rubyファイルの作成: 生成されたファイルの例 class Partials::Dashboard::Elements::Breadcrumbs::Product::Component < ViewComponent::Base renders_one :children def initialize(product: nil) @product = product end end 43 -
SIROK, Inc. erbファイルの作成: 変換されたerbの例 <%= render 'dashboard/elements/breadcrumbs/base' do %> <li
class="breadcrumb-item"><a href="/dashboard/products">商品</a></li> <% if product&.persisted? %> <li class="breadcrumb-item"><a href="/dashboard/products/<%= product.id %>"><%= product.name %></a></li> <% end %> <%= yield %> <% end %> <%= render Partials::Dashboard::Elements::Breadcrumbs::Base::Component.new() do |c| c.with_children do %> <li class="breadcrumb-item"><a href="/dashboard/products">商品</a></li> <% if @product&.persisted? %> <li class="breadcrumb-item"><a href="/dashboard/products/<%= @product.id %>"><%= @product.name %></a></li> <% end %> <%= children %> <% end end %> renderの書き換え 変数の書き換え 45 -
SIROK, Inc. rspecファイルの作成 テストの内容は、エラーなくレンダリングができるかどうかのみ カバレッジは目指さない方針 生成元のerbは本番で動作中のものなので動作は保証されていると考える 初期化引数に、適切な値を渡す必要がある RSpec.describe Partials::Dashboard::Elements::Breadcrumbs::Product::Component, type:
:component do before do # 前処理 end example '表示できる' do product = build(:product) component = described_class.new(product: product) render_inline(component) end end 46 -
SIROK, Inc. テストデータの生成 パラメータの変数名をもとに、どのようなオブジェクトを渡すべきか推測して渡す 特定のビュー要素だけで使うような特殊なテストデータの場合は、変数名だけでな くerbのファイル名も利用して分岐をする def test_data(variable_name) TestData.new(@partial.canonical_name, variable_name).code
end def factory_code(name) case name # ... when 'product' return <<~EOT.chomp build(:product) EOT # ... end return "build(:#{name})" end 47 productという変数名に対するテストデータ のコードを生成 -
SIROK, Inc. 自動生成スクリプトとテストデータの微調整 ViewComponentのコードを生成をしてrspecを実行 失敗したら、自動生成スクリプトを修正するか、テストデータを修正 rspecがパスするまで繰り返す この微調整が、作業工数の8割…。 • 記法の一貫性のなさのため、イレギュラーの対応が必要 •
ビュー内でActiveRecordを利用している箇所で、複雑なテストデータが必要 • partialを前提としたgemの利用 個別対応が必要 48 -
SIROK, Inc. 新しいビュー実装の組み込み 生成したViewComponentを実際にプロダクションコードに組み込む 安全にリリースをするために、段階的にリリースする方法を検討 • 別系統の生成 • view_pathの分岐 •
フィーチャーフラッグによる検証 • カナリアリリース 51 -
SIROK, Inc. 別系統の生成 app/views2というフォルダを作成 app/views app/views2 app/components/partial s spec/components/partia ls
app/controllers 利用 テスト 自動生成 利用 52 どちらを使うかを切り替える 利用 既存のビュー実装 ViewComponentを使う ビュー実装 -
SIROK, Inc. 別系統の生成: app/views2の中身 app/views: ERB Partialsを利用する今までの実装 app/views2: ViewComponentを利用する新しい実装 #
app/views/dashboard/products/index.html.erb <% content_for(:title) { '商品の一覧' } %> <div class="overflow-x-scroll"> <!-- 略 --> <!-- 略 --> </div> # app/views2/dashboard/products/index.html.erb <% content_for(:title) { '商品の一覧' } %> <%= render Partials::Dashboard::Products::Index::Component.new(products: @products, product: @product, ...)%> 53 -
SIROK, Inc. view_pathとは app/viewsとapp/views2の使い分けをする前に、Railsのビューの解決について Railsがビューの解決をする場合、view_pathsを順に探索していく ApplicationController.view_paths.map{|path| path.to_s} => ["/path/to/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/view_component-3.18.0/app/views",
"/path/to/vendor/bundle/ruby/3.2.0/gems/turbo-rails-2.0.11/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/actiontext-7.2.1.1/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/actionmailbox-7.2.1.1/app/views"] 54 app/viewsが上位にいるので基本的にはapp/viewsのビューを使う -
SIROK, Inc. view_pathの分岐 どのようにview_pathsを操作するか? append_view_path, prepend_view_pathメソッドを呼ぶ prepend_view_path で app/views2を追加することで、ViewComponent版の ビュー実装に切り替わる
prepend_view_path 'app/views2' ApplicationController.view_paths.map{|path| path.to_s} => ["/path/to/app/views2", "/path/to/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/view_component-3.18.0/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/turbo-rails-2.0.11/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/actiontext-7.2.1.1/app/views", "/path/to/vendor/bundle/ruby/3.2.0/gems/actionmailbox-7.2.1.1/app/views"] 追加される 55 app/views2が上位にくると、app/views2が使われるようになる -
SIROK, Inc. フィーチャーフラッグによる検証 フィーチャーフラッグ: 特定の人のみに新機能をリリースすること 特定のCookieを持つ人に対して、app/views2を選択 まず社内で動作を確認 また、Cookieの有無でレスポンスを取得してdiffを確認 before_action ->
{ prepend_view_path 'app/views2' if cookies[:feature_flag] == 'view_component' } 56 -
SIROK, Inc. カナリアリリース カナリアリリース: 一部のユーザーのみに先にリリースすること 10%の確率で、app/views2を選択 1%から開始して、ログなどを確認しながら、比率を上げていく before_action -> {
prepend_view_path 'app/views2' if rand < 0.1 } 57 -
SIROK, Inc. コンポーネント呼び出しのインタフェースが分かりやすくなった Before: どのように呼び出すべきかはerbをすべて読まないと分からない After: コンストラクタを見れば分かる 複雑なビューであっても一目でパラメータを把握できる class Partials::Sirok::Media::Ads::Contents::Components::Component
< ViewComponent::Base include OptimizeHelper include ViteHelper def initialize(components: nil, orders: nil, model: nil, extra: nil, replaced_link: nil, lazy_load: nil, is_content_editable: nil, survey_redirect_paths: nil, product_id: nil, type: nil, campaign: nil, campaign_user: nil, order: nil, is_preview: nil, is_lazy: nil, amazon_pay_redirect_path: nil, ad: nil, upsell_impression: nil, store_types: nil, stores: nil, coordinates: nil, marker_data: nil, direct_captureable: nil) # ...略... end end 60 あまり良い実装ではないが、とてつもなくパラメータの多い複雑なビュー…。 -
SIROK, Inc. パフォーマンスはどうなったのか? 50%の確率で分岐させて、ABテスト 実計測での差はわずか app/views: 平均 93.4ms app/views2: 平均
93.1ms ビュー内でActiveRecordのクエリ呼び出しがあり、クエリ時間が支配的 65