料理教室事業部の長(@s_osa_)です。最近読んで面白かった漫画は『ランウェイで笑って』です。
クックパッド料理教室では今年10月にデザインの全面リニューアルを行ないました。
Before | After |
---|---|
ユーザー向けページの HTML, CSS, JavaScript を約1ヶ月でまるっと書き換えるプロジェクトでした。
今回はそんなデザインリニューアルを支えた仕組みについて書きたいと思います。
全面リニューアルの大変さ
「全面リニューアル」
聞いただけで大変さがにじみ出る言葉ですが、具体的に何が大変なのか少し考えてみます。
主な大変さは2つあると考えています。
スコープが大きい
デザインの全面リニューアルという性質上、全ページが対象になります。 クックパッド料理教室のコードはそれほど大きくない Rails ですが、それでも対象の view ファイルは約200ほどあります。
もちろん、これら200個のファイルだけを変更するわけではなく関連するファイルも同時に修正する必要があるため、実際の作業量はもっと大きくなります。
リリースブランチの長期間運用とビッグバンマージ
リニューアルにともなってデザインを大きく変更するため、全ページのデザインを一度に切り替える必要があります。 また、プロジェクトを進める一方で、バグ修正をはじめとして日常的にコードに変更を入れていく必要もあります。 これら2つの目的を果たすためにプロジェクトの期間中ずっとリリースブランチをメンテナンスしていく必要が生じます。
リリースブランチを長期間にわたってメンテナンスしていくのも大変ですが、その後、master にマージするのも大変です。 差分が大きくなればなるほど、バグが入り込む可能性は大きく、バグが起こったときの原因究明も難しくなります。
大変じゃない全面リニューアルを考えてみる
大変な理由がわかったところで、その大変さを取り除くことを考えます。
スコープをできるだけ小さくする
デザインの全面リニューアルである以上、すべての HTML, CSS, JavaScript, Image を書き換えることは避けがたいです。 しかし、それ以外の箇所は触らないようにしました。
「せっかくリニューアルするなら」と新機能の追加や機能改善をしたくなりますが、そこはグッと我慢して粛々と画面だけを書き換えます。 ただでさえ大きいスコープをさらに膨らませてリリースが遅れるくらいなら、可能な限り早くリリースした後に小さく扱いやすいスコープで機能追加や改善を行なうという方向性をプロジェクト開始時にチームで合意しました。
また、デザインの刷新だけでもユーザーにとっては大きな変更であり戸惑いが生じるので、機能については据え置くことで少しでも戸惑いを減らしたいという意図もありました。
リリースブランチをつくらない
身も蓋もないことを言ってしまうと、リリースブランチをなくせばリリースブランチの長期間運用もビッグバンマージも発生しません。 そこで、リリースブランチをつくるのはやめて、書いたコードは順次 master に入れるようにします。
しかし、先述のとおり全ページのデザインを一度に切り替える必要があるので、単純に既存の view を書き換えるという手段は使えません。
そこで、「全ページのデザインを一度に切り替える」と「順次 master にマージする」を両立するための仕組みをつくりました。
柔軟なデザイン切り替えを実現するために
つくりたい状況は
- master に新旧2つのデザインが共存している
- 2つのデザインを柔軟に切り替えられる
というものです。
上記2点を実現するための方法についてそれぞれ考えていきます。
master に新旧2つのデザインを共存させる
まず、master に新旧両方のデザインを共存させる方法を考えます。
プロジェクト期間中は一時的に共存期間が必要ですが、プロジェクト完了後は新しいデザインのみが使用され古いデザインが必要になることはありません。
そこで、古いデザインのためのファイルをまとめたディレクトリを作ります。
具体的には app/views
, app/assets/images
, app/assets/javascripts
, app/assets/stylesheets
の中に旧デザインのためのファイルを置くためのディレクトリを掘って、既存のファイルをそちらに移動し、プロジェクト完了後にまるっと削除できるようにします。
イメージとしては以下のようなディレクトリ構成になります。
# tree app app ├── assets │ ├── images │ │ └── old │ ├── javascripts │ │ ├── application │ │ │ └── foo.js │ │ ├── application.js │ │ ├── application_old │ │ │ └── foo.js │ │ └── application_old.js │ └── stylesheets │ ├── application │ │ └── foo.scss │ ├── application.scss │ ├── application_old │ │ └── foo.scss │ └── application_old.scss └── views ├── foos │ └── index.html.haml ├── layouts │ └── application.html.haml └── old ├── foos │ └── index.html.haml └── layouts └── application.html.haml
また、asset precompile 時に新旧両方の asset を作成するようにし、新旧それぞれの layout ファイルから読み分けるようにします。
# config/initializers/assets.rbRails.application.config.assets.precompile += %w(application application_old)
# app/views/layouts/application.html.haml = stylesheet_link_tag 'application', media: 'all'= javascript_include_tag 'application'# app/views/layouts/application_old.html.haml = stylesheet_link_tag 'application_old', media: 'all'= javascript_include_tag 'application_old'
2つのデザインを柔軟に切り替える
ここまでで新旧両方のファイルを master に共存させることができました。
あとは render するテンプレートをいい感じに切り替えることさえできれば当初の目的を達成することができます。
Rails のテンプレート探索
テンプレートを望む通りに切り替えるためにはテンプレートがどのように探索されているかを知る必要があります。
Rails がどうやってテンプレートを探索しているかについては以下のエントリが詳しいです。
- Digging Rails: How Rails finds your templates - AprilTouch Part 1Part 2Part 3Part 4
- Railsはどのようにテンプレートを見つけているか - Qiita
リンク先にあるようにテンプレート探索の仕組みは結構複雑なのでここでその詳細を解説することはしませんが、今回作ろうとしている仕組みは Rails がテンプレート探索に使用している resolver の仕組みを利用します。ここでは resolver についてのみ簡単に説明します。
Resolver
Rails が render するテンプレートを探索するために使用しているオブジェクトです。 現在のアプリケーションが持っている resolver の一覧は rails console で以下のメソッドを呼ぶことで確認できます。
ApplicationController.new.view_paths
メソッドの返り値は resolver が入った配列で、デフォルトでは以下のような resolver だけが入っています。
#<ActionView::OptimizedFileSystemResolver:0x007fe41c5d88a8 @cache=#<ActionView::Resolver::Cache:0x7fe41c5d8bf0 keys=0 queries=0>, @path="/Users/shunsuke-osa/projects/cooking_school/app/views", @pattern=":prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}">
何らかの view を追加するタイプの gem を使用している場合はその gem が提供する view を探索対象に含むための resolver が追加されているはずです。 *1
Path
resolver が持っている @path
は文字通り探索対象のディレクトリを指し示す path です。デフォルトの resolver には app/views
が指定されており、普段 Rails がこのディレクトリを対象にテンプレートの探索を行なっていることがわかります。
Pattern
resolver の @pattern
からなんとなく察せるとおり、普段 Rails がやっている locale (e.g. ja, en), format (e.g. html, json), handler (e.g. haml, erb) に応じたテンプレートの切り替えも resolver によって行われています。
*2
パターン定義の中に含まれる :hoge
はテンプレート探索で用いられる LookupContext
において detail と呼ばれているもので、探索 path を動的に生成するために使用されます。現在使用されている detail の一覧は以下のメソッドで確認できます。
ActionView::LookupContext.registered_details # => [:locale, :formats, :variants, :handlers]
また、パターン中に含まれる {}
はブレース展開されます。
柔軟なテンプレート探索を実現する
Rails のテンプレート探索の仕組みを調べた結果、
- Rails はテンプレート探索に使うための resolver を持っている
- 適切な path や pattern を指定した resolver を追加すれば任意のテンプレートを render する仕組みをつくることができる
ということがわかりました。
方針が決まったので実装していきます。
シンプルなケース
view_paths
に独自 resolver が追加されていない Rails に対してテンプレート探索の対象ディレクトリを追加するのは簡単です。
ActionView::ViewPaths
が提供している prepend_view_path
や append_view_path
を使用することで任意の @path
を持った resolver を追加することができます。
# app/controllers/application_controller.rb before_action :fallback_to_old_templatesprivatedeffallback_to_old_templatesif prefer_new_template? append_view_path('app/views/old') else prepend_view_path('app/views/old') endend
独自 resolver が使用されているケース
我々のアプリケーションでは jpmobileを使用していました。
jpmobile は resolver の pattern に :mobile
という detail を追加して PC 向けとモバイル端末向けのテンプレートを切り替えています。
つまり、jpmobile が提供する端末ごとのテンプレート切り替えに対応しつつ、今回追加する新旧デザインの切り替えにも対応する必要があるため、前述の prepend_view_path
, append_view_path
に path を渡す方法では目的を果たすことができません。
そこで、jpmobile が提供する resolver を拡張した resolver を用意する必要があります。
つくりたい resolver は以下のようなものです。
- jpmobile の提供する
:mobile
という detail に対応している':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
- 探索ディレクトリを柔軟に変更するための detail として
:directories
のようなものを持っている'{:directories}:prefix/:action{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
つまり、両者を同時に満たすために '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
という pattern を持つ resolver である必要があります。
Detail
detail の追加は非常に簡単で、ActionView::LookupContext.register_detail
を使用します。
# config/initializers/action_view.rbActionView::LookupContext.register_detail(:directories) { [] }
こうして detail を登録することによって、controller で self.lookup_context.directories=
を呼んで、resolver の pattern にある :directories
に値を渡すことができるようになります。
Pattern
本来であれば、jpmobile を拡張して pattern が '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'
となる resolver を作成して prepend_view_path
に渡すべきなのですが、
- jpmobile が既存の resolver それぞれに対して resolver を作成しており、すべてに対応するのが面倒なわりにメリットが薄い
- 今回はプロジェクト中のみ一時的に使用する
といった点を考慮し、jpmobile にモンキーパッチを当てることにしました。
# config/initializers/monkey_patches/jpmobile.rbmoduleJpmobileclassResolver# Original: ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freezeDEFAULT_PATTERN = '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze endend
あまり褒められた方法ではありませんが、今回行ないたいのはデザインの全面リニューアルであり、そのための一時的な仕組みに対してあまり時間をかけたくなかったため割り切った判断をしました。
実際に切り替える
ここまで出来たらあとは実際に切り替えるだけです。
切り替え自体は controller で lookup_context.directories=
を呼ぶだけなので非常に簡単です。
# app/controllers/application_controller.rb before_action :set_view_template_directoriesprivatedefset_view_template_directoriescase preferred_template # returns 'new' or 'old'when'new'self.lookup_context.directories = ['', 'old/'] when'old'self.lookup_context.directories = ['old/', ''] endend
新旧どちらのテンプレートを優先するかを指定するメソッドを用意し、その返り値によってテンプレート探索の優先順位を切り替えています。
プロジェクト初期には新しいテンプレートは存在しません。しかし、その都度例外を吐かれると開発しにくいので、新テンプレートが見つからない場合には旧テンプレートにフォールバックするようにしています。
実際の手順
これまでの説明ではわかりやすさのため順番を前後させてきましたが、実際の作業手順としては以下のような順番でした。
- テンプレート切り替えの仕組みを実装する
preferred_template = 'old'
- この時点では旧テンプレートが
app/views
にある - old -> new のフォールバックによってアプリケーションは正常に動き続ける
- 旧テンプレートや関連リソースを移動する
- 旧テンプレートが
app/views/old
に移動
- 旧テンプレートが
- 新テンプレートを実装していく
preferred_template = 'new'
すると新テンプレートが優先的に render される- 新テンプレートが未実装のページは旧テンプレートにフォールバックする
いろいろな切り替え方
RAILS_ENV
一番わかりやすい切り替え方だと思います。
RAILS_ENV=development
では新しいテンプレートを優先し、RAILS_ENV=production
では古いテンプレートを優先するなどができます。
Query String
URL に ?template=new
などを付加することによって、RAILS_ENV
によるテンプレート指定を手軽に上書きする手段を提供します。production での確認などに利用していました。
Session
query string によるテンプレート指定は手軽で便利でしたが、ページ遷移を伴う場合に不便でした。そこで、プロジェクト後半にはページを遷移してもテンプレート指定が保たれるように session を用いたテンプレート指定も使用していました。 *3
外部データストアから設定を読み込み
全面リニューアルを実際にリリースする直前になると、リリース時に万一事故が起こったときの切り戻しを考えるようになりました。
しかし、我々のアプリケーションではデプロイやロールバックのために数分程度かかってしまいます。 つまり、リリース後にページが見れなくなるなどの問題が起こってしまった場合には数分間にわたってユーザーに迷惑がかかってしまいます。
そこで、デプロイなしでリリースするために外部のデータストアから指定するテンプレートを読み込めるようにしました。 Redis や memchached などの書き換えが容易なデータストアにテンプレート指定を保存し、リクエストごとに読み込むようにすることによって切り戻しにかかる時間を数秒程度まで短くすることができます。
パフォーマンスなど注意すべき点はありますが、比較的小規模なアプリケーションであることやリリース前後のみ使用するということを考慮して採用しました。
組み合わせると
それぞれのテンプレート指定方法を組み合わせて以下のような形で運用していました。
defpreferred_template# preferred_template_by_* は 'new', 'old', nil のいずれかを返す# 優先順位を query, session, data store, env の順に設定 preferred_template_by_query || preferred_template_by_session || preferred_template_by_configuration || preferred_template_by_env || 'old'end
応用:段階的リリース
リクエストごとにテンプレート指定を柔軟に変更できるようになると「スタッフのアカウントに対して一足先に新デザインをリリースする」「ユーザー ID の末尾2桁が10以下のユーザーに対してのみ新デザインをリリースする」といったようにリリース対象を少しずつ広げるというようなことも可能になります。
おわりに
規模が大きくなることを避けられないデザインの全面リニューアルをスムーズに行なうために使用したテンプレートの柔軟な切り替え方法を紹介しました。
この方法を用いた結果、開発者以外のメンバーも含めて早い段階から production で新しいデザインを確認することができ、バグの早期発見に繋げることができました。 また、リリースまでに新しいデザインを触る時間を十分取れたため、リリース規模のわりには安心してリリースすることができましたし、事実としてもリリース後に大きな不具合は起こりませんでした。
ここまで触れませんでしたが、画面に関連しているものとしてテストがあります。しかし、古いテストを隔離してテストの中で指定するテンプレートを切り替えるという方針は同じです。
リリース後しばらくして問題なく動いていることを確認できたら、テンプレートを切り替えの仕組みを削除して新しいデザインだけを使用するようにした上で、はじめにつくった /old
ディレクトリを rm -rf
してリニューアル完了です。
影響範囲が大きいリニューアルをすることはあまり多くはないと思いますが、もし同じような状況に置かれている方の参考になれば幸いです。
*1:我々のアプリケーションでは kaminari, letter_opener_webなどが含まれていました。
*2:デフォルトパターンは https://github.com/rails/rails/blob/6a902d43c76a8b5bc2ddd00b7c8af38f9fb82bdb/actionview/lib/action_view/template/resolver.rb#L209で定義されています。
*3:切り替え方法とは別に session の書き換え方法を別途用意する必要があります