Railsのフロントエンドのビルドをnpmコマンドのみで完結したい

  • 84
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

こんにちは、freeeでエンジニアをやってます @yo_waka です。

これは、freee Engineers Advent Calendar 2015の11日目の記事です。

これまでの弊社のAdvent Calendarでは、社内で革命と呼ばれるフロントエンドの改善ネタが多いと思いますが、その流れとして、RailsアプリケーションにおけるフロントエンドのビルドツールであるところのSprocketsをどのようにしたら外せるかという話を書きます。

Sprocketsがやってくれること

まずはSprocketsが裏側でどういうことをやっているのか知る必要があります。

Advent Calendar4日目の記事で既に概要が書かれていますが、もう少し細かく内部動作に踏み込んで書いてみます。

尚、Sprocketsのコードは現状のRailsの最新版である4.2.5で利用されている、Sprockets#3.0.0.rc.1を元に引用しています。

CoffeeScript、Sassのコンパイル

CoffeeScriptのコンパイルはExecJS経由で行われます。

def self.context
  @context ||= ExecJS.compile(contents)
end

# Compile a script (String or IO) to JavaScript.
def compile(script, options = {})
  script = script.read if script.respond_to?(:read)

  if options.key?(:bare)
  elsif options.key?(:no_wrap)
    options[:bare] = options[:no_wrap]
  else
  options[:bare] = false
  end

  wrapper = <<-WRAPPER
  (function(script, options) {
   try {
    return CoffeeScript.compile(script, options);
   } catch (err) {
    if (err instanceof SyntaxError && err.location) {
      throw new SyntaxError([
       err.filename || "[stdin]",
       err.location.first_line + 1,
       err.location.first_column + 1
      ].join(":") + ": " + err.message)
    } else {
     throw err;
    }
   }
  })
  WRAPPER

  Source.context.call(wrapper, script, options)
end

coffee-script-source gemに内蔵されているCoffeeScript CompilerをExecJSが実行した結果をRubyのStringとして受けとり、後のconcatに使われます。

Sass(SCSS)のコンパイルも似たような感じです。
こちらはRuby Sass gemがあるので、直接コンパイルした結果を使います。

https://github.com/rails/sprockets/blob/v3.0.0.rc.1/lib/sprockets/sass_processor.rb#L61

engine = Autoload::Sass::Engine.new(input[:data], options)

css = Utils.module_include(Autoload::Sass::Script::Functions, @functions) do
  engine.render
end

ここでmodule_includeで追加されている @functions には、お馴染みの asset_pathやasset_url、image_urlなどのRailsで使えるSass関数が定義されています。
これはRuby Sassが提供している機能で、Sass::Script::Functionsモジュールに関数を追加することで、Sassファイルから定義した関数を呼び出し処理することが可能になります。

fingerprintの付与

rake assets:precompileを実行すると、public/assets下にフィンガープリントが追加されたファイル名で生成されますね。

このフィンガープリントもまたSprockets内で作られます。

https://github.com/rails/sprockets/blob/v3.0.0.rc.1/lib/sprockets/digest_utils.rb#L95

def pack_hexdigest(bin)
  bin.unpack('H*').first
end

Assetファイルのバイトデータを16進数にエンコードした文字列が使われます。
これにより、application.{js,css}で呼んでいるどこかのファイルに変更があればダイジェスト値が変わるため、キャッシュを使わず再読み込みされるようになっています。

manifest.json

assets:precompile時にfingerprintを付与したファイルが生成されますが、ビューで呼び出す際には特にfingerprintを意識せず、<%= javascript_include_tag "application" %>と書きますね。

これを可能にするために、Sprocketsはprecompile元のファイル名とprecompile後のファイル名をマッピングするファイルをprecompile時に生成します。それがmanifest.jsonです。

何気にいろいろな情報が書き込まれます。元ファイル名があれば、fingerprintもmanifest.jsonから取ってこれます。

{
  "assets": {
    "application.js": "application-2e8e9a7c6b0aafa0c9bdeec90ea30213.js"
  },
  "files": {
    "application-2e8e9a7c6b0aafa0c9bdeec90ea30213.js": {
      "logical_path": "application.js",
      "mtime": "2011-12-13T21:47:08-06:00",
      "digest": "2e8e9a7c6b0aafa0c9bdeec90ea30213"
    }
  }
}

Sprocketsをやめたくなってきた理由

昨今ES2015を使いたい諸氏におきましては、ES2015で書きつつRailsのAsset Pipelineで扱う面倒臭さを知っている方も多いかと思います。
application.jsのみで単一ファイルならまだマシなのですが、そろそろ規模が大きいよねページごとに分割したいよねといった規模でES2015で書こうとなってくると、辛さが増してきます。

  • Webpack Loaderなどを使ってページごとにビルドしたJavaScript/SCSSファイルをprecompile対象に入れ忘れてステージング環境で動かない!
  • 結局gulpfile用意して誰でもnpmコマンド叩いて新しく書いたファイルをビルドできるものの、sprockets用の読み込み設定は別途書く必要がある
  • XX-rails系のフロントエンドgem、bower-rails、npm modulesが混在してカオス。特にXX-rails gemは中で利用しているJSライブラリのバージョンがいくつなのか誰も知らない
  • gulpでのビルドとassets:precompile両方実行しないといけないためデプロイ時間が伸びる
  • precompileとlivereload組み合わせるの辛い

などの辛さがあります。

Node.jsでSprocketsを実装してみよう

理想をいえば、npm run buildだけ叩けばよくなれば、1つのコマンドの実行結果だけ確認すればよくなるので簡単です。
また、gulpやwebpackはググるといろいろ情報が手に入るので便利。またその知識はRails以外でWebアプリを作る際にも持っていけます。

では、Node.jsのみで置き換えるには何をすればいいか。
上に挙げたSprocketsならではの処理をNode.jsで処理できればよいわけです。

CoffeeScript、Sassのコンパイル

CoffeeScriptのコンパイルはNode.jsなので楽勝ですね。

import compiler from 'coffee-script';

const compiled = compiler.nodes(
  compiler.tokens(fileContents)
).compile({bare: true});

Sassについてもnode-sassがあるので楽勝・・といいたいところですが、Sprocketsが組み込んでいるカスタム関数を扱えないといけません。
幸い、node-sassは3系以上であれば、Ruby Sassと同様にカスタム関数を組み込むことができます。

import compiler from 'node-sass';
import path from 'path';

const compiled = compiler.renderSync({
  file: file.path,
  outputStyle: 'expanded',
  functions: {
    "asset-path($url)": assetPath
  }
});

function assetPath(url) {
  const parsedUrl = path.parse(url.getValue());
  return compiler.types.String('url("' + parsedUrl.base + '")');
}

fingerprintの付与

これもcryptモジュールを使えば楽勝です。

import crypto from 'crypto';

function digest(fileContents) {
  return crypto.createHash('md5').update(fileContents).digest('hex')
}

manifest.json

fsモジュールで愚直にやってもいいのですが、node-fs-extraのreadJson、outputJsonを使うと、jsonファイルの読み込み/書き込みが簡単です。

// read
let manifestJson = fsextra.readJsonSync(manifestFilePath);

// write
fsextra.outputJson(manifestFilePath, manifestJson);

ライブラリ化しました

手前味噌ですが、上記のような処理をまとめて、gulpストリームとして使えるようにしたgulp-sprocketsというライブラリをnpmに公開しました。

詳しくはREADMEまたはサンプルアプリがsampleディレクトリ下にあるので、試してみてください。

弊社で運用しているサービスにも実際に導入しようとしています。
ぜひ興味を持たれた方がいたら試してみて、気になったところはissueなど投げてもらえると嬉しいです(日本語でもOK)。

宣伝

弊社では5%ルールを導入しています。
冒頭で革命と呼んでいるフロントエンドの改善の流れが今社内にあると書いていますが、元々初期のフロントエンドの実装があまり良くなくて新規開発の足かせになっていたり、負債に対するイライラを抱えつつ修正するなどよくある状態になりました。
高速にサービスを成長させるのはスタートアップの至上命題ですが、でも負債も返して新しく整った環境で開発も両立させたい。
そんな思いから、プロダクトや開発環境が改善されるのを前提に、普段の開発時間の5%を自分の思う改善に使ってもよい、5%ルールが生まれました。
最初は105%ルールになりがちだったのですが、今では組織として、開発スケジュールに組み込んで100%の中の5%として運用しています。
こうした取り組みから、フロントエンドの改善以外にも7日目のAdvent Calendarで紹介されたような高速化への取り組みなども成果としてサービスに反映されるなど、エンジニアそれぞれが思う改善施策がリリースされています。

freeeではサービスを成長させつつ開発環境も成長させたいエンジニアを募集しています。よろしくお願いします。

明日は、freeeのマイクロサービスおじさんこと @kakkunpakkun です。お楽しみに!