Hotwire への道 1. アセットパイプライン編

2022/12/04に公開約8,800字

この記事は、Redmine Advent Calendar 2022 の5日目の記事です。前日の記事は、yamasakiさんの「wikiを閉じたり展開する機能をJavaScriptで作ってみた」でした。

要点

  • 将来、Redmine を Rails7 に対応させると、Hotwireを避けては通れない。
  • Hotwireの利用はアセットパイプラインという仕組みの導入が前提。
  • Redmineでアセットパイプラインを導入するにはテーマ、プラグインへの対応が必要だった。

はじめに

昨年の Advent Calender の記事で、Redmine を Ruby on Rails 7.0betaで動かすという記事を書きました。その後、Rails 7 は無事リリースされ、現在のバージョンは Rails 7.0.4 です。

Rails 7 の大きなの変更点は、JavaScript関連のライブラリの刷新です。これはユーザーが直接触れることのないモジュール管理ツールから直接ユーザー体験に関わるフロントエンドライブラリまで全てに及びました。

このうちフロントエンドの部分は、Hotwire と称してRuby on Railsからは独立したライブラリとして展開されることになりました。
Hotwireの導入により、大量のJavaScriptのコーディングなしにユーザー体験を改善できると言います。

Hotwire は、完全に新しい技術というようりは、それまで Rails で培ってきた諸々のライブラリやパターンをブラッシュアップして独立させたものです。実体は turbo、 stimulus、strada(未リリース) という3つのライブラリで、Rails以外のフレームワークでも利用可能です。
一方で各種ドキュメントを見ても、Rails 7 は Hotwireの使用が前提となってくるようです(Rails 7 で新しいプロジェクトを開始すると、従来の rails-ujs というライブラリのかわりに turbo が使われるようになっている)。

Redmine にも導入しようとして試行錯誤してみたのですが、その前にアセットパイプライン(asset pipeline)を導入する必要があることが分かりました。

アセットパイプラインとは?

Webサイトを閲覧するとき、ユーザーはWebサーバから取得したhtmlを閲覧する訳ですが、それに付随して配信される画像、スタイルシート、JavaScriptといったファイル群をWeb開発界隈ではアセットと呼んでいます。

アセットパイプラインとは、Rails側がアセットを閲覧者に効率的に配信するための仕組みです。導入当初の役割は以下のようなものでした。

1. アセットの連結

Webアプリをブラウザで表示するとき、1個の大きなファイルをダウンロードする方がサイズの小さい大量のファイルを個別に取得するよりも効率的です。アセットパイプラインは小さいサイズの大量のアセットを1個の大きなファイルに結合します。

2. アセットのコンパイル

JavaScript、CSSの記述を快適にするために導入されたSass、Scss 、CoffeeScript などのコードを実際に使用されるcssやJavaScriptへ変換します。

3. アセットの最小化

JavaScriptやCSSから不要な空白や改行、コメントを取り除くことでファイルサイズを小さくします。

4. フィンガープリントの追加

アセットの内容が変更されたとき、既存のブラウザキャッシュを無効化し新しいアセットをキャッシュさせる仕組みをキャッシュバスターと呼びます。

アセットパイプラインではアセットの内容に応じてファイル名に sha256のハッシュを追加します。ファイル内容が少しでも変化するとファイル名も変更されるため、ブラウザ側でキャッシュが更新されず表示が乱れるといったことが起きなくなります。

初めて導入されたのはRails3.1(2011年8月リリース)の時でした。sprockets というライブラリによって実現されています。

Redmineとアセットパイプライン

Redmine ではデフォルトでは、アセットパイプラインを利用していません。

チケット上で議論されていますが、Redmine の管理者全てが Rails に精通しているわけではなく、無用な混乱を避けるためにも無効とした、ということのようです(Feature #11445: Switch from Prototype to JQuery)。かつてのsprocketsはインストール時にJavaScript実行環境との相性問題があってインストールエラーが多発し導入の難所になっていたたそのあたりを考慮したと思われます。各アセットをキャッシュさせるかどうかの制御は、各アセットの末尾にファイルの更新時刻をベースとしたクエリ文字列を追加する手法を取とり続けています。[1]

さらに sprocketsのバージョンアップにともない、Redmineの起動自体ができなくなるという問題もあり、デフォルトで無効にするだけでなく、sprocketsの読み込みそのものを行わないようになりました。(Defect #32223: Disable sprockets to avoid Sprockets::Railtie::ManifestNeededError raised by sprockets 4.0.0 )

こうした対応の背景として、Redmineではこの機能は、ぶっちゃけそこまで必要とも思われていなかったのではないか、というのがあります(個人の感想です)。

Redmine の利用シーンとして一番よく考えられるのは(検討された当時は)イントラネット内での利用です。インターネット上で公開して不特定多数からのアクセスを受けるサーバのパフォーマンスを最大化したいという需要はそこまではなかったことでしょう。

一方で、それでもアセットパイプラインは必要、という人のためにはプラグイン[2]が用意されていました。また、Redmine3系をベースにしたクラウドサービス planio ではアセットパイプラインを使用しているそうです(Patch #23980: Replace images with icon fonts)。クラウドサービスなど不特定多数にファイルをインターネット経由で配布するようなケースではアセットパイプラインは相性が良さそうです。

Hotwire とアセットパイプライン

Rails で Hotwireを導入するときは、モジュール管理ツールとして importmap-rails を使うか、jsbundling-rails を使うかを選ぶ必要があります。重要なのはどちらであってもアセットパイプラインを前提にしているということです。アセットパイプライン登場から10年以上が経過し、「使わない」といった選択肢は考慮する必要ないということでしょう。
なお、話が長くなるのでモジュール管理ツール選択以降の話は稿を改めます。

Rails 7 (その1) Rails 7 (その2) Rails 6.1 Redmine
フロントエンドライブラリ Hotwire Hotwire rails-ujs, turbolinks rails-ujs, jquery,jquery-ui
JavaScriptのモジュール管理 importmap-rails jsbundling-rails Webpacker なし
開発時のnodejs環境の要否 不要
キャッシュバスター アセットパイプラインのフィンガープリント アセットパイプラインのフィンガープリント アセットパイプラインのフィンガープリント クエリ文字列の追加

Redmineでは JavaScriptのライブラリを利用する際に、配布されている JavaScriptのファイルを公開ディレクトリにポンと置いておくだけでした。しかしHotwire(というか、その実体である turbo と stimulus)では、そういう手段が使えません。

Hotwire を使うには、turbo-rails、simulus-rails といったgemをインストールする必要があるのですが、これがモジュール管理ツールの利用を前提としているのです。特に turboでは、Railsで Turboが使いやすくなるような helperが多数用意されていて、これを使わないというのは現実的ではありません。

前述の障害を乗り越えて sprockets を何とか導入しないといけないのでしょうか? いいえ、sprocketsにかわって 新しくpropshaftが登場しました。近年のWeb開発環境が進化し、上記のアセットパイプラインの役割が以前に比べて縮小しつつあることを反映したものです[3]

propshaft は sprockets に比べて軽量でコードが追いかけやすく、後述のテーマ、プラグインへの対応時もどこをカスタマイズすれば良いのか把握しやすくなっています。

propshaft 導入

インストール

  • Gemfile に propshaft を追加し bundle install を実行します。
Gemfile
gem 'propshaft'

アセットパスの追加

標準では、app/assets/stylesheets/app/assets/images/ などを参照します。Redmine でアセットが保存されている public/stylesheets/public/images/ を参照するためには assets_paths に設定を追加する必要があります。

config/application.rb
+ config.assets.paths << Rails.root.join('public/javascripts')
+ config.assets.paths << Rails.root.join('public/stylesheets')
+ config.assets.paths << Rails.root.join('public/images')

スタイルシートのURL修正

propshaft 導入にともない、スタイルシート内で画像ファイルを参照している部分を書き換えてあげる必要があります。propshaft導入後は、Railsから見てアセットの配置が以下のようにフラットな形になるためです。

以下は書き換えの例です。スタイルシートと画像が同じディレクトリに保存されているような形に書き換える必要があります。

public/stylesheets/application.css
span#watchers_inputs {overflow:auto; display:block;}
span.search_for_watchers {display:block;}
span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
-span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
+span.add_attachment a {padding-left:16px; background: url(bullet_add.png) no-repeat 0 50%; }

input:disabled, select:disabled, textarea:disabled {
cursor: not-allowed;

実際のディレクトリ構成

public/
├── images
│   ├── a.png
│   ├── b.png
│   └── c.png
├── javascripts
│   ├── a.js
│   ├── b.js
│   └── application.js
└── stylesheets
   ├── a.css
   ├── b.css
   └── application.css

アセットパイプライン処理後のディレクトリ構成。

"xxxxxxxxxxxxxxxx" の部分には実際はファイルのハッシュ値が入ります。ファイルの内容が変更されると自動的にファイル名も変更されるため、内容の古いアセットをキャッシュから読みこむことはありません。
開発時には、propshaft が、あたかもこのような形でファイルがあるかのようにアセットを配信してくれます。本番環境ではアプリケーションの起動時に、この通りの形でアセットをディレクトリにコピーします([4])。

public/assets/
├ a-xxxxxxxxxxxxxxxx.png
├ b-xxxxxxxxxxxxxxxx.png
├ c-xxxxxxxxxxxxxxxx.png
├ a-xxxxxxxxxxxxxxxx.js
├ b-xxxxxxxxxxxxxxxx.js
├ application-xxxxxxxxxxxxxxxx.js
├ a-xxxxxxxxxxxxxxxx.css
├ b-xxxxxxxxxxxxxxxx.css
└ application-xxxxxxxxxxxxxxxx.css

Redmine のモンキーパッチを削除する

propshaft は今まで述べたようにファイル名にハッシュ値を自動で追加してくれます。開発者はビューでアセットを利用したい場合、今まで通り

<%= javascript_include_tag 'application' %>

とするだけで、上記のようなハッシュ付きのファイル名を参照してくれます。従来のファイル名の後ろにクエリ文字列をくっつけるキャッシュバスターは不要となるので、モンキーパッチを削除します。

動作確認

bin/rails s を実行してブラウザでアクセスし、ログに以下のような出力があれば成功です。

started GET "/assets/application-4e4a568466d50c73469a745b2a5e028e6a0e3486.css" for 172.22.0.1 at 2022-12-04 07:22:35 +0000

プラグインとテーマに対応する

これで終わりではありません。プラグインとテーマをアセットパイプラインで管理する方法を考えます。

上で見てきたように、propshaft では複数のアセットパスの内容をフラットな1個のディレクトリの中にあるものとして扱います。つまり別々のアセットパスの中に、偶然 application.js といったファイルがあった場合、どちらかは参照できなくなります。

plugin_a と plugin_b の両方に app.js というファイルが存在しているとして、各プラグインのアセットを単純にアセットパスとして追加するだけでは後から読まれたファイルが /assets/app-xxxxxxxxxxx.js といったファイルとして登録されてしまい、もう一方のファイルにアクセスできなくなってしまいます。
これを回避するためには、 plugin_a のアセットは plugins/plugin_a/app.js plugin_b のアセットは plugins/plugin_b/app.js というキーでアクセスできるようにしてあげる必要があります。
テーマは、public_html/themes/テーマ名/ 以下に保存されていスタイルシート、 JavaScript等の集まりです。各テーマのメインとなる cssのファイル名は application.css で固定、JavaScriptのファイル名も theme.js で固定です。こちらも単にテーマのディレクトリをアセットパスに追加するだけでは、デフォルトの application.css が読み取れなくなります。また複数のテーマの共存もできません。テーマ名が theme_a のアセットは themes/theme_a/applicattion.css というキーで、テーマ名が theme_b のアセットは themes/theme_b/applicattion.css というキーで取得できるようにしてあげないといけません。

これは、propshaft にモンキーパッチを当てることで対応しました。

https://github.com/tohosaku/redmine/tree/propshaft

せっかく古いモンキーパッチを削除できたところですが、これは止むを得ません。

まとめ

propshaftにより Hotwire導入の準備が整いました。

加えて以下のようなメリットがありました。

  • Redmine からクエリ文字列追加(railsガイドで諸々の問題点が指摘されている)のモンキーパッチを削除できた。
  • Redmine では、起動時に各プラグインのアセットをplugin_assetsという公開ディレクトリにコピーしている。プラグインをアセットパイプラインに対応させたおかげで、この処理が不要となった。
  • 以前より報告されていたスタイルシートの @import がうまく更新されない問題(Defect #29625: application.css imported by themes not covered by cache control versioning)が解消します。

一方で以下のような課題もあります。

  • アセットパイプライン対応のためには、すべてのプラグインとテーマの開発者に CSS のパスの書き換えてもらう必要がある (css のurlを書き換えるだけですが、同時に複数バージョンの Redmine に対応することはできなくなる)。

長々とやってきましたが、ようやく振出しです。次回、いよいよ Redmine に Hotwire を導入していきたいと思います。

脚注
  1. ちなみにこの手法は、Railsガイドでボコボコに批判されています ↩︎

  2. Redmine Plugin Asset Pipeline plugin.ただし、Redmine5 では動かないと思います ↩︎

  3. 詳しくは Sprocketから要らなくなった機能を削減したPropshaft - @ledsun blog (hatenablog.com)をごらんください ↩︎

  4. プレコンパイルと呼びます ↩︎

Discussion

ログインするとコメントできます