モノリシックな Ruby on Rails サービスからの Webpacker の剥がし方 | Dev Driven 開発・デザインチーム モノリシックな Ruby on Rails サービスからの Webpacker の剥がし方 | 働くひとと組織の健康を創る iCARE

BLOG

モノリシックな Ruby on Rails サービスからの Webpacker の剥がし方

2021/07/20

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 系の場合のみ参考にしてください
PR その1 - Webpacker 依存の config を整理して、普通っぽい webpack.config に修正する

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 をやめる

PR その2 - webpacker.yml を解体して疎結合を進める

1. webpacker.yml の役割

これで自分たちが書いたコードからは DSL が消えましたが、これだけではまだ Webpacker を剥がすことはできません。 require('@rails/webpacker') が残っていて、ほとんどの設定はこの中で生成されています。このモジュールへの依存を剥がしたいのですが、それにはまず webpacker.yml がなんなのかを知らなければなりません

このファイルの役割は二つあります

  1. @rails/webpacker が実行時に読み込んで出力するオブジェクトに反映する
  2. Rails 実行時に読み込まれ Webpacker::Helperrake 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.ymlRAILS_ENV をもとに適用する設定の出しわけをするため、それを environment.js 上で再現するには NODE_ENVdevelopment|production では足りないのです

この辺りはデプロイ関連のタスクにも影響してくるため以下のような事を注意するのが良いでしょう

  1. 必ず NODE_ENV を設定して Webpacker 関連のタスクを実行する
  2. 機能ごとの環境変数を用意してそれの組み合わせで webpacker.yml の状態を再現する

どっちみち、ここは絶対に苦労するので、気合を入れてなんとか乗り越えましょう

STEP3. webpack-dev-server をやめる

PR その3 - ローカルの 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. アンインストール

PR その4 - 【全体】 bye bye webpacker forever

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_tagstylesheet_packs_with_chunks_tag が使えなくなります

機能をシンプルにした代替の gem を利用しましょう。せっかく消したのに、、、という気持ちになるかもしれませんがこれらは単機能でシンプルな作りになっているため、心配しないでも大丈夫です

今回は simpacker を採用しましたがどちらでも大丈夫だと思います。これらの導入に関しては記事も色々ありますしドキュメントも充実しているため、 詳しくは解説しません

simpacker はシンプルすぎて split chunk に対応していないため自分で実装する必要があります。以下のようにヘルパーを実装して、既存の javascript_packs_with_chunks_tagstylesheet_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 テストがちゃんと書いてあるとこういうのに気づけて良いですね

お疲れ様でした!!!

既存の開発と並行しつつ約半年のかけたプロジェクトがようやく完了です

Slack にあふれる喜びの言葉

手に入れたもの

  1. ローカルで軽快に動く webpack-dev-server
  2. Rails を知らなくてもビルドをコントロールできる
  3. CI, コンテナレジストリとの連携などを通して明るい未来への道

これから Webpacker を剥がす人へのアドバイス

  1. Webpacker のバージョンを意識しよう
  2. webpack.config はただのオブジェクトなので恐れずに
  3. 本番相当の環境を用意して、目視による動作確認をちゃんとしよう
  4. 一度にいくつも変更しない(移動とバージョンアップを一緒にするとか)

参考資料

このプロジェクトをやり切るために参考にした神資料たち