Livesense AdventCalendar その1 13日目の記事です。
今年は3枚ありますので、その2、その3もご一緒にどうぞ。
フロントエンドのエコシステムの成長に伴って、Rails開発においてSprocketsを導入する、しないの議論が盛んに行われていました。
- Sprocketsを捨てたい
- Sprockets再考 モダンなJSのエコシステムとRailsのより良い関係を探す
- なぜSprocketsを捨てるべきか / Why do I want to throw away sprockets?
そこで、実際にRails上でフロントエンドエコシステムをSprocketsの代わりにできるのか、実際にやってみました。
自分で手を動かしてみて、得られた知見や感じたことを共有できればと思います。
再現するSprocketsのバージョン
- Sprockets3系(4系については最後にちょろっと触れます。)
Sprocketsがやっていること
- アセットのコンパイル
- アセットファイル同士の依存性を管理する
- アセットファイルにアクセスするためのパスを管理する
パーフェクトRuby on Rails 第3章より
ここまでは、殆どのフロントエンドビルドツールで再現できるでしょう。
やっかいなのはsprockets-railsが担っている部分です。こちらは
- digestの付与
- digestが付与されたアセットのパス解決
- devlopment環境・production環境それぞれでのプリコンパイル
など、ビルド生成物を実際にRailsに乗せて、連携させる役割を担っています。
フロントエンドビルドツールをRailsに組み込むときに、開発プロセスやデプロイプロセスに合わせて、方法を選ぶ必要があります。
Sprocketsがやっていることをやってみる
では実際に始めていきましょう。
$ bundle exec rails new no-sprockets-rails --skip-sprockets
$ npm init
ビルドツールは今回はwebpack使います。
行うことは以下。
- coffee-scriptコンパイル・sassコンパイル
- 依存関係解決
- 開発環境でのデバッグ
Sprocketsの再現が目的なので、色んな思いをグッとこらえてCoffeeScriptをコンパイルしていきます。
必要なライブラリをダダダッとインストール
npm install webpack coffee-loader coffee-script css-loader extract-text-webpack-plugin node-sass sass-loader style-loader --save-dev
ルートディレクトリにwebpackの設定ファイルwebpack.config.js
用意し、ビルド設定を追加していきます。
webpackの設定については
が参考になります。
coffee-scriptコンパイル・sassコンパイル
entry
エントリーファイルはSprocketsに則って、以下のように。
entry: {
application: [
'./app/assets/javascripts/application.js',
'./app/assets/stylesheets/application.scss'
]
}
output
ビルド生成物はpublicディレクトリに出力します。
output: {
path: __dirname + '/public/',
publicPath: __dirname + '/public/',
filename: '[name].js'
}
loaders
.coffee
拡張子の物はcoffeeコンパイル、.scss
拡張子はsassコンパイルします。
module: {
loaders: [
{ test: /\.coffee$/, loader: 'coffee' },
{ test: /\.scss/, loader: ExtractTextPlugin.extract('style', 'css!sass') }
]
}
依存関係解決
Sprocketsの// require
の代わりに、JavaScript(Node.js)とsassのそれぞれの機能を使って、manifestファイルを作成していきます
JavaScript
babel-loaderは使っておらず、import
構文は使えないので、普通にNode.jsのrequire
でファイルを読み込みます。
require('./src/saySomethingInCoffee')
require('./src/saySomethingInJs')
Sass
こちらは@import
機能を使います。Sprocketsのマニフェストファイルは.css
ファイルですが、Sassの@import
機能を利用したいため、拡張子を.scss
変更します。
@import "src/bold.scss";
ここまでで、entry→コンパイル→outputまでができました。
開発環境と本番環境用の設定
続いて、NODE_ENV
を利用して、開発環境用と本番環境用に処理を切り分けていきます。
本番用と開発用のnpm scriptを用意しましょう
"scripts": {
"build": "webpack", // 本番
"dev-watch": "NODE_ENV=development webpack -w" // 開発
}
今回は開発環境のみNODE_ENV
を設定し、それ以外はprodction
と同じとみなします。
staging
なども必要であればここで切り分けておくと良いでしょう
const isDevMode = process.env.NODE_ENV === 'development'
こうすることで、isDevMode
で開発環境と本番環境で処理を切り分けることができます。
開発環境・本番環境それぞれで必要な設定以下のように追加していきます。
開発環境 | 本番環境 |
---|---|
ソースマップを出力する | Uglifyする |
digestを付与する | |
webpack-manifest.jsonを出力する |
ソースマップの出力
開発環境でデバッグできるようにwebpackのsource-map出力機能を使用します
webpack -configuration-
幾つか種類がありますが、開発環境ではinline-source-map
を指定します。本番ではeval
を指定します。
const devtool = isDevMode ? 'inline-source-map' : 'eval'
ExtractTextPlugin
を使ってsassコンパイルを行っている場合、sourceMapがうまく出力されずにハマりました。
以下のように記述すればとりあえずは出力されました。
loaders: [
{ test: /\.scss/, loader: ExtractTextPlugin.extract('style', 'css?sourceMap!sass?sourceMap') }
]
Uglifyする
UglifyJsPluginを追加します
const webpack = require('webpack')
plugins:[ new webpack.optimize.UglifyJsPlugin() ]
digestを付与する
ビルド生成物にdigestを付与します。webpackが提供してくれている[hash]をファイル名の後ろに付与します。
const fileName = isDevMode ? '[name]' : '[name]_[hash]'
fileName
を出力されるファイル名に指定することで、開発環境以外ではhash付きのファイルを出力することができます。
ですが、ビルドの度に変更されるhash付きのファイル名をRails側で参照する手段が存在しないので、このままでは使えません。
そこで、assets-webpack-pluginを使って、webpack-manifest.json
を出力し、Railsとビルド生成物のひも付けを行います。
webpack-manifest.jsonを出力する
ひも付けと言ってもやることはシンプルで、ビルドの度にファイル名が記述されたJSONファイルを出力し、それをFile.readしてRailsから参照できるようにします。
const AssetsPlugin = require('assets-webpack-plugin')
new AssetsPlugin({path: __dirname + '/app/views', fullPath: false})
{
"application": {
"js": "application_c3d726db6f5bd7b8dd3f.js",
"css": "application_c3d726db6f5bd7b8dd3f.css"
}
}
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
helper_method :script_for, :css_for
def script_for(bundle)
return "#{Rails.application.config.action_controller.asset_host}/#{manifest_json[bundle]['js']}" if Rails.env.production?
"http://localhost:3000/#{bundle}.js"
end
def css_for(bundle)
return "#{Rails.application.config.action_controller.asset_host}/#{manifest_json[bundle]['css']}" if Rails.env.production?
"http://localhost:3000/#{bundle}.css"
end
private
def manifest_json
file = File.read('app/views/webpack-assets.json') # This is the file generated by the plug-in
json = JSON.parse(file)
end
end
今回はapplication_controller
にいろいろベタ書きしてしまいましたが、initializer
でFile.readした結果を保持しておくほうが良さそうです。
webpack設定完了
ここまででとりあえずwebpackの設定は完了なので、webpack.config.js
の全体像を確認しましょう
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const AssetsPlugin = require('assets-webpack-plugin')
const webpack = require('webpack')
const isDevMode = process.env.NODE_ENV === 'development'
const fileName = isDevMode ? '[name]' : '[name]_[hash]'
const devtool = isDevMode ? 'inline-source-map' : 'eval'
const plugins = [new ExtractTextPlugin(`${fileName}.css`)]
if(!isDevMode) {
plugins.push(new AssetsPlugin({path: __dirname + '/app/views', fullPath: false})) // manifest
plugins.push(new webpack.optimize.UglifyJsPlugin()) // minify
}
module.exports = {
entry: {
application: [
'./app/assets/javascripts/application.js',
'./app/assets/stylesheets/application.scss'
]
},
output: {
path: __dirname + '/public/',
publicPath: __dirname + '/public/',
filename: `${fileName}.js`
},
module: {
loaders: [
{ test: /\.coffee$/, loader: 'coffee' },
{ test: /\.scss/, loader: ExtractTextPlugin.extract('style', 'css?sourceMap!sass?sourceMap') }
]
},
resolve: {
root: __dirname + '/assets/',
extensions: ['', '.js', '.js.coffee', '.sass']
},
devtool,
plugins
}
ビルドしてデプロイする。
さて、いよいよデプロイプロセスです。
rake assets:precompile
に相当する部分が、npm scriptで定義したnpm run build
に置き換わるので、Capistranoなどのデプロイツールを使用する際は、そのタイミングでnpm run build
を実行し、ビルド生成物を本番サーバーに配布する手順となります。
今回はCircleCi上でビルドして、ビルド生成物をherokuにpushする形を取りたいと思います。
CircleCiやherokuについての細かい説明は割愛しますが、CircleCiの環境変数にherokuのemailとユーザー名を登録しておきます
そして、circle.ymlはこんな感じに。
machine:
timezone:
Asia/Tokyo
ruby:
version: 2.3.0
node:
version: 6.9.1
dependencies:
cache_directories:
- "vendor/bundle"
- "node_modules"
override:
- rm Gemfile.lock
- bundle install --path vendor/bundle
- npm install
- npm rebuild node-sass
- npm run build
test:
override:
- echo "Nothing to do here"
database:
override:
- echo "Nothing to do here"
deployment:
production:
branch: master
commands:
- "[[ ! -s \"$(git rev-parse --git-dir)/shallow\" ]] || git fetch --unshallow"
- git config --global user.email $HEROKU_EMAIL
- git config --global user.name $HEROKU_USER_NAME
- git add public/.
- git add app/views/webpack-assets.json
- git commit --amend --no-edit
- git push -f git@heroku.com:no-sprockets-rails.git master:refs/heads/master
- heroku run rake db:migrate --app no-sprockets-rails:
timeout: 400 # if your deploys take a long time
node-sassの警告が出るので、npm installした後にnpm rebuild node-sass
を挟んでいます。
deployment以下でherokuへのdeploy方法をドキュメントを参考にしつつ設定
かなり強引なやり方なのは自覚しつつ、ビルド生成物を無理やりlast commitに含めて無理やりherokuにpushしています。これは絶対他にいい方法があるだろうなと思いつつ、タイムアップ。
はじめてちゃんと触るのにもかかわらず、初対面で不躾な無茶振りをかましまくってしまい、CircleCiさんには本当に申し訳ない気持ちでいっぱいです。
今後ちゃんと勉強させていただきます。
おまけ Sprockets4
Railsにwebpackなどのビルドツールを導入する理由として、ES2015シンタックスでJavaScriptを書きたいというモチベーションがありますが、Rails5.1から標準となるSprockets4にてサポートされます。
https://github.com/rails/sprockets
他にもライブラリ管理がyarn
になったり、webpackの導入も議論中のようなので、ES2015が導入モチベーションかつ、Rails5.1に上げられそうなプロダクトは少し様子を見たほうが良いかもしれないです。
まとめ
ということで、すべての機能を再現できているわけではないですがSprocketsなしでSprocketsっぽいことをやってみました。
今回はRails newしたばかりのRailsかつ、簡易的なデプロイプロセスでしたが、実際のプロダクトでSprocketsを使わないとなると、考慮しなければならない点がいくつもあります。
フロントエンドビルドツールは自由度が非常に高い反面、それを安定して開発プロセスやデプロイプロセスに組み込むためにはツールへの理解と運用を組み立てるスキルが欠かせません。
一方で、SprocketsはRailsのフロントエンドビルドツールとして統一されていることは、Sprocketsの大きな価値のひとつです。
フロントエンドビルドツールが統一されることによって、開発プロセスを支えるツール郡の開発コストが低くなり、Railsが安定した開発環境を持つ一助となっているように感じます。
ですがそれはあくまで「Railsの開発プロセス」なので、「Sprocketsをなくしたい!」というモチベーションはRailsの開発プロセスに管理されず、よりフロントエンドに適したやり方でフロントエンドを管理したいモチベーションからくるもののように思います。
Sprocketsを剥がすには安定したRails wayを捨てるリスクと、代替手段を用意するコストがかかります。
それらを天秤にかけ、やる価値があるかどうかをよく考えて判断したいところですね。(自戒を込めて)