Timee Product Team Blog

タイミー開発者ブログ

後編:YARD から rbs-inline に移行しました

タイミーでバックエンドのテックリードをしている新谷(@euglena1215)です。

この記事は先日公開した「前編:YARD から rbs-inline に移行しました」の後編となっています。前編では rbs-inline の紹介、移行の目的などをまとめています。前編を読んでいない方はぜひ読んでみてください。

tech.timee.co.jp

後編では実際の移行の流れや詰まったポイント、今後の展望について紹介します。

移行の流れ

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

  1. rbs prototype で untyped ながらも全体の型を生成
  2. rbs_rails:all で Rails によって生成されたメソッドの型を生成
  3. sord で YARD から型を生成
  4. rbs subtract で 1. で生成された型に対して重複した型定義を除外

After

  1. rbs-inline で全体の型を生成
  2. rbs_rails:all で Rails によって生成されたメソッドの型を生成
  3. sord で YARD から型を生成
  4. 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-rbs-inline-sample/tasks/yard_to_rbs_inline.rake at main · euglena1215/yard-rbs-inline-sample · GitHub

また、実行例として YARD アノテーションが多く記述されている yard gem に対して変換スクリプトを実行した結果も載せておきます。

github.com

コードを書かずにエディタの一括置換機能を使って移行したものもいくつか存在します。

  • 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.defineStruct.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 がサポートされました 🎉

github.com

Data, Struct が面倒なことをsoutaroさんに共有している図

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

  1. rbs-inline で全体の型を生成
  2. rbs_rails:all で Rails によって生成されたメソッドの型を生成
  3. sord で YARD から型を生成
  4. rbs subtract で 1. で生成された型に対して重複した型定義を除外

After

  1. rbs-inline で全体の型を生成
  2. rbs_rails:all で Rails によって生成されたメソッドの型を生成

今後の展望

やりたいと思っているものの、やりきれていないいくつかの点について共有します。

型検査を通す

今回の取り組みで rbs-inline を書いていく土壌は整いましたし、実際に書かれるようになってきましたが、型検査(steep check)を通すところまでは進められていません。これから始まる長い旅のスタート地点に立ったかなという気持ちです。

また、Rails プロジェクトに対して全てのディレクトリに対して型検査を通すようにすべきなのか、それとも特定のディレクトリだけで実施するのが妥当なのかの整理はできていません。これから検証含め進めていく必要があります。

リアルタイムな実装へのフィードバック

前編「RBS 活用推進の背景」で説明したように、実装のフィードバックサイクルを早めるためには rbs-inline のコメントを更新したらリアルタイムに RBS に反映され、その RBS を元にした型検査がエディタ上ですぐに走るのが理想だと考えています。

上記の型検査を通すだけでは理想の状況は実現できません。コーディング環境のセットアップを含む包括的な開発環境の提供を推進していく必要があります。

まとめ

RBS 導入の背景から YARD から rbs-inline への移行理由、移行方法、これからの展望について紹介しました。rbs-inline は experimental ではあるものの本番運用している会社がある事実があなたの会社 rbs-inline 導入への後押しになれば幸いです。

この辺りについてもっと話したい方はカジュアル面談でお話ししましょう!

product-recruit.timee.co.jp