Kaigi on Rails の conference-app に型をつけてみた (前編)


2023年 11月 10日

はじまりは口約束

Kaigi on Rails 2023 の懇親会でふらふら歩いていたところ、たまたまスタッフのうなすけさんやふーがさんの会話に混ざることになりました。おふたりとは初対面だったのですが、何の流れか conference-app に型をつけてみよう、という話になりました。

ということで、今回は Kaigi on Rails の conference-app に型をつけてみた話を紹介します。

型をつける前に

型をつけ始める際に conference-app チームと会話をして、現状と目標を確認しました。

  • 現状
    • すでにモデルに対しての型を作っている
      • rbs_rails による自動抽出された型と手書きの型のハイブリッド
    • CI で型検査 (steep check) を実行している
    • 型検査のレベル (severity-level) は Steep のデフォルト設定を利用している
  • 目標
    • 型検査のレベルは下げない
    • 常に CI をパスした状態を維持する
    • 差分をなるべく小さくしつつ、型の導入をステップバイステップで進めたい

はじめて一緒に作業をすることになるので、こういったゴールの確認は大事ですね。

僕は普段 ゆるやかな型の導入 として、敢えて型検査を行わない方針で型を導入していることもあり、型検査を有効にしたまま Rails アプリに型を導入するのは初めての経験です。面白そうですね。

Steepfile の調整 (対象ファイルの指定方法の修正)

まず最初に確認したのは 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 がリリースされました)。

rbs prototype rb コマンドでプロトタイプを生成する

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_rails gem による型定義の生成
  • rbs subtract コマンドによる型定義とのマージ
    • 手書きの型と自動抽出分を突き合わせて、重複分を間引いています

ここまでの一連の修正はこちらの PR で修正を行いました。無事に CI による型検査が復活しました。

不足している gem の型を追加する

rbs prototype rb コマンドの導入と並行して、モデル内で利用している gem の型を追加しました。デフォルトの検査モードでは、依存 gem の型定義も検査に利用するため、型が提供されていない gem を利用している場合は、自身で型定義を用意する必要があります。徐々に gem_rbs_collection リポジトリに収録されている gem の数も増えてきてはいますが、2023年現在では自身で型を準備する心づもりが必要とされます。

型が提供されていない gem を見つけるには、対象のアプリを Steep で型検査を行い、UnknownConstant エラーや NoMethod エラーを見ていくと良いでしょう。これらのエラーを見て、どの gem の型を補う必要があるのか確認します。

調査の結果、conference-app のモデルでは以下の gem の型が不足していました。

  • web-push
  • octokit
  • marcel
  • open-uri
  • actiontext

あとは、これらの 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 では、ネストされたクラス定義自動的に展開してくれます。

ActiveRecord の enum 定義への対応

現在の rbs_rails gem は Rails7 形式の enum に対応していません。そのため、ActiveRecord モデル内の enum 宣言に対する scope などの型が生成されません。enum を利用している場合は、enum 関連のメソッドを手書きで補う必要があります

なお、rbs_rails に対しては修正を提案しています。この PR がマージされると自動的に型が抽出されるため、手書きは不要となります。

ActiveRecord の HABTM への対応

現在の rbs_rails gem は HABTM (has_and_belongs_to_many) に対応していません。enum と同様、ActiveRecord モデル内で HABTM 関連を宣言していても、生成されるメソッドの型定義が生成されません。こちらも、手書きで型を補う必要があります。

なお、こちらも rbs_rails に修正を提案しています

gem により自動生成されたメソッドへの対応 (kaminari, ActionText)

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 の型をさらに充実させつつ、不具合を直していく部分について紹介したいと思います。