👂

プラグイン を Redmine 4.* でも 5.0 でも動かすためには

2021/12/08に公開

この記事は Redmine Advent Calendar 2021 の8日目の記事になります。前日の記事は、onozatyさんの「Redmine View Customize Plugin のコード例を整理しました - Enjoy*Study」でした。

はじめに

Redmine の次期リリース 5.0 は Ruby on Rails6.1 上で稼動する予定です。
その一連の対応の一つに Rails6 のオートローダーの変更があります。オートローダーの対応は先月完了した[1]のですが、複数のプラグインがそのままでは動かなくなる、という話を耳にしました[2]
そこで、zeitwerkの概要とともに、Redmineプラグイン作成という切り口からzeitwerkにどう立ち向うのか、いくつかの方法を紹介したいと思います。

1. zeitwerk がやってきた

1.1 zeitwerk とは

zeitwerk は、Rails6 から導入された新しいオートローダーです。Rails の開発では、定義したクラスやモジュールをいちいち require で呼び出さなくても良いようにオートローダー機能が用意されています。しかし限られたケースですが問題が発生することも知られていました[3]。それらの問題を解消するために導入された gem が zeitwerk です[4]

Rails6 では、従来のオートローダーを使用する classicモードとzeitwerkを使用するzeitwerkモードが選択できるようになっています。
さらに今月クリスマス前にはリリースされると言われている[5] Rails 7.0 では classicモードが削除され Rails のオートローダーは zeitwerk のみになります。

1.2 そもそもオートローダーって何するんだっけ?

オートローダーを利用することで、あらかじめ指定したディレクトリ以下で定義されたクラス、モジュールは、require 無しで呼び出すことができ、コードの記述量を減らせます。

またロードされたクラス、モジュールは、任意のタイミングでリロードできます。

Railsガイド では、developmentモードでの挙動について以下のように説明しています。

Webサーバーが実行中の状態でアプリケーションのファイルが変更されると、Railsは次のリクエストが処理される直前にすべての定数をアンロードします。これによって、アプリケーションでリクエスト継続中に使われるクラスやモジュールが自動読み込みされるようになり、続いてファイルシステム上の現在の実装が反映されます。

コードを修正するたびに Rails を再起動するのに比べてよりスピーディーに開発が進められます。

また、productionモードでは、オートロード対象のクラス、モジュールをアプリ初期化後に一括でロードします。これを、事前一括読み込み(eager loading)と言います。

1.3 zeitwerkモード利用時の従来からの変更点

主に以下の2点です。

初期化時には、オートロード対象のクラス・モジュールを使用できない。

Railsアプリ起動時の初期化時のコード[6]でオートロード対象のクラス、モジュール[7]を使用するには、Rails.configuration.to_prepare メソッドのブロックで囲む必要があります。

ブロック内のコードは初期化時に実行されるわけではなく、「初期化後でアプリ本体の開始直前」または「クラス、モジュールがリロードされた時」に実行されます。

Railsガイドでは、Rails.configuration.to_prepare を以下のように説明しています。

to_prepare これは、Railtiesの初期化処理とアプリケーション自身の初期化処理がすべて実行された後、かつ事前一括読み込み (eager loading) の実行とミドルウェアスタックの構築が行われる前に実行されます(訳注:
RailtiesはRailsのコアライブラリの1つで、線路の犬釘を表すrail tieのもじりです)。さらに重要な点は、これはdevelopmentモードではサーバーへのリクエストのたびに必ず実行されますが、productionモードとtestモードでは起動時に1度だけしか実行されないことです。

つまり、Rails のアプリでは、コードが実行されるタイミングが大まかに、初期化時、リロード時、アプリ実行時に分かれることになります。

オートロード対象のクラス・モジュールを本当に初期化時に使うときは、require で呼び出す必要があります。ただしこの場合、1.2で述べたようなサーバ起動中のリロードの対象から外れます。ちなみにrequireなしでクラスを呼びだそうとするとアプリ起動時に Deprecation Warning が表示されます。

ディレクトリ構造、ファイル名と名前空間、クラス・モジュール名の一致が必要になる。

zeitwerk の特徴として一番よく言われるのがこれです。

zeitwerkモードでは、クラス・モジュールが使用されるとあらかじめ登録されたディレクトリを探し、そこで定義されたクラス・モジュールをロードします。この際、パス名(スネークケース)とクラス名(キャメルケース)が対応している必要があります。app/models/issue_query.rb には、IssueQueryクラスが、lib/redmine/utils/date_calculation.rb には Redmine::Utils::DateCalculation が定義されていなればなりません。クラス、モジュールが発見できない場合、エラーとなります[8]

実際にどんなパス名がどんなクラス名に変換されるかは、rails console でcamelizeを使用すれば確認できます。

"redmine/utils/date_calculation".camelize
# => "Redmine::Utils::DateCalculation"

2. Redmineの対応

2.1 Redmine 本体の対応

チケット32938 で導入方法が検討され、2021/11/18にパッチがコミットされました(その後、プラグインのcssやjavascriptを取得する部分でバグが発見され、11/25に修正されました。ホントすいません)。

従来の実装では、各プラグインのinit.rb は、初期化時に実行されていましたが、zeitwerkモードの「初期化時には、オートロード対象のクラス・モジュールを使用できない」という制約に抵触するため、リロード時の実行に変更となりました。

これにともない、init.rb で動いていた処理が動かなくなるケースが出てきます。以下、ケースごとに対処法を見ていきます。

なお、2021/11/15 にリリースされた Redmica 2.0 は、Rails6.1 を使用していますが、オートローダーは、classicモードで動いています。

2.2 プラグインの対応方法

  • app/, lib/ 以下のディレクトリ構造、ファイル名をクラス・モジュールの定義に合わせます。
  • オートロード対象のクラス・モジュールが require されているとリロードの対象とならないため、不要な require は削除します。また、zeitwerk 導入にともない、require_dependency は不要となっているはずなのでこちらも削除します。
  • init.rb は、リロード時に呼ばれるようになったため、 Rails.configuration.to_prepare を使用していた場合、ブロック内の内容は、外側に直接書けば大丈夫です。Redmine 4.*系でも動くようにするためには、以下のようにすれば大丈夫でしょう。
init.rb修正前
Rails.configuration.to_prepare do
  # リロード時の処理
end
init.rb修正後
# zeitwerk_enabled? は、Redmica2.0での稼も考えると必要
if Rails.version > '6.0' && Rails.autoloaders.zeitwerk_enabled?
    # リロード時の処理 
else
  Rails.configuration.to_prepare do
    # リロード時の処理
  end
end

2.3 全プラグインが読みこまれた後に実行する処理を書きたい

従来、プラグインのinit.rbは初期化時に読み込まれていました。そこでリロード時の処理としてRails.configuration.to_prepareで処理を登録しておくと全てのプラグインのinit.rbが実行された後の処理を自然に書くことができました。

今後はそうした方法が使えません。幸い、Redmine 4.2 以降では、全プラグインが登録された後に発火する after_plugins_loadedフックが追加されています。プラグインの中にすでにフックのリスナーがあるならそこにafter_plugins_loadedメソッドを追加すれば良いと思います。

既存のフックが無い場合、init.rbに書くのが楽でしょう。

Class.new(Redmine::Hook::ViewListener) do |c|
  def after_plugins_loaded
    #  何かの処理 
  end
end

なお、こういうケースがあるよ、というのは チケット36245 で指摘を受けました。

2.4 クラス名・モジュール名にHTTPなどの略語を使いたい

zeitwerk は基本的に、クラスが定義されたファイルのパス名を camelize したクラス名をロードしようとします。しかし実際には、PDF、CSV など全て大文字を使った略語をクラス名にしたいことはよくあります。

Redmine でも CSV や PDF には全て大文字を使っています。またモデルのVersionとRedmine自体のバージョン情報であるRedmine::VERSIONが存在し、通常のzeitwerkのクラス探索機能だけでは対処できません。このあたりはzeitwerk を初期化するconfig/initializer/zeitwerk.rbでzeitwerkの挙動をカスタマイズしています。

今のところ、プラグインからこうした略語を追加する方法は用意されていません(この記事を書いている過程でそいう要望が出てくる可能性に思い至りました)。

今後、プラグインによるカスタマイズが可能かどうかも含めて検証が必要です。

3. 参考になるドキュメント

Rails ガイド

定数の自動読み込みと再読み込み (Zeitwerk)
Classic から Zeitwerk への移行

zeitwerk 本家

README に非常にいろいろなケースに対する対処法が記載されています。

その他

Railsにおける zeitwerk について幅広くまとまっています。
Upgrading To Zeitwerk

脚注
  1. 筆者もちょっとパッチを書きました ↩︎

  2. 一説では、7割近くが動かなくなるとか ↩︎

  3. 「定数読み込み順と名前空間」地獄とrequire_dependency ↩︎

  4. 開発者の Xavier Noria さんは、Railsのコア開発者の一員ですが、zeitwerk はRailsと関係ないruby開発でも使えます。 ↩︎

  5. Rails 7.0 RC1: New JavaScript Answers, At-Work Encryption, Query Origin Logging, Zeitwerk Exclusivelyの末尾を参照 ↩︎

  6. config/initializers/ 以下にあります ↩︎

  7. app/ 以下のディレクトリ内のクラスやモジュール 。Redmineでは、 lib/redmine も対象 ↩︎

  8. 1 ファイル1クラスなら、StandardErrorを継承しただけのエラーのためにファイルを分けるのか、と思われるかもしれませんが、Foo::Error とかは、foo.rbの中にあっても大丈夫です ↩︎

Discussion