Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

リリース8年目のサービスの1800個のERBファイルをViewComponentに移行した方法...

 リリース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
Tweet

Other Decks in Programming

Transcript

  1. SIROK, Inc. 自己紹介 片岡 直之 / Naoyuki Kataoka (  katty0324)

    株式会社シロク CTO Excel VBAでプログラミングを始める 大学院生の頃に出会った仲間と株式会社シロクを立ち上げ アプリ、SaaS、広告事業などを経て、現在はブランド事業 昨年からコーチングに興味をもってトレーニング中 先月に2人目の子どもが生まれて、「技術×事業×育児」で奮闘中 2
  2. 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
  3. 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 呼び出し時に変数を渡している それとは別に、インスタンス変数も 使う実装になっている
  4. SIROK, Inc. テンプレート内のロジックの多さ 複雑なロジックがテンプレート内に埋め込まれており、可読性・保守性が低下 <% browser = ContextManager.get_browser acquisition =

    browser.latest_acquisition partner = acquisition.partner %> <% if partner.present? %> <meta name="acquisition-partner" content="<%= partner %>" /> <% end %> ロジック 12 ビューの先頭で処理をしている例がある
  5. 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
  6. 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 テンプレートだけでなく、クラス を定義する 必要なパラメータを初期化引数で渡す
  7. 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のクラス属性を生成するロジックがビュー内に書かれている
  8. 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 テンプレートはメソッドや変数を呼び出すだけになる
  9. 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のテストを書けると知りました…。
  10. 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オブジェクトなので、一般的な単体テストの手法が応用可能 ロジック
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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ファイルをコレクションクラスに追加してリスト化する
  16. 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を正規化して扱う これによって、ファイルと呼び出しの同一性が取れる
  17. 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 抽象構文木を得られる
  18. 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タグはコメントに
  19. 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
  20. 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
  21. 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 もっと巨大なツリーになるビューもある
  22. 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 インスタンス変数、ローカル変数に加えて、子要素で使うパラメータも必要になる
  23. 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
  24. 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
  25. SIROK, Inc. 別系統の生成 app/views2というフォルダを作成 app/views app/views2 app/components/partial s spec/components/partia ls

    app/controllers 利用 テスト 自動生成 利用 52 どちらを使うかを切り替える 利用 既存のビュー実装 ViewComponentを使う ビュー実装
  26. 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
  27. 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のビューを使う
  28. 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が使われるようになる
  29. 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 あまり良い実装ではないが、とてつもなくパラメータの多い複雑なビュー…。