こんにちは、freeeでフロントエンドエンジニアをしている @joe_re です。
freee Engineers Advent Calendar 2015の4日目を書きます。
僕からはfreeeで現在進行中の革命について、フロントエンドのビルドプロセスを中心に書こうと思います。
革命 ってなんのこと?
というのはフロントエンドヤンキーこと @ymrl が 2日目で書いたので詳しくはそちらをご参照頂ければ幸いです。
背景
弊社ではRuby on Railsを主軸にしてWebサービスを作っています。
Railsは素晴らしいフレームワークですが、めまぐるしく変化する昨今のフロントエンドについては、このままRailsの用意しているRailに乗ったままでは時代に取り残されてしまう危機感があります。
僕たちは時代に取り残されている場合じゃない。
最先端でかつ適切な技術を用いて未来を切り開いていかなくてはならないんだ!
という熱い思いから、革命という言葉を胸に刷新に取り組んでいます。
フロントエンドのビルドを導入
Railsの提供するassetsのビルドプロセス
Railsでは、SprocketsというRubyGemsでjs、css、imageなどのassets(静的ファイル)のprecompileを行います。
Railsではapp/assets配下に静的ファイルを配置して開発していきますが、そこに配置されているファイルについては、Sprocketsが自動でよしなにビルドプロセスを通してくれます。
具体的には
- CoffeeScript、Sassのコンパイル
- uglify
- minify
- fingerprintの付与
- 依存関係の定義(requireディレクティブの提供)
などを行ってくれます。
これは出てきた時は非常に画期的だったのですが、2015年の現代においては使い続けるのはつらい状況になってきました。
具体的には以下のような事情があります。
- フロントエンドのビルドツールの進化
- 基本的にgem化されていないと使えない(フロントエンドの進化に追いつけない)
- フロント側までRailsにロックインされてしまうのが気持ち悪い
- bowerが廃れた今、package.jsonに定義したnpmを直接importして使いたい欲求がある
Sprockets を捨てる2つの道
Sprocketsを捨てるには2つの道があります。
1つは Sprocketsの提供するasset pipelineを一切使わない道 で、もう1つは フロントエンドでビルドした成果物をapp/assetsに出力する道 です。
Sprocketsの提供するasset pipelineを一切使わない道
asset pipelineを捨てる道を選んだ場合は、Sprocketsさんのやっているお仕事を全て奪ってフロント側のビルドプロセスに置き換えます。
成果物はpublicに出力するのが妥当かと思います。
この方法は理想ですが、フロント側で単純にビルドしただけでは、Railsのasset_pathヘルパーメソッドが一切使えなくなります。
Railsはデプロイ前のassetのコンパイルの時に、fingerprintをassetファイルに付与してファイル名を変更することで、ブラウザキャッシュを無効にして確実な静的ファイルの刷新を実現しています。
フロント側のビルドでもfingerprintを付与するだけなら簡単にできるのですが、その値は何らかの方法でRailsに伝える必要があります。
加えて、Sprocketsの提供しているrequireディレクティブの解決も一切できなくなるので、BrowserifyなりWebpackなりの別の依存解決方法に置き換えなければならず、一気にやらないといけない作業量が多すぎる問題もありました。
ゆえに当時の僕たちは、app/assetsに成果物を出力し、段階的にSprocketsを捨てていく道を選択しました。
※ Sprockets完全撤廃については、弊社の誇るフロントエンドヤンキー通り越してもはやフロントエンドヤクザであるところの @yo_waka が Sprocketsのビルドプロセスを完全にgulpに置き換えるソリューションを提供するgulp-sprocketsというnpmを作ったので、実現可能に!詳しくは @yo_waka が語ってくれる(たぶん)
フロントエンドでビルドした成果物をapp/assetsに出力する道
この道では、AltJSやSassのコンパイルなど、Railsで行う必要のないビルドプロセスについてはフロント側で行い、成果物はapp/assetsに出力します。
そうすることで(fingerprint付与はSprocketsさんに任せるとして、それ以外の)ビルドプロセスは徐々にフロント側に移していくことができます。
具体的には、ES2015(Babel)や依存解決(Browserify、Webpack)などデプロイに直接影響せずに、かついますぐ使いたい!
というモチベーションの高いところから取り組んでいくことができます。
柔軟性が高くて取り組みやすい方法ですが、「ビルド結果をはたしてSprocketsさんが理解してくれるか?」を意識して開発していかなくてはプロダクトが死ぬ、というデメリットがあります。
そこでgulpによるフロントエンドのビルドプロセスを導入
まずは gulp によるフロントエンドのビルドプロセスを導入しました。
プロジェクトルートにfrontというディレクトリを作り、今までapp/assetsに配置していたファイルたちをそこに配置しました。
(files changedが300超という巨大なPRになったのは、このためです。)
最初に導入した段階では、gulpはES2015、CoffeeScriptのビルドのみに留めて、残りのファイルはそのままapp/assetsにコピーすることにしました。
こうして、段階的にフロント側でのビルドプロセスを増やしていきます。
宣伝になってしまいますが、弊社にはエンジニアの「カッとなってやった」を歓迎する文化があります。
このPRも、新規機能をどうしてもES2015で書きたい衝動に耐えかねて、カッとなって出したPRなのですが、開発プロセスまで大きく変えてしまう変更に対して誰も嫌な顔せずに「よくなるならやろうぜ!」と受け入れてくれました。
(もちろん本当に妥当かどうかはちゃんと厳しくレビューされますが。)
こういうところが弊社の良いところだなー、と思います。
JavaScriptのビルド
Sprocketsとの戦い
Sprocketsにはrequireディレクティブという仕組みがあって、これは簡単に言うと定義した順番にファイルをconcatしてくれるものです。
//= require foo
//= require bar
と書くと、foo.jsとbar.jsをconcatしたjsを返却してくれます。
しかしCommonJSやES6-modulesによってJavaScriptのモジュール化が促進されている現代において、この仕組みは合いません。
僕たちはCoffeeScriptを使ってプロダクトを書いていたのですが、foo.js.coffeeに定義しているclassには下記のような形式でグローバルな名前空間を持たせることで、それぞれのjsからの矛盾のない読み込みを実現していました。
# initialize的なやつで先に `window.freee = {}` をやっておく
class Foo
hogeMethod: ->
# some processes
window.freee.Foo = Foo
実際は社内に独自フレームワークを持っていてもう少しエレガントに書けるのですが、やっていることはこんな感じでした。
こういう方法を取ると、concatされる順番を意識しなくてはなりません。
つまり、foo.js.coffeeがbar.js.coffeeを参照しているような場合、先にbarをrequireしておかなければいけません。
class Foo extends freee.Bar
fugaMethod: ->
# some processes
window.freee.Foo = Foo
//= require bar
//= require foo
加えて、ディレクトリごとconcatするrequire_treeというディレクティブもあるのですが、こいつは名前順でconcatするため、依存関係がある場合には、下記のように必要とされるファイルが先になるようにrequireしなければいけない、という奇妙な書き方まで発生したりします。
//= require modules/dependent_file
//= require_tree modules
Webpackの導入
そこで僕たちはWebpackを導入して、レガシー環境からの脱却をはかりました。
弊社のアプリケーションは完全なSPAにはせずに、ページ単位に必要なjsを渡すアーキテクチャを採用しているので複数のエントリーポイントがあります。
当初はシンプルな設計思想に惹かれてBrowserifyを導入しようとしたのですが、Browserifyでは複数エントリーポイントがある場合に、開発時のwatchにおいて変更のあったファイルのインクリメンタルビルドをエントリーポイントごとに行う仕組みを構築するところに壁がありました。
その点Webpackはwatchオプションにtrueを渡すだけで、ファイルの監視 + インクリメンタルビルドを実行できます。
参考: https://webpack.github.io/docs/tutorials/getting-started/#watch-mode
得たもの
かくして僕らは以下を獲得しました。
- ライブラリはnpmで管理(これまで使っていたbower(bower-rails)とはオサラバ!)
- Babelによるトランスパイル(ES2015)
- CoffeeScriptのトランスパイル
- ES6-modules、CommonJSによる依存解決
cssのビルド
libsassにしたいモチベーション
cssのビルドプロセスをフロント側に移したい理由は、単純にSprocketsから脱却したいというだけではありませんでした。
Sprocketsに乗っている時はruby-sassを使ってsassのビルドを行っていたのですが、デプロイ前のビルドが遅すぎるという問題がありました。
普段のデプロイ時には、前回との差分を見て、変更がないファイルについてはSprocketsの仕組みでキャッシュが効くのでほとんど問題になりませんが、なんらかの理由でキャッシュが消える(Sprocketsのバージョンアップなど)と、下手するとデプロイが2時間近くかかるという致命的な問題がありました。
日中に2時間もデプロイできないのはプロダクトの品質に関わるので、みんなデプロイしなくなった夜からやるかー、みたいなことになり非常に不健全でやばいです。
※ これについては自分のブログでも書いたので、もしお時間ありましたら
ここでも繰り広げられるSprocketsとの戦い
僕たちはcssのビルドをフロント側に移すにあたって、node-sassを使って、libsassでビルドする方法を選択しました。
ここでも問題になったのが、開発時のwatchにて、複数エントリーポイントのインクリメンタルビルドをどうやって実現するか?ということでした。
Sprocketsを使っている場合には、依存関係に従ってリクエストされたタイミングでassetsを動的にコンパイルします。
この場合リクエストされたタイミングで依存解決するので、特にファイルの変更は気にせずとも変更されたファイルを受け取ることができます。
しかしSprocketsに頼らずにgulpでビルドする場合には事前にビルドすることになるため、app/assetsに出力した段階で依存関係を解決しておく必要があります。
小さいアプリケーションでエントリーポイントが1つであれば、全部ビルドし直しでも問題はないのですが、弊社のscssはlibsassを使ってフルビルドした場合(ruby-sassよりは高速とはいえ)、数十秒かかります。
これをファイルの変更の度にやっていたのではストレスで死にかねません。
つまり、ファイルが変更されたタイミングで、差分のみの再ビルドが必要になります。
試行錯誤の末、なんとかインクリメンタルビルドする方法を見つけたのがこの記事です。
かくして僕らはSprocketsからstyleのビルドも引き剥がすことに成功し、Sprocketsのキャッシュが効かない状況においても普段とさほど変わらない時間でデプロイすることができるようになりました。
React + Fluxの導入
これは今まさに着手しているところです。
前述のビルドプロセスの改善により、npmでのライブラリ管理、ES2015のトランスパイル・依存解決を手に入れたことにより、新しいアーキテクチャを無理せずに採用する土台ができました。
そうして僕たちは現在選びうる選択肢の中で、最も適当なものであるとして、React + Fluxを選択しました。
既存機能はBackbone.jsなりVue.jsなりで書かれているところもまだまだ多いですが、新規機能に関してはReact + Fluxの実装が増えています。
作る機能によってはReact on Backboneで動かさないといけない場面もあったりしますが、これに関しても問題なく動いています。
(ただし少し工夫が必要だったり、設計に悩むところもありました。この記事の主題であるビルドプロセスから外れるのでここでは詳しく語りませんが、また別の機会に書きたいです。)
ただしいつまでも複数フレームワークを意識していくのはつらいので、どうやってレガシーコードを取り除いていくか、または住み分けをどうしていくか、というところが今後の課題かなー、と思います。
lintの導入
創業時より書かれていたCoffeeScriptには、規約などは特になく(エンジニアが個別にlintをかけたりはしていたものの)、なんとなく雰囲気で良さそうなコードを書いていました。
ES2015で書き始めるにあたって、さすがにこれはまずいだろ、ということでeslintによるlintをビルドプロセスに組み込みました。
方針として、今まで書いてきたCoffeeScriptにはlintをかけず、新規に作る拡張子が.js(ES2015)のファイルにのみかけることにしました。
それもあって、開始時から割と厳しめのチェックを導入しましたが、ここまで上手く回っています。
また、lintでエラーレベルで落ちた場合は、プロダクションで信頼性の低いコードが紛れ込むのを防ぐため、デプロイが失敗するようにしています。
開発時は変更を監視しつつ差分ビルドするwatchタスク、デプロイ前は完全フルビルドのみするbuildタスク、みたいな感じでpackage.jsonにコマンドを定義しているのですが、開発時に使うwatchタスクで毎回gulpのプロセスが落ちるのはつらいので、buildタスクでのみプロセスが落ちるようにしています。
イメージ的には↓のような感じです。
gulp = require 'gulp'
minimist = require 'minimist'
eslint = require 'gulp-eslint'
gulpIf = require 'gulp-if'
gulp.options = minimist process.argv.slice(2),
boolean: 'force'
default:
force: false
gulp.task 'eslint', ->
gulp.src 'path/to/javascripts/**/*.js'
.pipe eslint()
.pipe eslint.format()
.pipe gulpIf(!@options.force, eslint.failAfterError())
このタスクでは、gulp eslint --force
で実行した場合はエラーがコンソールに吐かれるものの、プロセスは終了しません。
--forceオプションをつけない場合は異常終了となります。
他のタスクも同じ仕組みにしていて、エラーをcatchするのにgulp-plumberを使っているものが多いです。
↓こんな感じ。
gulp = require 'gulp'
minimist = require 'minimist'
cache = require 'gulp-cached'
coffee = require 'gulp-coffee'
plumber = require 'gulp-plumber'
gulpIf = require 'gulp-if'
gulp.options = minimist process.argv.slice(2),
boolean: 'force'
default:
force: false
gulp.task 'build:coffee', ->
gulp.src 'path/to/**/*.coffee'
.pipe cache('coffee')
.pipe gulpIf(@options.force, plumber())
.pipe coffee(bare: true)
.pipe gulp.dest 'app/assets'
欲望はつきない
ここまでは最先端の環境を導入するための土台作りをしてきました。
これからのフロントエンドの話をしよう!!!!
というフェーズに今は入っています。
既存コードもES6に置き換えたい!! とか
もっとテスト充実させて最高の品質を実現するぞ!! とか
全てをコンポーネント化していくぞ!! とか
やっぱり複雑になってくるとisomorphic欲しくなるよね。サーバサイドも一部nodejsで書く仕組みがあったりすればロジック共有できてハッピーなんじゃね?ちょっと俺作ってみるわ!! とか
electronで完全非同期通信でオフライン動作可能なクライアントアプリ作りたい!! とか
とにかく考えうる最高のアーキテクチャで最高のユーザ体験を提供したいんだ!!とか
欲望はつきません。
というわけで宣伝です
freee では 共に革命を志すフロントエンドエンジニア を募集しています。
共にユートピアを築こう。
明日は三ツ星(トリプル)無線ハンター @sugitak です。