kokoro.io における脱sprockets/webpacker、あるいはdigestベースキャッシュ戦略との決別

by supermomonga 2018-02-14 23:42

これは kokoro.io Advent Calendar 2017 5日目の記事です。

皆さんはRailsにおけるassetsの管理をどうされていますでしょうか。私はsprocketsおよびwebpackerは要らない派なのでkokoro.ioにおいてはこれを使用せず、サーバーサイドとフロントエンドは完全に疎にして開発を行っています。今日はその辺りについて書きます。最近長文を書くモチベーションが皆無になっているので要点だけまとめて書きます。

TL;DR

  • webpacker導入しなくてもちょっと工夫すればいい感じにwebpack使えるのでwebpacker依存はやめとこう
  • sprockets使ってdigest付与する必要はなくて、RackミドルウェアかNginxのレイヤでETagベースのキャッシュ破棄戦略を使おう

脱webpacker編

webpackerを使用してもRailsが用意した無駄な抽象化層が増えるだけです。せっかくwebpackというフロントエンド側の世界でassetsの管理ができるのですから、Railsとは完全に切り離しましょう。特にkokoro.ioは将来的にサーバーサイドの実装をASP.NET Coreに置き換える予定なのでフロントエンド周りの設定についてバックエンド(というかrubygem)の実装が食い込んでいることは好ましくありません。その為できるだけ疎にしつつ簡単なヘルパを導入することでフロントエンドとバックエンドをプラガブルに保ちます。

基本的に開発環境ではwebpack-dev-serverを起動し、assetの参照をそこに向けます。これは単純なビューヘルパを定義してやれば一瞬で実現できます。

module ApplicationHelper
  def assets_url(path)
    if Rails.application.secrets.assets_base_url.present?
      Rails.application.secrets.assets_base_url + path
    else
      path
    end
  end
end

secrets.ymlassets_base_urlの値をdevelopment環境とproduction環境で切り替えます。development環境では'//localhost:8080'を、production環境では''(空文字)指定もしくは未指定(値がnilになります)にしておきます。

あとはview側で以下のようにassetを参照します。

<script src="<%= assets_url('/javascripts/application.js') %>" data-turbolinks-track="true" />

これで、development環境ではwebpack-dev-server、production環境ではRailsをホストしているRackにリクエストが飛ぶ様になります。

production環境においてはconfig.ruの設定にてRackミドルウェアのレイヤでassetを配信してやるようにします。

require ::File.expand_path('../config/environment',  __FILE__)

use Rack::ETag
use Rack::Deflater
use(Rack::Static, urls: ['/javascripts', '/stylesheets', '/images', '/fonts', '/sounds'],
  root: 'assets/dist',
  header_rules: [
    [:all, {
      'Cache-Control' => 'no-cache'
    }]
  ]) if ENV['RACK_ENV'] == 'production'
run Rails.application

ここではRack::Staticを使用し、/javascript/stylesheetなどのディレクトリへのアクセスはルートディレクトリからのassets/dist内のファイルを返却するようにしています。

あとは、デプロイ時にwebpackを使用しassets/dist内にコンパイル済みassetsを配置する設定を書けば完了です。

これは各々自由にやってもらえればいいですが、herokuなどのPaaSにおいては規定でデプロイ時にassets:precompileRakeタスクが自動実行されるようになっていますので、/lib/tasks/assets.rakeファイルを作りこれを上書きしてしまうのが手っ取り早いでしょう。

Rake::Task['assets:clean'].clear
Rake::Task['assets:precompile'].clear
namespace :assets do
  desc "Precompile assets"
  task precompile: :environment do
    require 'open3'
    Rails.logger.info "Install dependencies…"
    stdin, stdout, stderr, status = Open3.popen3("cp -r ./node_modules ./assets/javascripts/")
    stdin.close
    stdout.each do |l|
      Rails.logger.info l
    end
    stderr.each do |l|
      Rails.logger.warn l
    end

    Rails.logger.info "Compiling…"
    stdin, stdout, stderr, status = Open3.popen3("yarn font sound image build", chdir: "./assets/javascripts")
    stdin.close
    stdout.each do |l|
      Rails.logger.info l
    end
    stderr.each do |l|
      Rails.logger.warn l
    end

    if status.value.success?
      Rails.logger.info "Compiled all assets."
    else
      Rails.logger.info "Compilation failed."
    end

    status.value.success?
  end

  desc "Clean assets"
  task clean: :environment do
    Rails.logger.info "Nothing to do with webpack."
  end

end

依存するnpmパッケージをコンパイル実行ディレクトリにコピーした後、コンパイル用のコマンドを実行しています。この例ではyarn font sound image buildコマンドを実行していますが、ここはあなたのプロジェクトで定義してあるコンパイル用yarnタスクに置き換えてください。

以上です。結構やることが多く感じますが、やっていることは薄く明確なので、やっていきましょう。

脱sprockets編

上記の脱webpacker編の作業にて、既に脱sprocketsは完了しています。

皆さんが脱sprocketsできない一番の要因はasset urlへのdigest付与を使いたいためだと思われますが、上記のconfig.ruの設定にてETagベースのキャッシュ破棄を実装していますので、assetsのurlを固定したまま、内容に変更があった場合のキャッシュ即時破棄が実現できます。

そもそもdigestを付与・変更することによるキャッシュ破棄戦略は好きではなく、この様なダーティハックを使うのではなく、ちゃんとHTTPの仕様として策定されているETagベースのキャッシュ破棄機構を使うべきと私は考えています。

上記の設定では、ページをロードする度に"必ず"各assetのURLに対してHTTPリクエストが飛んでしまいますが、assetの中身に変更がなかった場合、Rackミドルウェアは単に「お前が持ってるキャッシュ使えよな」という返答、つまりHTTPステータスコードで言うと304 Not Modifiedを返してくれるだけですので、httpリクエストの本数自体は削減できないものの、レスポンスのbodyは空なのでdigest付与によるキャッシュ破棄戦略に較べてそこまで通信コストが増えるとは思いません。

私は基本的によほど大規模であったりレイテンシを気にするサービスない限りはherokuなどを使いましょうというスタンスなのでRackのレイヤで処理してしまっていますが、Nginxのレイヤでも同様の事は可能ですので、Nginxが導入可能なのであればそのレイヤでEtagベースのキャッシュ破棄戦略を担当してしまえば無駄なhttpリクエストがRailsのプロセスやスレッドを専有することもないです。

CDNで配信しなければいけない規模の場合にdigestがないとキャッシュ破棄できなくて困るという声は聞きますが、恐らくFast.lyとか使えばいい感じにURL固定しつつキャッシュ破棄でいるんじゃないでしょうか。知らんけど。

要はバランス感覚で、せっかくHTTPプロトコルレベルでちゃんとキャッシュ破棄の仕様が策定されているのでそれに則ったほうが綺麗に物事を分離できるよねという話でした。終わり。