こんにちは。ラクマの岸です。
ラクマでは、既存Webフロントエンドをよりモダンな技術にリプレースしようという動きが加速しており、サービスが発足した2012年から使われているjQueryベースのフロントエンドをVue.jsに置き換えようとしています。
さて、我々が使っているサーバーサイドフレームワークはRuby on Railsですが、webpackのラッパーgemであるwebpackerは使わずに、素のwebpackでビルドされた純粋なフロントエンドリポジトリをsubmoduleとして取り込んで使っています。
(後に、フロントエンドを1マイクロサービスとして切り出すことも視野に入れてこうしています)
これから様々なページを完全にVue.jsで作られたページ(正しくは <div="app">
以下のDOM)に置き換えていきますが、いきなり100%公開すべきでしょうか?
否、私たちのコアバリューの一つである「Fail Smart」的にはユーザーのみなさんに極力迷惑をかけるわけにはいきません。
私たちのコアバリューは、キャリアサイトをご覧ください😉
ということで
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
を組み合わせたキーを生成するとページごとに公開割合を設定することができます。
- Feature 'Toggles(またはFeature Flags)を使う場合、
この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/controllers
や git 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
なにかの参考になれば幸いです!