こんにちは。サーバーサイドエンジニアの山田です。
Rubyに型システムが入ってからしらばく経ちましたが、弊社のRailsプロジェクトの一部でもRBSを導入しました。 そのことについて昨年行われたKaigi on Rails 2022にて「RBSとSteepで始める型のあるRails開発とその運用」というタイトルで発表しました。 この記事では発表の内容についてあらためて整理した内容やその後の状況について紹介します。
記事の目的
Rubyの静的型解析をRailsに導入する話は、こうすれば動くといった内容が多く実際に運用しての苦労や、運用の工夫について書かれているブログなどはまだまだ少ないです。導入方法自体は価値のある情報ですが、導入を判断するにあたっては、型定義のメンテナンスコストやメンテナンス方法も気になるのではないでしょうか。
本記事では弊社の一部のRailsアプリケーションで実際にプロダクトで導入するにあたっての運用面の工夫やぶつかった課題などについて書いていきます。Railsのプロダクトに静的型解析を導入するか迷っている人にとって少しでも役立てれば幸いです。
RBSを導入した背景
RBSを使おうと思った背景ですが大きくは以下の3つです。
- 既存コードのメンテナンスコストを軽減したいため
- RBS自体の環境が整ってきたこと
- 個人的に興味があったこと
サービスの運用で改修を入れる際、久しぶりに読むコードや初めて読むコードもあります。オブジェクトのクラスや使えるメソッド、メソッドシグネチャなどの把握に時間がかかるとメンテナンスし辛くなります。 設計の工夫やテストコードによって辛さは軽減できますが、さらにできることはないかと考えていた時にRBSを使った静的型解析は1つの方法だと考えました。
以下のように型のフィードバックを受けながら開発できるイメージです。
また、RBS RailsやRBS Collectionなどの便利な周辺ツールが整ってきたこと、個人的にrubyの型が今後どうなっていくのか興味がありRBSをさわっていたことも理由です。
対象となるRailsアプリケーション
新規のRailsアプリケーションにRBSを導入していきました。 理由は、わたし自身が新規開発に関わるタイミングだったことが大きいです。加えて新規アプリケーションの場合は設計指針などを最初から考えて整備できるため導入のハードルが低かったこともあります。
実際にRailsでRBSを使う場合に、新規アプリケーションか既存アプリケーションかによって導入の仕方や運用方法は大きく異なります。以降は新規アプリケーションを対象にしていることを前提に読んでいただければと思います。
どうやって進めたか
RBSは導入する場合、学習コストに加えて型を書くという追加の作業コストが発生します。開発体験のメリットよりもこのコストが上回ってしまう状況が続かないように運用をうまく設計して導入していく必要があります。
そのために、以下を目的にまずはRBSの更新のタイミングと修正方法を分類して整理し、ドキュメント化するところから始めました。
- RBSを知らないメンバーの学習コストを下げること
- RBSが整理されて管理しやすい状態に保ちメンテナンスコストを下げること
RBSの分類
RBSと一言で言っても自分で書く場合と、Gemで準備されているものを使うなどいくつかに分類できます。それぞれどのように管理するかを以下のように考えていきました。
分類 | 更新のタイミング | 修正方法 |
---|---|---|
自作クラス・モジュールなどのRBS | アプリケーションコード修正時 | 自分で書く rbs prototypeコマンド |
GemのRBS (RBS Collectionにあり) |
Gemの追加、更新 | rbs collection update |
GemのRBS (RBS Collectionになし) |
Gemの追加、更新 | 自分で書く rbs prototypeコマンド |
RBS Railsから生成されるRBS | 更新対象となるコード修正時 | rails rbs_rails:all |
標準ライブラリのRBS | コード修正時 (チェックエラーとなる時) |
rbs_collection.ymlに追加 rbs collection update |
※ 他にもGemのリポジトリにRBSが書かれている場合もありますが、このパターンに遭遇しなかったため省略しています。
ディレクトリ構成
分類に合わせてディレクトリ構成は以下のように決めました。
. ├── .gem_rbs_collection: rbs collectionで自動生成するRBS(git管理対象外) ├── sig │ ├── app: ./app配下のRBSファイル │ ├── gems: rbs collectionで管理していないgemのRBSファイル │ ├── lib: ./lib配下のRBSファイル │ ├── standard_lib: RBSが準備されていない標準ライブラリのRBS、パッチを当てたい標準ライブラリのRBS │ └── rbs_rails: rbs_railsで自動生成するRBSファイル │ └── app
置き場所が決まったので、それぞれの更新をどのように行なっているか、やってみてどうだったか見ていきます。
自作クラス・モジュールなどのRBS
rbs prototype
コマンドを使って対象のRBSの雛形を作りながら手動で書いています。
慣れるまで少し時間はかかっていましたが、慣れてくるとシンプルな型ではRDocやYARDを書くような感覚で書くことができるようになってきました。
但し、継承が複雑に絡み合っていたり、動的にコードが定義されるような場合は書くことが難しく時間を使ってしまっています。
そのため、型を正確に定義するのが難しい場合は untyped
を使うことも問題なしという方針にしています。
GemのRBS(RBS Collectionにあり)
Gemの追加、バージョンアップ、RBS Collectionのリポジトリ側に修正が入る場合などに更新を行なっています。 更新といっても基本的には、RBS Collectionのコマンドを実行しているだけなので、更新の手間はそれほどかかっていないです。
更新を忘れてしまうということが何度か発生したためCIでのチェックを入れるようにしました。 CIでのチェックについては後ほど説明します。
GemのRBS(RBS Collectionになし)
更新に一番苦労しているパターンです。 GemのRBSの型がない場合に型を書かないという方法も考えられます。しかし、エディタ上で型エラーが出続けている状態で開発することはノイズになってしまうことと、Gemにおいても型のフィードバックを活用したいという理由でRBSを書く方向で進めました。
当初の運用ルール
以下のような流れで行っていました。
rbs prototype rb --out-dir=sig/gems/{gem_name} {gem_lib_path}
などのコマンドでgemのRBSファイルをまとめて作成- 作成したファイルを必要に応じて修正していく
例えばinteractorというgemがある場合には以下のようにgemのlib以下のファイルのRBSをまとめて生成し、そのファイルを修正していくイメージです。
実際にやってみると、自動生成したファイルにエラーが残ってしまい、その解消にかなり時間がかかってしまいました。また、gem更新時に修正箇所を特定するのが困難なためこの運用は止めました。
変更後の運用ルール
Gemのコードの隅々まで型が必要な場合は少なく、アプリケーションから呼ぶパブリックメソッドに関する型が定義されて入れば十分な場合が多いです。そのため、まずは使用するコードに関する型のみ手動で書いていくようにしました。
例えばinteractorというgemがある場合には以下のようにアプリケーションから使うファイルに絞るイメージです。
変更後はRBSを書く時間はかなり短縮できましたが、型のフィードバックが限定的になることや、Gem更新への追従が課題として残っています。
RBS Railsから生成されるRBS
テーブル定義、ルーティング、モデルのアソシエーション、enumなどのRBS Railsが生成するRBSに関するコードを修正した場合にRBS Railsのコマンドを実行して更新します。
例えばenumを追加する場合は以下のようなRBSが生成されます。
一人一人がRBS Railsの更新対象かどうかを把握して修正していくのは漏れが発生しやすいため、CIで検知できるようにしました。
標準ライブラリのRBS
基本的にはドキュメントに書かれているように rbs_collection.yaml
に使用するライブラリにを追加して管理します。
RBSが不足していたり、修正が必要な場合はRBSを書いて sig/standard_lib
配下に置いています。
CIの導入
実際にRBSを使い出すと、型の更新を忘れたり、不正な状態でmainブランチにマージされるということが発生していました。 レビューなどである程度防ぐことはできますが、それ自体もコストになります。 なるべく人の手を介さなくて良いようにCIで以下をチェックするようにしています。
- Steep checkが通ること
- RBSが最新であること
- RBS Railsの自動生成ファイル、およびRBS Collectionの依存関係(rbs_collection.lock.yml)が対象
Steep checkが通ること
CI上で steep check
コマンドを実行してチェックが全て通ることを検査しています。
実際に運用しているとSteep checkで発生する一部のerror, warningがどうしても解消できないということが発生しました。例えば、Gem側で用意されたRBSファイルに起因するエラーなどです。
一部のチェックをスキップする機能がないか探したとことろ、 --save-expectaions
と --with-expectations
オプション*1で実現可能とわかったため使用しています。
以下の流れで使っています。
steep check --save-expectaions
を実行してerror, warningとなる内容をsteep_expectations.ymlに記録するsteep check --with-expectations
を実行時してsteep_expectations.ymlに記録したerror,warningは無視してチェックを行う
注意点としては、コードのファイル内の行数まで記録されるため、該当するコードの行が変わった場合にymlの更新が必要になってくる点です。そのため極力使わないようにしていますが、解消に時間がかかりそうな場合には使用しています。
RBSが最新であること
CIではsteep check以外に以下のチェックを行っています。
- RBS Railsの自動生成ファイル
- CI上で
rails rbs_rails:all
コマンドを実行して現在のRBSとの差分をチェック
- CI上で
- RBS Collectionの依存関係(rbs_collection.lock.yml)
- CI上で
rbs collection install
コマンドを実行して現在のymlとの差分をチェック
- CI上で
なるべくライブラリ側のRBSの更新を意識しないですむように追加しました。
RBSの利用状況
現状はまだまだ小さなコードベースのRailsアプリケーションですが、RBSをどれぐらい書いているかイメージを掴んでもらうためにスタッツをのせます。(2023/1時点)
Rails stats
Code LOC | Test LOC | Code to Test Ratio |
---|---|---|
1718 | 6030 | 1 : 3.5 |
Steep stats
Typed calls | Untyped calls*2 | Typed |
---|---|---|
2133 | 699 | 75% |
./app、./lib配下のコードをRBSを書く対象としています。
RBSを導入してみて
数値化は困難ですが、想定していたほど工数はかかっていないためデメリットよりメリットが上回っていると感じています。実際にRBSによってバグを早期に発見することが何度かありました。
また、プロジェクトの振り返りでメンバーから以下のようなコメントもありました。
特にRBSを導入できたのは、バグを防ぐ仕組みが一つ増えた感じがして、とてもよかった
その後の状況
中心となってRBS導入を進めていたわたし自身は昨年の10月に別のチームへ異動となりRBSを使ったアプリケーションの開発から離れることとなりました。一方で5名のエンジニアがRBSを導入したアプリケーションの開発に加わりました。
その際にRBSをどうするかはチームにお任せしていましたが、体制変更後に数ヶ月経過した現在でもRBSを利用する運用が続き新たなメンバーもRBSを書いています。
運用周りの整備やドキュメント化に一定の効果があったと考えられます。
おわりに
RBSをRailsアプリケーションに導入するにあたって、どのように考えて進めたかや発生した課題などを紹介しました。
型を書かなくても動くRubyで型を書くことは、その効果が測りづらいこともあり導入に躊躇することは多いと思います。一方でRBSはアプリケーションのコードとは別ファイルに書くため、うまくいかない場合は簡単に使用を止めることができます。皆さんのプロジェクトでも一度試して見てはいかがでしょうか。
*1:https://github.com/soutaro/steep/pull/303
*2:untypedに対してメソッド呼び出しを行っているコードの数