タイミーでバックエンドのテックリードをしている新谷(@euglena1215)です。
この記事は先日公開した「前編:YARD から rbs-inline に移行しました」の後編となっています。前編では rbs-inline の紹介、移行の目的などをまとめています。前編を読んでいない方はぜひ読んでみてください。
後編では実際の移行の流れや詰まったポイント、今後の展望について紹介します。
移行の流れ
YARD が日常的に書かれている状況から YARD がほとんど rbs-inline になり、YARD 関連のツールが削除されるまでの流れを紹介します。
1. 型をやっていくことを表明する
まずはバックエンド開発者に対してやっていく気持ちを表明しました。
YARD から rbs-inline への移行は自分1人で進めるよりは誰かに手伝ってもらったほうが自分ごとに感じられる方が増えると思い、表明と同時に手伝ってもらえる方を募集しました。
ここで、 @Juju_62q @dak2 の2名に立候補いただきました。ありがたい。
2. rbs-inline のセットアップを行う
移行を段階的に進めるためにも、まずは rbs-inlne コメントを書いたらきちんと反映されるような状況を作ります。
タイミーでの型生成は pocke さんが作った便利 Rake Task rbs:setup
をアレンジして使っています。そのため、 rbs:setup
を実行することで rbs-inline による型定義も生成されるように変更しました。
また、タイミーでは sord gem という YARD コメントから RBS を生成するツールも使っています。rbs-inline はアノテーションがないメソッドにも RBS を生成するため、ただ生成しただけでは RBS が重複してしまいエラーになってしまいます。且つ、rbs-inline コメントが少なく YARD コメントが多い現段階においては YARD コメントによる RBS は捨てずに互換性を維持する必要がありました。
これらは sord gem の --exclude-untyped
オプションを設定しつつ、 rbs subtract
コマンドによって YARD → rbs-inline の優先順位で重複を削除することで解決しました。
--exclude-untyped
オプションは名前の通り、YARD コメントがなく untyped にせざるを得ない RBS を生成結果から除外できます。ですが、 --exclude-untyped
オプションもユースケースに完全に一致するものではなく、YARD コメントがない定数や initialize メソッドが untyped として生成されるので rbs-inline での記述が反映されない形になっていました。
定数が untyped になる問題は最終的に sord gem を削除するまでは解決しませんでしたが、YARD コメントがない initialize メソッドも生成されてしまう問題は以下のように生成されたファイルの中身を書き換えることで生成結果から強引に除外する対応を行いました。
task sord: :environment do sord_path = 'sig/sord/generated.rbs' sh 'sord', '--exclude-untyped', '--rbs', sord_path Rails.root.join(sord_path).then do |f| content = f.read # sord は --exclude-untyped をつけていても initialize メソッドの型を出力するが、 # rbs-inline を優先したいので削除する。 content.gsub!(/def initialize:.*?(\n\s+.*?)*-> void/, '') f.write(content) end end
また、rbs-inline をセットアップした時点の rbs-inline 0.4.0 では ActiveAdmin 用のいくつかの実装にて rbs-inline コマンドがクラッシュする事象を観測していたので、rbs-inline の変換対象から除外していました。この辺りを soutaro さんにフィードバックしたところ、0.5.0 に入ったこの変更で修正してもらえました 🎉
結果として、タイミーの RBS 生成ステップは以下のように変化しました。
Before
rbs prototype
で untyped ながらも全体の型を生成rbs_rails:all
で Rails によって生成されたメソッドの型を生成sord
で YARD から型を生成rbs subtract
で 1. で生成された型に対して重複した型定義を除外
After
rbs-inline
で全体の型を生成rbs_rails:all
で Rails によって生成されたメソッドの型を生成sord
で YARD から型を生成rbs subtract
で 1. で生成された型に対して重複した型定義を除外
rbs-inline
が全体のRBSファイルを生成をしてくれるので、 rbs prototype
によって型定義を生成するステップを削除しました。そのため、実質的にrbs prototype
の上位互換として扱っています。rbs-inline に興味があってrbs prototype
コマンドを使っているプロジェクトがあれば、とりあえず rbs prototype
コマンドを rbs-inline
コマンドに置き換えてみても良いのではないでしょうか。
また、このタイミングで開発ルールにも変更を加えています。
「新規で型アノテーションするときは YARD よりも rbs-inline を使うことを推奨する」ルールを追加しました。この時点ではコードベース上に rbs-inline によるアノテーションはほとんどなく、サンプルコードが少ないので義務ではなく推奨という形に留めています。(学んでみてほしくはありつつも、書き方分からないので rbs-inline ではなく YARD を書くことは許容する形)
3. YARD から rbs-inline への移行を進める
2.でrbs-inline 書き始められる状況を作れたので、ようやく YARD から rbs-inline への移行を進めていきます。
1.で手伝ってくれると立候補してもらった2名と一緒に方針を以下のように決めました。
「機械的に変換できる部分は機械的に変換していくが、自動変換を頑張りすぎない。手動で手直しした方が早い部分は手動で書き換える。コスパの良い方法を適宜選んで置き換えを進めていく」
また、変換スクリプトはメソッドに対するコメントを完全に変換可能なもののみ変換するという方針を取りました。
例えば以下のような YARD コメントが書かれたメソッドがあったとして
# @param x [String] # @return [String] def foo(x) x end
YARD コメントの@return
タグのみ変換できるスクリプトがあったとすると以下のように変換されます。
# @param x [String] # @rbs return: String def foo(x) x end
この状態だと、YARD コメントから生成された RBS は (String) -> untyped
になり、rbs-inline コメントから生成された RBS は (untyped) -> String
になります。
今の rbs-inline コメントによる RBS の生成結果と YARD コメントによる RBS の生成結果だと YARD コメントが優先されるため、結果として (String) -> untyped
が最終的な型になります。
元々 YARD コメントだけで記述していた際は (String) -> String
と正しい型が定義できていたのに、YARD と rbs-inline が混在することで型情報が落ちてしまうことは意図したものではないため、完全に変換できるもののみ変換対象としました。
前述した方針より、YARD タグの全てをサポートしているわけではないためライブラリとしての公開は控えたいと思いますが、興味ある方向けにソースコードは公開します。興味があればご覧ください。
また、実行例として YARD アノテーションが多く記述されている yard gem に対して変換スクリプトを実行した結果も載せておきます。
コードを書かずにエディタの一括置換機能を使って移行したものもいくつか存在します。
- yard-sig の記法
@!sig
を@rbs
に置換 @example
に対応する rbs-inline はないのでNOTE:
に置換@see
に対応する rbs-inline はないのでrefs
に置換@raise
に対応する rbs-inline はないのでRaises
に置換@deprecated
に対応する rbs-inline はないのでDeprecated
に置換
数が少なく手動で手直しした例も挙げておきます。
- YARD のタプル(e.g.
Array(String, Integer)
)を使っている箇所を rbs-inline に修正 - YARD の
@option
タグを rbs-inline で Hash のリテラルに修正
# @param [Hash] h # @option h arg1 [String] # @option h arg2 [Integer] # @return [Integer] def foo(h) = h.size
↓
# @rbs h: { arg1: String, arg2: Integer } # @rbs return: Integer def foo(h) = h.size
これらの作業でコードベースからほとんどの YARD が rbs-inline のコメントに移行が完了しました 🎉
移行期間としてはサブタスクとして取り組んで1ヶ月半ほどでした。このタスクに集中すれば1~2週間で終わったのではないかと思います。
4. 後片付け
YARD コメントがほとんどなくなったので YARD 関連のツールを削除を始めとする後片付けを進めていきました。基本的にはスムーズに進んだのですが、進めていく中でハードルとなった点をいくつか紹介します。
sord gem の削除
YARD コメントから RBS を生成する sord gem を削除しようとしたところ、Data, Struct の型定義が見つからなくてエラーが発生しました。
sord が Data.define
、 Struct.new
に対応する型定義を生成していたのに対し、rbs-inline は生成しておらず、sord 削除のタイミングでその問題が健在化しました。
対応としては、以下のように @rbs!
を使って直接 RBS をコードベース上に手書きしました。
# @rbs! # class Foo # attr_reader bar: String # attr_reader baz: Integer # end # @rbs skip Foo = Data.define(:bar, :baz)
さすがにこれはちょっと辛いですという話を soutaro さんにしたところ、rbs-inline 0.6.0 で Data, Struct がサポートされました 🎉
Data, Struct がサポートされたことによって @rbs!
を使う必要がなくなり、以下のようにシュッと書けるようになりました。めっちゃ便利…!
Foo = Data.define( :bar, #: String :baz #: Integer )
rbs subtract
をやめる
sord gem が削除されたことでアプリケーションコードから RBS を自動生成する方法が rbs-rails と rbs-inline のみになりました。rbs-rails は Rails 側が自動的に生成するメソッドに対して RBS 生成を行うのが目的で、rbs-inline は開発者が実装したメソッドに対して RBS 生成するのが目的です。
それぞれ RBS の生成対象が異なることから重複を吸収する必要はないだろうと考え、 rbs subtract
をやめました。
rbs subtract
をやめてみたところ、以下のコードでエラーが出るようになりました。
class User < ApplicationRecord has_one :profile after_create :create_profile private # create_profile メソッドの型定義が重複しているエラーが発生 def create_profile ... end end
ActiveRecord はアソシエーションで関連付けたモデルに対して create_xxx
メソッドを動的に定義します。動的に定義されたメソッドとアプリケーションコードで定義したメソッドによる型定義が重複したことによるエラーでした。今回のケースでは意図的にメソッドの上書きをしていたわけではなかったため、本来は別名をつけることが望ましいパターンでした。
rbs subtract
をやめたことで、意図せずメソッドを上書きしていた場合はエラーによって別名で定義できるようになりますし、意図的にメソッドを上書きしていた場合は @rbs override
を記載することでその意図をコード上に残せるようになります。
必要なくなったから rbs subtract
をやめようくらいの気持ちで消していましたが、これは思いがけない発見でした。
さらに細かい話になりますが、上記の重複エラーの参照先が rbs-inline ではなく rbs-rails 側になっていることに気付きました。なんでだろうと思って調べてみると、rbs-inline は sig/rbs_inline/ に格納していて rbs-rails は sig/rbs_rails/ に格納していたのですが、RBS はアルファベット順でファイルを読み込んでいくため sig/rbs_inline/ → sig/rbs_rails/ という順番に RBS を読み込んでいたことに起因するものでした。
なので、rbs-inline は sig/rbs_inline ディレクトリではなく、sig/z_rbs_inline ディレクトリに格納するように変更し、必ず rbs-rails の後に読み込まれるようにしました。
これらの取り組みによって、最終的に RBS 生成のステップが以下のようにシンプルになりました。
Before
rbs-inline
で全体の型を生成rbs_rails:all
で Rails によって生成されたメソッドの型を生成sord
で YARD から型を生成rbs subtract
で 1. で生成された型に対して重複した型定義を除外
After
rbs-inline
で全体の型を生成rbs_rails:all
で Rails によって生成されたメソッドの型を生成
今後の展望
やりたいと思っているものの、やりきれていないいくつかの点について共有します。
型検査を通す
今回の取り組みで rbs-inline を書いていく土壌は整いましたし、実際に書かれるようになってきましたが、型検査(steep check)を通すところまでは進められていません。これから始まる長い旅のスタート地点に立ったかなという気持ちです。
また、Rails プロジェクトに対して全てのディレクトリに対して型検査を通すようにすべきなのか、それとも特定のディレクトリだけで実施するのが妥当なのかの整理はできていません。これから検証含め進めていく必要があります。
リアルタイムな実装へのフィードバック
前編「RBS 活用推進の背景」で説明したように、実装のフィードバックサイクルを早めるためには rbs-inline のコメントを更新したらリアルタイムに RBS に反映され、その RBS を元にした型検査がエディタ上ですぐに走るのが理想だと考えています。
上記の型検査を通すだけでは理想の状況は実現できません。コーディング環境のセットアップを含む包括的な開発環境の提供を推進していく必要があります。
まとめ
RBS 導入の背景から YARD から rbs-inline への移行理由、移行方法、これからの展望について紹介しました。rbs-inline は experimental ではあるものの本番運用している会社がある事実があなたの会社 rbs-inline 導入への後押しになれば幸いです。
この辺りについてもっと話したい方はカジュアル面談でお話ししましょう!