entry-header-author-info.html
Article by
entry-page.min.js.html

Rails + Contentful で LP のコーディングをゼロにする

こんにちは、@f_subal です。 pixivFACTORY というサービスで普段はフロントエンドをやっています。

今回は Rails のサービスに Headless CMS の Contentful を導入し、ワークフローを改善した話をします。

ランディングページ、あるいはマスターデータの詳細について

pixivFACTORY はグッズおよび同人誌がブラウザ上で簡単に作れるサービスです。 取り扱っているグッズは 60 種類以上あり、各グッズごとに仕様が大きく異なります。

グッズにはそれぞれ、仕様や出来上がりの写真を載せたページ(以下、product 詳細とも呼びます)が存在します。 f:id:pxvpxv:20200106145243p:plain 要するに、以下の状況を想定してください。

  • 運営が管理する静的なドメインモデル(ここでいう「作れるグッズの仕様」)が存在する
  • モデルの各内容について説明するページが存在する
  • 各ページの内容は DB の内容からは一意に定まらない(ふつうの HogeController#show のノリで作れない )
  • ユーザーは検索流入などでそこに来るので、引きの強い文言や画像がページごとに用意される
  • 管理画面の自作が困難なので、結果各ページが個別にマークアップ(ないしコピペ)される形になる

こういったものを「LP」と呼称するかは意見が分かれるかもしれませんが、実際こういう状況はよく見かけるでしょう。

料金プラン、雑誌概要、コンテスト、キャンペーン、キャラクター紹介…など、皆さんのサービスにも思い当たるページがないでしょうか。

個別にマークアップするしかない問題

pixivFACTORY のグッズの仕様説明は各グッズごとにマークアップが、つまり、60 種類のグッズに対して 60 個のテンプレートファイル(!)が存在する状況でした。 f:id:pxvpxv:20200106145509p:plain

おまけに新しいグッズが年に 3 〜 4 種のペースで増えていくので、リリース時に LP の追加コストが毎回( 1 〜 2 人日)かかることになります。ページそのものはある程度パターン化されているとはいえ、これではスケールしません。

どうなっていると良いか

今回は次の状態をゴールに据えました。

  1. マークアップに不慣れなメンバーでも LP の追加/変更が簡単にできるようにする
  2. CMS を内製しない
  3. URL を変更しない

product 詳細の追加は、私あるいはマークアップに比較的慣れたビジネス職のメンバーのいずれかが行っていました。これを、新グッズのリリースに関わる誰でもができる状況にするのが第一の目標です。

要するに CMS が欲しいということなんですが、これを内製するのは避けました。

CMS の実装が面倒なのは、たとえページがパターン化されていたとしても、複数のリッチテキストエディタの塊になるからです。経験上、リッチテキストエディタを内製の管理画面に構築すると本当にひどい目に合うので、それがサービスのコア機能である場合を除いては作らないほうが賢明です1

DB に HTML をどう保存するか、そもそも HTML で保存すべきなのか、画像アップローダでここは複数枚を受け入れるべきかとか、1つの編集画面だけで野放図な開発コストとメンテコストが発生するのが想像できるでしょう。


考えられるもう一つの方法として、表示用のデータをコード( yaml、json、クラス定数の Hash… )として管理するのも一応検討しました。ActiveHash を ViewModel として用いたり、どこかのクラスに巨大な Hash 定数を置くと行った運用です。みなさん心当たりがありますね?嫌です。

この方法ではテンプレートの共通化は(うまくやれば)可能ですが、リポジトリへのコミットが必要なことには変わりませんし、たとえば画像をリポジトリに追加する手間などは特に減りません。なので今回はこの方法も見送りました。


3. はどういうことかと言うと、たとえば「静的なページなんだから Wix に置いて外部化しようぜ!」といった方法は取らないということです。EC サイト向け CMS とかも使いません。いわゆる「NoCode」系のサービスには魅力的なものもありますが、独自ドメインサポートこそあれ、メインサービスとは別ドメインへの移行が現状避けられません2

キャンペーン告知だけそうするとかなら実際ありなんですが、グッズの仕様説明はメインサービス内に置きたいよねという気持ちからこの方針もやめました3

Headless CMS to the rescue

今回は最終的に、Headless CMS として知られる Contentful を導入することにしました。

Headless CMS については既にいくつも記事がありますが、要するに編集画面と REST API だけを持った CMS です。Wordpress からブログの表示機能を削って編集画面と API だけ残したようなサービスを想像してください。 f:id:pxvpxv:20200106145549p:plainf:id:pxvpxv:20200106145553p:plainf:id:pxvpxv:20200106145601p:plain こういう感じで、Content Model と呼ばれるスキーマを定義すると、その編集画面がいきなり生えてきます。リッチテキストエディタは最初からサポートされていますし4、画像アップローダも付きます。おまけに、一つのフィールドを複数枚画像に対応させるとか、他の Content Model を has_one や has_many で持つこともできます。行けそうな気がしてきますね!

近年、SPA のバックエンドや静的サイトジェネレータの編集画面として Headless CMS を利用するケースが増えており、ひょっとすると Contentful もそういう用途のものだと思っている人がいるかも知れません。が、実際はそんなことはなく、普通にバックエンドのフレームワークと組み合わせても利用できます( Node.js、Ruby、PHP、Java、Python、C# ほか多数の言語向けに SDK があります )。

Rails への導入

さて、Contentful 側で準備が整ったら、これをサービス側で LP として表示できるようにしましょう。

Contentful は公式の gem をいくつか提供しています。

今回は contentful_model を使います。こんな感じでクラスを用意します。

# models/contentful/application_content.rb
class Contentful::ApplicationContent < ContentfulModel::Base
end
# models/contentful/product_page.rb
class Contentful::ProductPage < Contentful::ApplicationContent
  self.contentId = 'productPage'
end

こうすれば、後は LP のコントローラーで参照するだけです。どことなく ActiveResource って感じがしますね。

# controllers/products_controller.rb
def show
  @content = Contentful::ProductPage.find_by(slug: params[:slug]).load
end

もちろん、アプリケーションの DB の値と混ぜ合わせて使うこともできます。単に外部 API が一つ増えただけなので、そこは柔軟に扱うことができます。

# controllers/products_controller.rb
def show
  # 文言とかメイン画像は Contentful から
  @content = Contentful::ProductPage.find_by(slug: slug).load

  # 価格とか発送予定日は ActiveRecord から引いて両方使える
  @product = Product.find_by!(slug: slug)
end

さて、ここでキャッシュの問題について触れておきましょう。

Contentful からの json レスポンスは CDN に乗っている ようですが、それでも LP を 1 回表示する度に毎回リクエストを飛ばすというのは嫌な感じがします。アプリケーション側で適当にキャッシュを持ちたいですね。

当初、model のインスタンス(先の例で言う ApplicationContent )をシリアライズして Redis にでも入れるかと思ってましたが、この方法は難しいのでやめました。

キャッシュ対象が 1 つの Content Model に閉じている時は良いのですが、モデル同士の関連とか画像への紐づきが存在するときに、子要素のモデルすべてを予め fetch しておくのがどうにもやりづらかったためです。

なので今回はビューのフラグメントキャッシュを使いました。テンプレートエンジン内でのリクエストになってしまうのが癪ですが、これが一番簡単でした。

def show
  # この時点ではまだリクエストが発生しない
  @content_query = Contentful::ProductPage.find_by(slug: slug)
end
= cache [@slug, RedisCacheVersion.for(:product_page, @slug)] do
  // ここで初めてリクエストが飛ぶ!
  - content = @content_query.load
  = render 'product/body', content: content

キャッシュを扱うためには、キャッシュパージが必要です。これは「キャッシュのバージョンないし更新日時」を表す何らかの値を保持しておいて、slim で cache ヘルパーに渡すことで実現します( 上のコードに出てきた RedisCacheVersion.for(...) がそれです )。更新の仕組みは単純で、押すと Redis が更新されるボタンを管理画面に置けば良いです。

ボタンを押さなくても自動でパージする仕組みを実装しても良いでしょう( Contentful には保存時にイベントを発火する Webhook があります )が、現状では意図しない本番の更新を警戒してやっていません。RedisCacheVersion は次のように実装します。

class RedisCacheVersion
  attr_reader :namespace, :slug

  def initialize(namespace, slug)
    @namespace = namespace
    @slug = slug
  end

  def update
    # 管理画面でキャッシュパージボタンを押したら呼ぶ
    redis.set(slug, Time.zone.now.to_i)
  end
  
  def updated_at
    return unless raw = redis.get(slug)

    Time.zone.at(raw.to_i)
  end
  
  def self.for(namespace, slug)
    new(namespace, slug).updated_at.to_i
  end
  
  private

  def redis
    # ...
  end
end

CMS で保存したときっていきなり本番反映されるの?怖くない?

もともとコードで管理していたものを CMS に移行すると、本番への反映タイミングが問題になります。コードで管理していた時はデプロイ前に調整や確認ができたのに、CMS 化した結果保存がぶっつけ本番になるというのは嫌です。

pixivFACTORY では二段構えで塞いでいて、

  • Publish を押す前に管理者限定のプレビュー画面を見れるようにする
  • Publish を押しても、先に述べた「キャッシュパージボタン」を押すまでは本番の画面が変わらないようになっている

ことで安全に公開できる形にしています。後者はすでに話したので、前者について説明しましょう。

Contentful には Preview API と呼ばれる仕組みがあります。これは published になる前のコンテンツを API で叩ける仕組みです。これを使い、本番と全く同じ slim テンプレートを render する管理者限定のコントローラーをもう一個用意すれば、それでリリース前の確認ができます。

# models/contentful/application_content.rb

class Contentful::ApplicationContent < ContentfulModel::Base
  def self.with_preview_api
    ContentfulModel.use_preview_api = true
    yield
  ensure
    ContentfulModel.use_preview_api = false
  end
end
# controllers/admin/products_controller.rb

around_action :use_preview_api, only: %i(show)

def show
  @content = ...
end

def use_preview_api(&block)
  Contentful::ApplicationContent.with_preview_api(&block)
end

Contentful では、編集画面から「プレビュー画面へのリンク」を追加できる仕組みがあるので、ProductPage の編集画面にそこへのリンクを追加しましょう。これなら誰でも編集から公開までできますね。 f:id:pxvpxv:20200106145637p:plain

ViewModel as a Service

さて、ここまでは一つの大きなページを題材に、Contentful の導入を扱ってきました。これ以外にも、工夫次第で色々な活用方法が考えられます。

よくある一般的な CMS だと「記事」とか「ページ」単位での編集を強制されます。しかし、Contentful は API first の CMS ですから、編集対象はページ単位に限られません。たとえば「トップページのお知らせウィジェット」とか「特集のカルーセル」だけを Contentful で管理するといったことは完全に可能です。

実際 pixivFACTORY ではトップページのグローバルナビゲーションや、グッズの一覧の並び順を人間が指定していますが、これを最近 Contentful 化しました。それまでは「このグッズは上の方に見せたい」みたいな要件が発生したときは slim を毎回編集していましたが、そういうのもなくなりました。 f:id:pxvpxv:20200106145651p:plain

皆さんの Rails プロジェクトにも、トップページの特定のクリエイティブに使う Carrierwave の uploader クラスとか、ほとんどビューのためだけに存在する topics テーブルとか、そういうものがあると思います。 あるいはキャンペーンの度に app/assets/images に画像が増えてリポジトリがどんどん肥大化しているとか、なにかの順番を指定するための謎のクラス定数が数十行を超えているとかもあるでしょう。それらは全部 Contentful で代替、解決可能な問題です。

現代では ViewModel は外部サービスに切り離す選択肢があります。切り離していきましょう。

まとめ

9 月に Contentful を導入してから pixivFACTORY では 3 つほどグッズリリースされましたが、マークアップがなくなった分明らかにリリース作業が楽になったと感じます(作業量そのものよりも「マークアップできる人がボトルネックになる」のがなくなったのが大きいです)。Node.js 界隈では比較的メジャーな Headless CMS ですが、Rails でもビューの負債を削るための SaaS として優秀なので皆様ぜひ検討してみてください。おすすめします。

ピクシブ株式会社では、サーバーサイドも巻き込んでビューの負債を削れるフロントエンドエンジニアを募集しています!


  1. ライブラリが進化していても、です。Quill なら良いとか、ActionText とか、そういう話をしているんじゃあありません!

  2. HTML export 機能があるサービスならこの問題は解決可能かもしれません(たとえば Webflow にはエクスポート機能があります )。ただそうすると結局 export された HTML の反映には git commit いるよね?という問題が発生するので悩みどころです。

  3. 加えて、定常的なページでドメインを移行するとなると、全ページ一斉に移行した方が良いという話が出てきます( このグッズは factory.pixiv.net だが別のグッズは違うドメイン、なんて状況を作りたいでしょうか? )。流石にそれはしんどすぎるので、段階的な移行ができるソリューションだけを選ぶことにしました。

  4. そうは言っても、リッチテキストエディタには入れられる要素に制限があります。たとえば今回でいうと、価格表をマークアップしたいときに表を入れる機能がなくて困りました。Contentful にはカスタムの編集 UI を定義できる「UI Extention」という機能があるので、表編集はそれで対応しました。https://github.com/fsubal/contentful-a-table

20191219021843
f_subal
2016年新卒入社。sensei by pixiv の開発や pixiv 本体投稿画面リニューアルなどを経て、pixivFACTORY のエンジニアをしている。百合と TypeScript が好き。