Kaigi on Rails 2023 の懇親会でふらふら歩いていたところ、たまたまスタッフのうなすけさんやふーがさんの会話に混ざることになりました。おふたりとは初対面だったのですが、何の流れか conference-app に型をつけてみよう、という話になりました。
ということで、今回は Kaigi on Rails の conference-app に型をつけてみた話を紹介します。
型をつけ始める際に conference-app チームと会話をして、現状と目標を確認しました。
はじめて一緒に作業をすることになるので、こういったゴールの確認は大事ですね。
僕は普段 ゆるやかな型の導入 として、敢えて型検査を行わない方針で型を導入していることもあり、型検査を有効にしたまま Rails アプリに型を導入するのは初めての経験です。面白そうですね。
まず最初に確認したのは Steep の設定です。型は導入済みで、CI で型検査するようにしているとお伺いしていたのですが、実は正しく設定されておらず、検査が行われていませんでした。
Steepfile には以下の記述があり、モデルコードの対象に検査をするように設定されていました。
check "app/models/**/*.rb"
しかし、Steep-1.5 にはバグがあり、** による対象ファイルの指定に失敗することがあるようです。そのため、conference-app では対象ファイルが見つからない、と判断されて型検査がスキップされていました。このバグは次のリリースで修正される予定です。
Steep はディレクトリを指定すると再帰的に Ruby ファイルを探索してくれますし、 **/*.rb
と指定する必要もない場面だったため、以下のシンプルな記述に変更して型検査を復活させました。
check "app/models"
パターンでファイルを絞り込みたい場合は、Steep-1.6 のリリースを待つとよさそうです。ちょうど Kaigi on Rails の開催期間中に Steep-1.6 の pre バージョンがリリース されていましたし、こちらを試すのも良さそうです (追記: 11/9 に Steep-1.6.0 がリリースされました)。
Steep の設定を調整したところ、型検査の実行でエラーが出るようになりました。いままで型検査がパスしているつもりでいたのですが、Steep のバグにより型エラーがあることに気づけていなかったようです。
まず最初に手を付けたのは、型定義の不足への対処です。rbs_rails や手書きによって、モデルの型は用意されていたのですが、モデル全体を網羅できていませんでした。
そこで、 rbs prototype rb
を使って型定義を生成するようにしました。rbs prototype rb
は rbs gem に含まれるコマンドで、Ruby のソースコードを静的解析して型定義ファイルを生成します。
ほぼすべてのメソッドが untyped
(未定義型)として出力されるため、型の正確性での弱点はありますが、網羅率をかんたんに上げられるというメリットがあります。デフォルトの型検査をパスするためには網羅性が求められることもあり、 rbs prototype rb
を利用することにしました。
(なお、Kaigi on Rails 2023 では ksss さんが orthoses を紹介していましたが、今回は自分が手慣れた rbs prototype rb を選択させてもらいました。どちらをチョイスしてもよさそうだと思います)
なお、rbs prototype rb コマンドの運用には、RubyKaigi 2023 の pocke さんのトークで紹介されている rbs.rake が便利です。この Rake タスクを組み込むと、型定義ファイルの生成や更新にかかわる一連の処理を rails rbs:setup
コマンドで行ってくれます。具体的には以下の処理が行われています。
rbs prototype rb
コマンドによる型定義の抽出rbs subtract
コマンドによる型定義とのマージ
ここまでの一連の修正はこちらの PR で修正を行いました。無事に CI による型検査が復活しました。
rbs prototype rb
コマンドの導入と並行して、モデル内で利用している gem の型を追加しました。デフォルトの検査モードでは、依存 gem の型定義も検査に利用するため、型が提供されていない gem を利用している場合は、自身で型定義を用意する必要があります。徐々に gem_rbs_collection リポジトリに収録されている gem の数も増えてきてはいますが、2023年現在では自身で型を準備する心づもりが必要とされます。
型が提供されていない gem を見つけるには、対象のアプリを Steep で型検査を行い、UnknownConstant
エラーや NoMethod
エラーを見ていくと良いでしょう。これらのエラーを見て、どの gem の型を補う必要があるのか確認します。
調査の結果、conference-app のモデルでは以下の gem の型が不足していました。
あとは、これらの gem ひとつずつに対して型をつけていきます。gem の型を作っていく際は、ドキュメントや実際のコードを読むと型を書く手助けになります。
なお、型をつける際は gem のすべてのクラス、すべてのメソッドを網羅する必要はありません。アプリケーションで利用している部分、エラーが発生した部分に絞って型を付けていくと良いでしょう。大きな gem の場合、すべてのコードに対して型を付けていこうとする場合、アプリケーション本体に辿り着く前に力尽きてしまうことになりかねません。自分のアプリで必要な部分に絞って型をつけることをおすすめします。
また、ここで作成した型情報は rbs 本体や gem_rbs_collection に少しずつフィードバックを進めています (open-uri, web-push, octokit)
ここまでの作業で app/models
以下のファイルが型検査にパスするようになりました。
あとは同じ手順で、対象のファイルを徐々に広げていきます。
conference-app への型の導入作業では、app/
以下のディレクトリひとつにつきひとつの PR を作りながら対象を広げていきました。具体的な変更内容は以下の PR を見ていただくとよいかと思います。
モデルに対して行った作業を繰り返し行っている様子が見ていただけるはずです。
Steep のデフォルトの検査モードは指摘がやや厳し目なため、一気にアプリ全体に対象を広げようとすると、修正規模が一気に増えがちです。少し手間ではありますが、1ディレクトリずつ作業を進めていくと良いでしょう。差分も小さく抑えられるため、レビューもやりやすいはずです。
なお、Steep の検査モードを lenient
モード(緩め)や silent
モード(指摘なし)に設定している場合は、もう少しまとめて作業を進めても問題ないかと思います。選択する検査モードに合わせてペースを調整してみてください。
※ 筆者(@tk0miya) は Steep による型検査は、Rails アプリを検査するには未成熟である(さらに機能追加が必要である)と感じているため、実務のアプリケーションでは silent
モードを選択しています。
現在の自動抽出ツールにはいくつかの問題点があります。そのため、以下の点については手書きで型を補いました。
Rails では Zeitwerk オートローダーの機能により、以下のようなネストされたクラス定義、モジュール定義を行った場合、自動的に Admin
モジュールが定義されます。
class Admin::UsersController
しかし、rbs prototype rb
はこうした仕組みを持たないため、Admin
モジュールの型が生成されません。この状態で型検査を行うと、 Admin
モジュールが未定義のままとなるためエラーが発生します。Rails では見慣れた書き方なのですが、Ruby 本体ではエラーになるので致し方ないですね。
今回は Admin
モジュールの型を手書きで補って対応しました。
※ 以前別の記事で紹介しましたが、拙作の rbs_heuristic_prototype gem では、ネストされたクラス定義自動的に展開してくれます。
現在の rbs_rails gem は Rails7 形式の enum に対応していません。そのため、ActiveRecord モデル内の enum 宣言に対する scope などの型が生成されません。enum を利用している場合は、enum 関連のメソッドを手書きで補う必要があります。
なお、rbs_rails に対しては修正を提案しています。この PR がマージされると自動的に型が抽出されるため、手書きは不要となります。
現在の rbs_rails gem は HABTM (has_and_belongs_to_many) に対応していません。enum と同様、ActiveRecord モデル内で HABTM 関連を宣言していても、生成されるメソッドの型定義が生成されません。こちらも、手書きで型を補う必要があります。
なお、こちらも rbs_rails に修正を提案しています。
Kaminari の per, page メソッドや ActionText の with_rich_text_… メソッドなど、gem の導入によって自動的に追加されるメソッド群には対応していません。こちらも、手書きで型を補いました。
Kaigi on Rails の conference-app に型を導入しました。
rbs prototype rb
を使いプロトタイプの型を抽出することにより、手早くアプリケーション全体を型検査できるところまで持っていきました。依存している gem もあまり多くないため、新たに必要となった gem の型もあまり多くなかったこともあり、さくさくすすめることができました。修正は 2-3日ぐらい、PR レビューを含めて 2週間程度の作業でした。
比較的小さな Rails アプリであり、きれいに書かれているアプリであったため、デフォルトの型検査モードであってもエラー 0件の状態で型の導入ができました。
untyped
型が多用されている状態ですので、強力な型検査サポートを受けられるようになったとは言えませんが、ここから型を増やしていくことで型検査をリッチにできるはずです。
一方で、ツールのサポート不足や gem の型定義不足により、手書きで型を補う必要もいくつかありました。まだ導入がかんたんであるとは言えない部分があるのは確かです。ただ、今回の導入をきっかけに問題をあぶり出し、改善するきっかけになったのは Ruby 界への貢献になったのではないかと思います。
次回の記事では、conference-app の型をさらに充実させつつ、不具合を直していく部分について紹介したいと思います。