もし、僕らのRailsにSprocketsがなかったら

[Livesense AdventCalendar その1](http://qiita.com/advent-calendar/2016/livesense_1) 13日目の記事です。
今年は3枚ありますので、[その2](http://qiita.com/advent-calendar/2016/livesense_2)、[その3](http://qiita.com/advent-calendar/2016/livesense_3)もご一緒にどうぞ。

フロントエンドのエコシステムの成長に伴って、Rails開発においてSprocketsを導入する、しないの議論が盛んに行われていました。

* [Sprocketsを捨てたい](http://www.slideshare.net/masatonoguchi169/sprockets-49965435)
* [Sprockets再考 モダンなJSのエコシステムとRailsのより良い関係を探す](http://qiita.com/joker1007/items/9068e223744b3ac8c6dd)
* [なぜSprocketsを捨てるべきか / Why do I want to throw away sprockets?](http://sssslide.com/speakerdeck.com/joere/why-do-i-want-to-throw-away-sprockets)

そこで、実際にRails上でフロントエンドエコシステムをSprocketsの代わりにできるのか、実際にやってみました。

自分で手を動かしてみて、得られた知見や感じたことを共有できればと思います。

## 再現するSprocketsのバージョン
* Sprockets3系(4系については最後にちょろっと触れます。)

## Sprocketsがやっていること
* アセットのコンパイル
* アセットファイル同士の依存性を管理する
* アセットファイルにアクセスするためのパスを管理する

>パーフェクトRuby on Rails 第3章より

ここまでは、殆どのフロントエンドビルドツールで再現できるでしょう。
やっかいなのは[sprockets-rails](https://github.com/rails/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の設定については

* [webpack.config.jsの読み方、書き方](http://dackdive.hateblo.jp/entry/2016/04/13/123000)
* [【意訳】Webpackの混乱ポイント](http://qiita.com/chuck0523/items/caacbf4137642cb175ec)

が参考になります。

### coffee-scriptコンパイル・sassコンパイル
#### entry
エントリーファイルはSprocketsに則って、以下のように。

```webpack.config.js
entry: {
  application: [
    './app/assets/javascripts/application.js',
    './app/assets/stylesheets/application.scss'
  ]
}
```

#### output
ビルド生成物はpublicディレクトリに出力します。

```webpack.config.js
output: {
  path: __dirname + '/public/',
  publicPath: __dirname + '/public/',
  filename: '[name].js'
}
```

#### loaders
`.coffee`拡張子の物はcoffeeコンパイル、`.scss`拡張子はsassコンパイルします。

```webpack.config.js
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`でファイルを読み込みます。

``` app/assets/javascripts/application.js
require('./src/saySomethingInCoffee')
require('./src/saySomethingInJs')
```

#### Sass
こちらは`@import`機能を使います。Sprocketsのマニフェストファイルは`.css`ファイルですが、Sassの`@import`機能を利用したいため、拡張子を`.scss`変更します。

``` app/assets/stylesheets/application.scss
@import "src/bold.scss";
```

ここまでで、entry→コンパイル→outputまでができました。

### 開発環境と本番環境用の設定
続いて、`NODE_ENV`を利用して、開発環境用と本番環境用に処理を切り分けていきます。
本番用と開発用のnpm scriptを用意しましょう

```package.json
"scripts": {
    "build": "webpack", // 本番
    "dev-watch": "NODE_ENV=development webpack -w" // 開発
  }
```
今回は開発環境のみ`NODE_ENV`を設定し、それ以外は`prodction`と同じとみなします。
`staging`なども必要であればここで切り分けておくと良いでしょう

``` webpack.config.js
const isDevMode = process.env.NODE_ENV === 'development'
```
こうすることで、`isDevMode`で開発環境と本番環境で処理を切り分けることができます。
開発環境・本番環境それぞれで必要な設定以下のように追加していきます。

|開発環境|本番環境|
|:--|:--|
|ソースマップを出力する|Uglifyする|
||digestを付与する|
||webpack-manifest.jsonを出力する|

**ソースマップの出力**
開発環境でデバッグできるようにwebpackのsource-map出力機能を使用します
[webpack -configuration-](https://webpack.github.io/docs/configuration.html#devtool)
幾つか種類がありますが、開発環境では`inline-source-map`を指定します。本番では`eval`を指定します。

```webpack.config.js
const devtool = isDevMode ? 'inline-source-map' : 'eval'
```

`ExtractTextPlugin`を使ってsassコンパイルを行っている場合、sourceMapがうまく出力されずにハマりました。
以下のように記述すればとりあえずは出力されました。

```webpack.config.js
loaders: [
  { test: /\.scss/, loader: ExtractTextPlugin.extract('style', 'css?sourceMap!sass?sourceMap') }
]
```

**Uglifyする**
UglifyJsPluginを追加します

``` webpack.config.js
const webpack = require('webpack')
plugins:[ new webpack.optimize.UglifyJsPlugin() ]
```
**digestを付与する**
ビルド生成物にdigestを付与します。webpackが提供してくれている[hash]をファイル名の後ろに付与します。

```webpack.config.js
const fileName = isDevMode ? '[name]' : '[name]_[hash]'
```
`fileName`を出力されるファイル名に指定することで、開発環境以外ではhash付きのファイルを出力することができます。
ですが、ビルドの度に変更されるhash付きのファイル名をRails側で参照する手段が存在しないので、このままでは使えません。
そこで、[assets-webpack-plugin](https://github.com/kossnocorp/assets-webpack-plugin)を使って、`webpack-manifest.json`を出力し、Railsとビルド生成物のひも付けを行います。

**webpack-manifest.jsonを出力する**
ひも付けと言ってもやることはシンプルで、ビルドの度にファイル名が記述されたJSONファイルを出力し、それをFile.readしてRailsから参照できるようにします。

```webpack.config.js
const AssetsPlugin = require('assets-webpack-plugin')
new AssetsPlugin({path: __dirname + '/app/views', fullPath: false})
```

```app/views/webpack-manifest.json
{
  "application": {
    "js": "application_c3d726db6f5bd7b8dd3f.js",
    "css": "application_c3d726db6f5bd7b8dd3f.css"
  }
}
```

```app/controller/application_controller.rb
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`の全体像を確認しましょう

```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](https://circleci.com)上でビルドして、ビルド生成物を[heroku](https://www.heroku.com)にpushする形を取りたいと思います。

CircleCiやherokuについての細かい説明は割愛しますが、CircleCiの環境変数にherokuのemailとユーザー名を登録しておきます
<img width="981" alt="スクリーンショット 2016-12-12 17.00.55.png" src="https://qiita-image-store.s3.amazonaws.com/0/56883/1d24ff4b-bae7-ec2b-d857-4273565f1618.png">

そして、circle.ymlはこんな感じに。

```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方法を[ドキュメント](https://circleci.com/docs/continuous-deployment-with-heroku/)を参考にしつつ設定
かなり強引なやり方なのは自覚しつつ、ビルド生成物を無理やり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を捨てるリスクと、代替手段を用意するコストがかかります。
それらを天秤にかけ、やる価値があるかどうかをよく考えて判断したいところですね。（自戒を込めて）

## 参考
* [Rails 4.2でSprocketsを捨ててwebpackに移行する](http://qiita.com/dakatsuka/items/c815a27b9740c843bee0)
* [WebPackを使ってRailsからJavaScriptを楽に良い感じに分離する](http://qiita.com/necojackarc/items/afa674ab10aafa9784eb)