モノリシックな Ruby on Rails サービスからの Webpacker の剥がし方
iCARE の提供しているサービス Carely では 2020年の 10月から半年ほどかけて Webpacker からの脱却を行いました。似たような記事はいくつかありますが、まったくの未着手から解説したものはないと思うので、記録をかねてまとめました。長めの記事になりますが同じことで困っている方の参考になれば幸いです
はじめに - Carely の構成について
もともとは Rails5 のスタンダードな構成だったものを、 Webpacker を導入して GraphQL + Vue2 の構成に移行。SPA ではなくエンドポイントごとにエントリーポイントがあるような構成になっています
ルーティングなどは Rails に則りつつ、画面はほぼ Vue2 で構成されています。もちろん古い slim + sprockets の画面も残っているため coffee script のファイルも一部健在です
Webpacker 自体のバージョンは少し古い 4.2.2 。最新と比べて異なる部分も多く、それに気付かずに調査が難航することが度々ありました
Webpacker の辛み
起動が重いよ問題
Docker コンテナの中で webpack-dev-server を起動するのでひたすらに遅いです。場合によってはブランチを切り替えるたびに再起動が発生するため、日々の開発に支障をきたすレベルでした
コンテナでかいよ問題
Webpacker は Rails のコンテナに含まれるため webpack-dev-server を起動するために巨大な Rails コンテナをもう一つ起動する必要がありました
node_modules が壊れるよ問題
docker volumes に node_modules を配置しており、これがたびたび壊れていました。その度にディスクを削除してクリーンインストール → docker の再起動が必要になり場合によっては 30分くらい時間が飛ぶことも、、、
STEP0. 事前準備
現在の webpack.config の出力を保存する
現在の設定のまま Webpacker 経由で生成している webpack.config を再現するわけですが、かなり大きめなオブジェクトになるため、何度も見比べながら進める必要があります。そのためにも、現時点での出力結果をテキストファイルで保存しておく事をお勧めします
...
console.dir(menu, {depth: null})
process.exit()
module.exports = config
環境分だけ設定があるのでそれぞれで実行してあげる必要があります
$ NODE_ENV=development ./bin/webpack --config config/webpack/development.js
$ NODE_ENV=production ./bin/webpack --config config/webpack/production.js
$ ...
Webpacker レポジトリをクローンする
いろんなフェーズで Webpacker のソースコードを見る必要があるので、クローンしておいてローカルで参照しながら作業をすることをお勧めします。その際には自分のプロダクトで使われているバージョンをチェックアウトするようにしましょう( 1日無駄にしました)
STEP1. DSL をやめる
この DSL については最新のコードでは利用されていないようです
v4 系の場合のみ参考にしてください
1. Webpacker DSL の仕組み
Webpacker をインストールすると config/environment/
配下に設定ファイルが生成されます。これがいわゆる webpack.config.js
にあたるものなのですが、これの中身は純粋なオブジェクトでは無く、 Webpacker が独自にプロトタイプ拡張を行ったオブジェクトになっており、それを経由して設定をするため、フロントエンドエンジニアが見慣れた設定と微妙に食い違っています
具体的にどのようなものなのか見てみましょう
const { environment } = require('@rails/webpacker')
module.exports = environment
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
const environment = require('./environment')
module.exports = environment.toWebpackConfig()
@rails/webpacker
を require してそれを toWebpackConfig()
したものを webpack.config 用のオブジェクトとして export しています。これに設定を追加する場合は以下のようなコードを追加します
// Set nested object prop using path notation
environment.config.set('resolve.extensions', ['.foo', '.bar'])
environment.config.set('output.filename', '[name].js')
// Merge custom config
environment.config.merge(customConfig)
environment.config.merge({ devtool: 'none' })
// Delete a property
environment.config.delete('output.chunkFilename')
この environment.config.*
の関数が独自で設定された DSL となります
2. toWebpackConfig() をできるかぎり前で実行する
Webpacker を消すと当然ながらこの DSL で記述された部分は動作しなくなるので、まずはこの DSL への依存を減らしていく所から始めましょう
ここで注目するのは最後に実行されている toWebpackConfig()
です。これはプロトタイプ拡張されたオブジェクトを通常のオブジェクトに変換して取り出す関数で、通常はすべての処理の最後に実行されています
const { merge } = require('webpack-merge');
const { environment } = require('@rails/webpacker')
let config = environment.toWebpackConfig()
// Set nested object prop using path notation
config.resolve.extensions = ['.foo', '.bar']
config.output.filename = [name].js'
// Merge custom config
config = merge(config, customConfig)
config = merge(config, { devtool: 'none' })
// Delete a property
delete config.output.chunkFilename'
module.exports = config
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
const config = require('./environment')
module.exports = config
これでようやくぼくらの知っているコードが帰ってきました
!!! CAUTION !!!
簡単に書いていますが、独自の設定が environment.js
や {production|development|test}.js
に書かれているはずなのでそこそこ大変です。特に webpacker.yml
などを経由して設定された内容をさらに上書きしているところなどは、最終的に出力されるオブジェクトとにらめっこしながら調整をしていく必要があります
Carely では最終的にいくつかの delete や insert は残しておき、 export 直前に module.exports = environment.toWebpackConfig().merge(webpackConfig)
として最終的なオブジェクトを生成していました( {production|development|test}.js
にはピュアなオブジェクトが渡される)
STEP2. webpacker.yml をやめる
1. webpacker.yml の役割
これで自分たちが書いたコードからは DSL が消えましたが、これだけではまだ Webpacker を剥がすことはできません。 require('@rails/webpacker')
が残っていて、ほとんどの設定はこの中で生成されています。このモジュールへの依存を剥がしたいのですが、それにはまず webpacker.yml
がなんなのかを知らなければなりません
このファイルの役割は二つあります
@rails/webpacker
が実行時に読み込んで出力するオブジェクトに反映する- Rails 実行時に読み込まれ
Webpacker::Helper
やrake webpacker:*
などで利用される
これが意味するところは Rails と webpack の両方から参照されているため、一部は Rails のために残した上で webpack 用に分離したものとの整合性を取り続けなければいけないのです
すなわちここが Point of no return ですね
2. @rails/webpacker のコードリーディング
では実際に剥がしていきましょう
何をするかというと @rails/webpacker
の中のオブジェクトを生成している部分を取り出して、 config/webpack/*
の配下に作ったフォルダに移動していきます
const { join } = require('path')
const { source_path: sourcePath, static_assets_extensions: fileExtensions } = require('../config')
module.exports = {
test: new RegExp((${fileExtensions.join('|')})$
, 'i'),
use: [
{
...
}
]
}
const { join } = require('path')
const sourcePath = 'path/to/source'
const fileExtensions = ['.array', '.of', '.extensions']
module.exports = {
test: new RegExp((${fileExtensions.join('|')})$
, 'i'),
use: [
{
...
}
]
}
const { merge } = require('webpack-merge');
const fileLoader = require('./loaders/file')
const webpackConfig = {
...
module: {
rules: [fileLoader],
},
...
}
// すべてが移し終わったらこれは消す
const config = environment.toWebpackConfig()
module.exports = merge(config, webpackConfig)
こんな感じで Webpacker が提供しているコードをローカルに移動してきて config/webpack/environment.js
で読み込むようにします
require('../config')
が webpackar.yml
から生成されるオブジェクトなので webpacker/package/config.js
を読んで構造を掴みましょう。ここでの例では定数を直接 loader ファイルに書いてしまいましたが config/webpack/setting.js
みたいなのを用意してそこに設定は集約した方がいいです。 webpacker.yml
との比較もしやすいですし
このステップの最終的なゴールは config/webpack/environment.js
から require('@rails/webpacker')
を消すことです。@rails/webpacker
が出力するオブジェクトは webpacker/package/rules/
のファイルたちを webpacker/package/environments/base.js
でごにょごにょ組み立ててるという構成になっているので頑張ってコードを読みましょう
地味で長い作業ですが地道にやれば終わります。なお、オブジェクト内に array で持っている奴は順番などが変わると出力内容も変わるので、これまたオリジナルのオブジェクトとにらめっこしながら地道に組み立てていきましょう。物によっては順番が変わっても大丈夫ですが、ダメなものも多いです
!!!CAUTION!!!
ローカルに移したタイミングで Webpacker が使っている npm モジュールをローカルの package.json
追加する必要があります。念のためにバージョンは合わせておいた方がいいです
3. webpacker.yml の中身を消していく
config/webpack/
に設定が移動できたら webpacker.yml
の中身を消していくんですが、これらは Rails 実行時にも参照されるので完全に消すことはできません。どれを消せるかは Gem の中身を見つつ勘で決めて最終的にこんな感じになりました
# Note: You must restart bin/webpack-dev-server for changes to take effect
default: &default
source_path: app/frontend # Rails 側で使っているかもしれない。消す
public_root_path: public # Rails 側で使っているかもしれない。消す
public_output_path: packs # Rails 側で使っているかもしれない。消す
cache_path: tmp/cache/webpacker # Rails 側で使っているかもしれない。消す
resolved_paths: ['app/assets/images'] # Rails 側で使っているかもしれない。消す
# Webpacker gem 用の設定。 webpacker とともに消す
check_yarn_integrity: false # rails コマンド時の yarn install の実行を無効にする
cache_manifest: false # Reload manifest.json on all requests so we reload latest compiled packs
extract_css: true # Extract and emit a css file
compile: false # webpakcer を経由してコンパイルするかどうか( false だと assets:precompile に依存する
development:
<<: *default
compile: true
extract_css: false
test:
<<: *default
compile: true
public_output_path: packs-test # rspec が使っている
production:
<<: *default
# Cache manifest.json for performance
cache_manifest: true
RAILS_ENV と NODE_ENV の呪い
フロントエンドエンジニアからするとびっくりなんですが、 Rails + Webpacker の環境には普通 NODE_ENV
が設定されていません。というのも、ビルド時に RAILS_ENV
を元に勝手に設定してくれるのです。さらに webpacker.yml
は RAILS_ENV
をもとに適用する設定の出しわけをするため、それを environment.js
上で再現するには NODE_ENV
の development|production
では足りないのです
この辺りはデプロイ関連のタスクにも影響してくるため以下のような事を注意するのが良いでしょう
- 必ず
NODE_ENV
を設定して Webpacker 関連のタスクを実行する - 機能ごとの環境変数を用意してそれの組み合わせで
webpacker.yml
の状態を再現する
どっちみち、ここは絶対に苦労するので、気合を入れてなんとか乗り越えましょう
STEP3. webpack-dev-server をやめる
1. manifest.json を理解する
webpack でビルドしたファイルを Rails はどうやって読み込んでいるのでしょうか? Webpacker は webpack-assets-manifest
というプラグインを利用して、 public
配下に manifest.json
を生成。それを Rails 側で読み込んで読み込むファイルを決めています
どのような形式でファイルが出力されるかは現行のファイルを確認するのが一番なので、どこに出力されるかを確認して、タイムスタンプや内容をチェックしながら進めていきましょう
2. ホストを指定できるようにする
これまで Docker 経由で配信していたため、 output.publicPath
はルート相対パスで指定されていました。これを localhost
から配信する必要があります。既存の Webpacker にはその辺りの設定は存在しないので実装してあげる必要があります
Carely では WEBPACKER_ASSET_HOST
という環境変数を経由して必要に応じてホストを注入できるようにしました
const dist = 'packs'
const host = process.env.WEBPACKER_ASSET_HOST || ''
const path = 'public'
const publicPath = ${host}/${dist}/
module.exports = {
filename: process.env.NODE_ENV === 'production' ? 'js/[name]-[contenthash].js' : 'js/[name]-[hash].js',
chunkFilename: process.env.NODE_ENV === 'production' ? 'js/[name]-[contenthash].chunk.js' : 'js/[name]-[hash].js',
hotUpdateChunkFilename: 'js/[id]-[hash].hot-update.js',
path: resolve(path, dist),
publicPath,
}
3. ローカル用のコマンドを用意する
Carely で使われている Docker のフロントエンドコンテナでは ./bin/webpack-dev-server
が実行されていました。ローカルでこれを実行してもいいんですが、最終的には削除するファイルなのと Ruby に依存したくないので、WEBPACKER_ASSET_HOST
を指定して webpack-dev-server
を実行します
念のために古いコマンドも残しておきました
"scripts": {
"serve": "NODE_ENV=development WEBPACKER_ASSET_HOST=http://localhost:3035 yarn webpack-dev-server --config ./config/webpack/development.js --progress --color",
"build:old": "docker-compose exec -e RAILS_ENV=development -e NODE_ENV=development rails bundle exec rake assets:precompile"
}
manifest.json との戦い
manifest.json
の出力をするプラグインには webpack-manifest-plugin
というものもあり微妙に出力されるファイルの形式が違うので注意が必要です
また Webpacker はビルドターゲットによって微妙に設定を変えていて(チャンクの設定や MiniCssExtractPlugin
の適用範囲など)、 development
では動いたので production
ではビルドされない、、、みたいな事も何度か発生しました。なるべく本番相当の環境を用意してきちんと動作するかを確認する方がよいです
実際に動いているプロダクトの場合エントリーポイントの数も多くなっているはずで、その分問題も発生するでしょうし、確認する対象も多くなるので想定外の不具合も発生しやすいです。 webpack.config と同様に既存の設定での manifest.json
と見比べながら抜けがないように注意しながら進めてください
STEP4. アンインストール
1. デプロイタスクの修正
Gem を消す前にデプロイタスクの切り替えが必要になります
サービスで利用しているデプロイ手法によって修正方法は変わりますが、 rake assets:precompile
もしくは bin/webpack
がどこかで実行されているはずです。これを npm スクリプトに置き換えます
Webpacker がよしなにしてくれていた環境変数の設定をすることも忘れないようにしましょう
次のステップで gem をアンインストールすると simpacker の導入が完了するまで動作確認ができなくなります。このタイミングで一度デプロイして動作確認することをオススメします
2. webpacker gem をアンインストールする
まずは Gemfile から Webpacker を削除しましょう
それ以外にもいくつか削除が必要なファイルがあります
bin/webpack
bin/webpack-dev-server
config/webpacker.yml
lib/tasks/webpacker.rake
また、 config/environments/*.rb
から config.webpacker.check_yarn_integrity
の記述の削除もしておきましょう
ここまで完了したら bundle install
を実行して完了です 👏
3. 追加で必要になった npm パッケージを package.json に追加する
gem を削除したら yarn install
を忘れずに実行しておきましょう(一度 node_modules 消しちゃっても良いかもしれません)
この状態で webpack を実行するとさっきまで動いていたものが動かなくなっているはずです(動いていたらおめでとうございます。このステップは不要です)。これは Webpacker gem がインストールしていた npm モジュールが一緒に消されてしまったので参照エラーが発生してしまったのが原因です
何も考えずに Webpacker のレポジトリを参照して、 package.json
から dependencies を丸っと持ってきても良いですが、治安が悪くなりがちなので頑張って少しずつ移動していくことをオススメします。これは完全に職人芸なので少しずつやっていくしかありません。もし上手くいかなくなったら最初に戻ってやり直しましょう。それでも上手くいかなかったら一度寝て翌日やると良いと思います
4. simpacker をインストールする
Webpacker を削除してしまったため webpack が生成した JavaScript や CSS のファイルの読み込みができなくなってしまいました。具体的には javascript_packs_with_chunks_tag
や stylesheet_packs_with_chunks_tag
が使えなくなります
機能をシンプルにした代替の gem を利用しましょう。せっかく消したのに、、、という気持ちになるかもしれませんがこれらは単機能でシンプルな作りになっているため、心配しないでも大丈夫です
- hokaccha/simpacker: Use modern JavaScript build system in Rails.
- nikushi/minipack: Minipack, a gem for minimalists, which can integrates Rails with webpack. It is an alternative to Webpacker.
今回は simpacker を採用しましたがどちらでも大丈夫だと思います。これらの導入に関しては記事も色々ありますしドキュメントも充実しているため、 詳しくは解説しません
simpacker はシンプルすぎて split chunk に対応していないため自分で実装する必要があります。以下のようにヘルパーを実装して、既存の javascript_packs_with_chunks_tag
や stylesheet_packs_with_chunks_tag
は変更せずに使えるようにしました
# frozen_string_literal: true
# <https://github.com/hokaccha/simpacker/tree/master/example/split-chunks>
module SimpackerHelper
def javascript_packs_with_chunks_tag(*names, **options)
paths = names.flat_map{ |name| simpacker_context.manifest.lookup!("entrypoints", name, "js") }.uniq
javascript_include_tag(*paths, **options)
end
# rubocop:disable Lint/SuppressedException
def stylesheet_packs_with_chunks_tag(*names, **options)
paths = names.flat_map{ |name| simpacker_context.manifest.lookup!("entrypoints", name, "css") }.uniq
stylesheet_link_tag(*paths, **options)
rescue Simpacker::Manifest::MissingEntryError
# css を extract しない場合もあるのでその場合はエラーが発生するが何も返さない
end
# rubocop:enable Lint/SuppressedException
end
5. 動作確認
simpacker が正しくインストールされていればあとは動作確認だけです
影響範囲が多く、また個別に動かないケースもあり得るので、ここは全ページを目視してやるぜくらいの勢いで確認しましょう。リリースまでのスケジュールに長めの動作確認のフェーズを用意して、修正漏れの対応ができるように余裕を持って対応しましょう!
ぼくはアドバイザーという立場上プロダクト自体には詳しくないので、開発部のみなさんの協力を仰ぎました(特にデザイナーの皆さんありがとうございました!)
!!!CAUTION!!!
simpacker の導入を行ったところなぜかサービスクラスのテストが失敗するようになりました
ApplicationHelper に実装した simpacker 用のヘルパー関数が ActionController::Base.new
経由の render_to_string
メソッドを利用している箇所からヘルパー関数が見えずにエラーになっていたようです
こちらは技術顧問の @netwillnet さんにアドバイスをいただき ApplicationController.render
を直接呼び出すことで解決できました。技術顧問大事
最後まで困らせてくれるぜ Webpacker テストがちゃんと書いてあるとこういうのに気づけて良いですね
お疲れ様でした!!!
既存の開発と並行しつつ約半年のかけたプロジェクトがようやく完了です
手に入れたもの
- ローカルで軽快に動く webpack-dev-server
- Rails を知らなくてもビルドをコントロールできる
- CI, コンテナレジストリとの連携などを通して明るい未来への道
これから Webpacker を剥がす人へのアドバイス
- Webpacker のバージョンを意識しよう
- webpack.config はただのオブジェクトなので恐れずに
- 本番相当の環境を用意して、目視による動作確認をちゃんとしよう
- 一度にいくつも変更しない(移動とバージョンアップを一緒にするとか)
参考資料
このプロジェクトをやり切るために参考にした神資料たち
- Webpacker使うなら最低限これだけは知っておいてほしいこと - Qiita
- 今日から簡単!Webpacker 完全脱出ガイド - pixiv inside
- Simpacker: Rails と webpack をもっとシンプルにインテグレーションしたいのです - クックパッド開発者ブログ
- Ruby on Rails: WebpackerからピュアなWebpack環境に置き換えるメモ📝 - Madogiwa Blog