actionがrenderするファイルを自動で分岐させる

こんにちは。ラクマの岸です。

ラクマでは、既存Webフロントエンドをよりモダンな技術にリプレースしようという動きが加速しており、サービスが発足した2012年から使われているjQueryベースのフロントエンドをVue.jsに置き換えようとしています。

www.wantedly.com

さて、我々が使っているサーバーサイドフレームワークはRuby on Railsですが、webpackのラッパーgemであるwebpackerは使わずに、素のwebpackでビルドされた純粋なフロントエンドリポジトリをsubmoduleとして取り込んで使っています。

(後に、フロントエンドを1マイクロサービスとして切り出すことも視野に入れてこうしています)

これから様々なページを完全にVue.jsで作られたページ(正しくは <div="app"> 以下のDOM)に置き換えていきますが、いきなり100%公開すべきでしょうか? 否、私たちのコアバリューの一つである「Fail Smart」的にはユーザーのみなさんに極力迷惑をかけるわけにはいきません。

私たちのコアバリューは、キャリアサイトをご覧ください😉

careers.fril.jp

ということで

Vue.jsで作られたページを小規模に公開し、既存のRailsでサーバーサイドレンダリング(以下SSR)されたページとしばらく共存する方法を検討します。

手っ取り早く

まどろっこしい解説は嫌いです。まずは結論をササッと書いちゃいましょう。

Controllerで使えるConcernを作成します。

app/controllers/concerns/vue.rb

module Vue
  # rubocop: disable Metrics/MethodLength, Metrix/AbcSize
  def self.included(base)
    base.class_eval do
      cattr_accessor :vue_actions

      private

      def self.vue(*vue_actions)
        self.vue_actions = vue_actions.map(&:to_sym)
      end

      def render(*args)
        return super unless action_name.to_sym.in?(self.class.vue_actions) && vue?

        lookup_context.variants = ['vue']
        context, options = args
        options = (options || {}).tap { |o| o[:layout] = false }
        super(context, options)
      end

      def vue?
        // add your own balancing logic
      end
    end
  end
  # rubocop: enable Metrics/MethodLength, Metrix/AbcSize
end
  • Controllerから vue :fuga, :piyo と複数渡されたaction名に対して、lookup_context.variants = ['vue']+vue の付いたテンプレートファイルを探しに行くよう設定を追加しています。
  • リクエストが指定されたactionを実行する場合、layout:false でレイアウトをOFFにできるよう、render メソッドをオーバーライドします。
  • また、vue? メソッドでは、ご自身の環境で最適なVue.jsとSSRを振り分けるための判定メソッドを実装してください。
    • Feature 'Toggles(またはFeature Flags)を使う場合、 controller_name , action_name を組み合わせたキーを生成するとページごとに公開割合を設定することができます。

このConcernをControllerで読み込んで、 vue :fuga (fugaはaction名)と記述します。

app/controllers/hoge_controller.rb

class HogeController < ApplicationController
  include Vue
  vue :fuga
  ...
end

viewには既存のSSR用ファイルとは別に、Vue.js用に .html+vue.erb で終わるファイルを作成します。

app/views/hoge/
  fuga.html.erb
  fuga.html+vue.erb

これで、 HogeController#vue? に合致したリクエストではVue.js用のテンプレートファイル fuga.html+vue.erb がレンダリングされることになります。

このやり方の良いところ

こうしておけば、都度都度 if 分岐を書く必要もありませんし、FeatureTogglesのような仕組みで毎回キー名を考えて書く必要もなくなります。DRYですね。

class HogeController < ApplicationController
  def fuga
    if FeatureToggles.on?('vue-hoge-fuga')
      request.variant = ['vue']
    else
      # ...
    end
  end
end

また、 git grep 'vue :' app/controllersgit ls-files app/views | grep '+vue' とgrepすることで簡単にVue.js対応したページを洗い出すことができます。

以上がタイトルの内容になりますが、これだけで終わるのは少しもったいないので、 lookup_context.variants = ['vue'] とするとなぜファイル名に +vue と付いたテンプレートがレンダリングされるのか、ActionViewの仕組みにも踏み込んでみたいと思います。

ActionViewの動き

※actionviewの実装(2020/08/24時点のmasterブランチ最新)を元にしています。

以下は要点を極限まで絞込んだ解説となります。

ActionView::LookupContext

Context(文脈)という名が示すとおり、テンプレートファイルを探しに行くベースの検索条件を保持しています。これは、リクエスト毎にチューニングが可能です。 今回の場合は、指定されたactionの場合のみ、 variants['vue'] という値を保持するようにしています。

ActionView::Resolver が、ファイルを探しにいく具体的なロジックを担っている

ActionView::LookupContextは、ActionView::Resolver を継承したresolver(ファイルを見つけるオブジェクト)複数持つことができ、resolverはそれぞれ探索先のパスを保持しています。つまり、 render: 'fuga' とした場合にテンプレートファイルの探索先を複数持てることであり、 app/views 以下だけでなく、Rails Engineなどに同梱されたテンプレートファイルを探しに行くこともできるようになっているんですね。

ActionView::Resolver の継承先である ActionView::PathResolver には DEFAULT_PATTERN というファイル名のフォーマットが定義されています。この中に {+:variants} が含まれており、予め渡しておいた "vue" という文字列によって {+vue} に置き換えられる予感がしますね。

lib/action_view/template/resolver.rb

  class PathResolver < Resolver #:nodoc:
    EXTENSIONS = { :locale => ".", :formats => ".", :variants => "+", :handlers => "." }
    DEFAULT_PATTERN = ":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}"

ちなみに jpmobile というGemでは、リクエストヘッダの情報を元に、テンプレートファイルを分ける実装を提供しています。テンプレートファイル名のフォーマット内に {_:mobile} というフォーマットが存在しますが、例えばAndroidからのリクエストの場合は app/views/hoge/fuga_android.html.erb というファイルをテンプレートファイルとして使う実装として使われます。今回の記事の実装は、この挙動をヒントにしました。

module Jpmobile
  class Resolver < ::ActionView::FileSystemResolver
    EXTENSIONS = [:locale, :formats, :handlers, :mobile].freeze
    DEFAULT_PATTERN = ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze

build_query メソッドでは、LookupContextから渡された検索条件を元に、実際にファイルを探しに行くクエリを作成します。

lib/action_view/template/resolver.rb

    # Helper for building query glob string based on resolver's pattern.
    def build_query(path, details)
      query = @pattern.dup

      prefix = path.prefix.empty? ? '' : "#{escape_entry(path.prefix)}\\1"
      query.gsub!(/:prefix(\/)?/, prefix)

      partial = escape_entry(path.partial? ? "_#{path.name}" : path.name)
      query.gsub!(/:action/, partial)

      details.each do |ext, candidates|
        if ext == :variants && candidates == :any
          query.gsub!(/:#{ext}/, "*")
        else
          query.gsub!(/:#{ext}/, "{#{candidates.compact.uniq.join(',')}}")
        end
      end

      File.expand_path(query, @path)
    end

作成されたクエリは Dir[] に渡されて、条件に合うファイルが合致します。ここで、先程のフォーマットは "#{プロジェクトroot}/app/views/hoge/fuga{_{},}{.{ja},}{.{html},}{+{vue},}{.{raw,erb,html,builder,ruby,slim,coffee},}" のようになって渡されます。

lib/action_view/template/resolver.rb

    def find_template_paths(query)
      Dir[query].uniq.reject do |filename|
        File.directory?(filename) ||
          # deals with case-insensitive file systems.
          !File.fnmatch(query, filename, File::FNM_EXTGLOB)
      end
    end

テンプレートファイルが見つかれば、テンプレートに変数を埋め込むERBが活躍して、実際にレンダリングされる文字列が作られ、拡張子に応じたContentTypeでレスポンスボディとして送出されていくことが予想に難くありません。

より詳細な解説は、以下のような記事を参照いただければ、より詳しく分かるかと思います。

techracho.bpsinc.jp techracho.bpsinc.jp

qiita.com

なにかの参考になれば幸いです!