料理教室のデザインリニューアルを支えた技術

料理教室事業部の長(@s_osa_)です。最近読んで面白かった漫画は『ランウェイで笑って』です。

クックパッド料理教室では今年10月にデザインの全面リニューアルを行ないました。

Before After
f:id:s_osa:20171219172624p:plain f:id:s_osa:20171219171826p:plain

ユーザー向けページの 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.rb
Rails.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 がどうやってテンプレートを探索しているかについては以下のエントリが詳しいです。

リンク先にあるようにテンプレート探索の仕組みは結構複雑なのでここでその詳細を解説することはしませんが、今回作ろうとしている仕組みは 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_pathappend_view_path を使用することで任意の @path を持った resolver を追加することができます。

# app/controllers/application_controller.rb
before_action :fallback_to_old_templates

private

def fallback_to_old_templates
  if prefer_new_template?
    append_view_path('app/views/old')
  else
    prepend_view_path('app/views/old')
  end
end

独自 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.rb
ActionView::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.rb
module Jpmobile
  class Resolver
    # Original: ':prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
    DEFAULT_PATTERN = '{:directories}:prefix/:action{_:mobile,}{.:locale,}{.:formats,}{+:variants,}{.:handlers,}'.freeze
  end
end

あまり褒められた方法ではありませんが、今回行ないたいのはデザインの全面リニューアルであり、そのための一時的な仕組みに対してあまり時間をかけたくなかったため割り切った判断をしました。

実際に切り替える

ここまで出来たらあとは実際に切り替えるだけです。

切り替え自体は controller で lookup_context.directories= を呼ぶだけなので非常に簡単です。

# app/controllers/application_controller.rb
before_action :set_view_template_directories

private

def set_view_template_directories
  case preferred_template # returns 'new' or 'old'
  when 'new'
    self.lookup_context.directories = ['', 'old/']
  when 'old'
    self.lookup_context.directories = ['old/', '']
  end
end

新旧どちらのテンプレートを優先するかを指定するメソッドを用意し、その返り値によってテンプレート探索の優先順位を切り替えています。

プロジェクト初期には新しいテンプレートは存在しません。しかし、その都度例外を吐かれると開発しにくいので、新テンプレートが見つからない場合には旧テンプレートにフォールバックするようにしています。

実際の手順

これまでの説明ではわかりやすさのため順番を前後させてきましたが、実際の作業手順としては以下のような順番でした。

  1. テンプレート切り替えの仕組みを実装する
    • preferred_template = 'old'
    • この時点では旧テンプレートが app/views にある
    • old -> new のフォールバックによってアプリケーションは正常に動き続ける
  2. 旧テンプレートや関連リソースを移動する
    • 旧テンプレートが app/views/old に移動
  3. 新テンプレートを実装していく
    • 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 などの書き換えが容易なデータストアにテンプレート指定を保存し、リクエストごとに読み込むようにすることによって切り戻しにかかる時間を数秒程度まで短くすることができます。

パフォーマンスなど注意すべき点はありますが、比較的小規模なアプリケーションであることやリリース前後のみ使用するということを考慮して採用しました。

組み合わせると

それぞれのテンプレート指定方法を組み合わせて以下のような形で運用していました。

def preferred_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 の書き換え方法を別途用意する必要があります

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/