[Steep]Railsの本番環境にruby3.0の型定義を入れていく


やったこと

自分の持ち手の中に以下の三拍子が揃ったプロダクトがあったのでruby 3.0まであげて強い気持ちで型を入れていくことにしました。

  • 立ち上げ間もない(利用者も限定的)
  • 各種ライブラリが最新(変な古いバージョンに影響されることが少ない)
  • 今後そこそこの機能拡張が見込まれる

うちのチームでは初めてのRuby 3.0&型付だったけどやっていき

結論成果物

  • tool
    • RBS
    • Steep
  • API周りのモデル層に型定義を導入
  • circleciによる自動テストの追加

Rubyの型とは

Rubyの静的解析はv3.0で導入された新機能になります。
型定義はrubyのコードの外側にRBSファイルとして定義していきます。

2010年代は静的型言語の時代でした。Rubyは抽象解釈を武器に、型宣言なしで静的型チェックする未来を目指します。RBSとTypeProfはその第一歩です。Rubyがもたらす誰も見たことがない静的型の世界を見守ってください — Matz

引用: https://www.ruby-lang.org/ja/news/2020/12/25/ruby-3-0-0-released/

Rubyの型定義周りでいくつかのツールが出てきてこんがらがりやすいのでさっくりまとめます。
ここら辺は クックパッドさんの開発者ブログ が詳しく書かれていました。

  • RBS
    • Rubyの型定義を行うための言語。Ruby 3 にバンドルされています
  • TypeProf
    • Rubyのコードから型を解析してRBSファイルを出力するためのツール。Ruby 3 にバンドルされる。
  • Steep/Sorbet
    • Rubyの型チェックの実施やIDEで型の表示やリアルタイムで型の確認などをしてくれるツール。

さらにRailsや本番環境に型を導入するにはここら辺も必要になってきました。

  • gem_rbs_collection
    • 各種gemのrbsファイルをよしなに集約してくれるgem
  • rbs_rails
    • Railsの各種機能のrbsを提供してくれたり、ActiveRecordやurl_helper周りの定義ファイルの作成タスクを提供してくれている

rspecで書いたテストからTypeProf通して自動生成とかしてくれないかなぁ。。。という希望(見つからなかった)

導入していく

必要なライブラリの導入

一旦各種ライブラリを導入

# Gemfile
group :development, :test do
  gem 'rbs_rails', require: false
  gem 'steep', require: false
  gem 'rbs', require: false
end

gem_rbs_collection導入

どこかにおいてプロダクト間で同じものを利用してもいいのですが

  • 各々のPCでgem_rbs_collectionを置く場所を強制する
  • 別プロダクトで参照するgem_rbs_collectionの場所を強制する

のようなお気持ちがなかったのでサブモジュールとしてプロダクト配下に追加

$ git submodule add https://github.com/ruby/gem_rbs_collection.git gem_rbs/gems

Steepfileの用意

Steepfileを一旦作成(余計なものも入っているかもしれないですが一旦導入することを優先)

target :app do
  signature 'sig' # => 型定義ファイルをおくディレクトリ

  check 'app' # => チェック対象ディレクトリ(最終的にはmodelsの特定のディレクトリ(API関連のロジック)だけに絞りました...)

  repo_path "gem_rbs/gems" # => submoduleで追加したディレクトリ

  library 'pathname'
  library 'logger'
  library 'mutex_m'
  library 'date'
  library 'monitor'
  library 'singleton'
  library 'tsort'

  library 'activesupport'
  library 'actionpack'
  library 'activejob'
  library 'activemodel'
  library 'actionview'
  library 'activerecord'
  library 'railties'
end

rbs_railsのセットアップ

こちらの通りに進めていく
https://github.com/pocke/rbs_rails#installation

# lib/tasks/rbs.rake
require 'rbs_rails/rake_task'

RbsRails::RakeTask.new

タスク実行!

$ bundle exec rails rbs_rails:all

ActiveRecordやら各種url_helper周りの型定義ファイルが出てきた。。。。つよぃ。。。

circleciによる自動テスト

実際にrspecでテストしているところに入れていくので細かいところははしょります

git submoduleで追加したgem_rbs_collectionを更新する

commands:
  ...
  install_submodule:
    description: install submodule
    steps:
      - run:
          name: git submodule init
          command: git submodule init
      - run:
          name: git submodule update
          command: git submodule update

job用意

jobs:
  steep:
    executor:
      name: default
    steps:
      - checkout
      - setup_something # bundle install etc
      - install_submodule
      - run:
          name: run steep
          command: bundle exec steep check

workflowに追加

workflows:
  version: 2
  build-and-deploy:
    jobs:
      - steep
      - rspec # (既存のもの)

(実際には真っ赤になりますが無事に型検証が通った記念)

実際のプロダクトに入れていく

実際のRBSの書き方に関しては pockestrap - RBS基礎文法最速マスターgithubのドキュメント が参考になります。
またローカルで小さなrubyファイルを作成してTypeProfを実行してどんなRBSが出力されるかをみると参考になります。

まずスコープを絞った

出てきたエラー件数を見て一旦対象を外部露出しているAPIのロジック部分に絞りました。

target :app do
  ...

  check 'app/models/api'

  ...
end

ARを拡張している層の型定義はgeneratorに寄せて自動生成

このプロダクトではARをラッピングした層を用意しており、基本的なメソッドはARにdelegateして特定のメソッドを拡張できるようにしていました。
この層ではmethod_missingをフックに自前定義していないものはARにdelegateする機構を組んでいて、ARで提供しているメソッドも提供しています。
こちらに対してissueがありましたがどうにも上手いやり方はない様子...
https://github.com/ruby/rbs/issues/422

最初はよく使われているものだけ共通層に定義すればいいかなと思ったのですが、個別モデルの事情(特にRelation周り)によったものを都度定義するのはだいぶしんどかったので最終的にgeneratorを自分で作ることにしました。
幸い rbs_rails でActiveRecordに対する型定義を自動生成していたので多分に参考にさせてもらいながらシンプルを保てる範囲で自動型定義ファイルの出力をするようにしました。

一つのclassに対して複数のrbsファイルで分けても大丈夫と言うことなのでrbs_railsと同様自前のディレクトリを用意してそこにrbsファイルを出力するようにしました。

これによって実際の自前定義したビジネスロジック部分だけを型付けしていけばよくなったのでだいぶやる気が上がりました

Steepのバグ?に当たってciが通らない

継承元のメソッドに対してsuper(**args, &block)と渡した時にblock optionalで宣言した型定義が通らない問題に直面し
悩んだ挙句ruby-jpにお尋ねした。
https://ruby-jp.slack.com/archives/CM3PA3DAB/p1618535504180600

結果Steepのバグの可能性が出てきた。

そこでどうしてもクリアできないところへの対処を教えてもらいました。

__skip__ = begin
  dosomething # この中ではSteepによる検査がスキップされる
end

結果

キタ――(゚∀゚)――!!

参考

ruby3.0 release note
クックパッド開発者ブログ - Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート
pockestrap - RBS Railsを使ってRailsアプリケーションにSteepを導入する