pockestrap

Programmer's memo

RBS Railsを使ってRailsアプリケーションにSteepを導入する

RBS Railsを使ってRailsアプリケーションにSteepを導入するまでの解説します。

ただしこの「導入できる」というのは、解析が完走するという意味です。 型エラーを全てつぶすことや、Steepを実際の開発フローに乗っけることは私もまだできていないため、この記事ではサポートしません。

登場人物

RailsアプリケーションにSteepを導入するには、複数のプロジェクトが関連してきます。 まずはそれらをざっと紹介します。

  • steep gem
    • Rubyの静的型検査器です。
  • rbs_rails gem
    • RailsRBSを使う上で必要な型定義を提供・生成するgemです。
    • データベースのスキーマ定義からモデルの型生成、及びroutesの定義からパスヘルパーの型生成をします。
    • それ以外のコードの型生成は担当範囲外です。
  • rbs gem
    • ビルトインのライブラリ、及びスタンダードライブラリの型定義を提供します。
    • また、型を書いていく上で便利なツールをrbsコマンドとして提供します。
  • ruby/gem_rbs repository
    • gemの型を提供します。
    • activesupportなど、rails関連のgemの型はここから入手できます。

次の記事を読んでおくと、更に理解が深まるでしょう。

techlife.cookpad.com

手順

では、RailsアプリケーションにSteepを導入する手順を確認していきましょう。

1. 使うGemをGemfileに追加する

まずは使うGemをGemfileに追加します。 RailsアプリケーションにSteepを導入するには、steep, rbsそしてrbs_railsの1つのgemが必要です。 この記事では、それぞれsteep v0.39.0, rbs v1.0.0, rbs_rails v0.6.0 を使って検証しました。また、Rubyのバージョンは2.7.1を使っています(RBSやSteepはRuby 3でなくても問題なく動作します)。

# Gemfile

group :development do
  gem 'steep', '>= 0.39.0', require: false
  gem 'rbs', '>= 1', require: false
  gem 'rbs_rails', '>= 0.6.0', require: false
end

どのgemもRailsアプリケーションがrequireする必要はないため、require: falseを指定して構いません。

Gemfileを書いたらbundle installしましょう。

$ bundle install

2. RBS Railsが定義するRake Taskを用意する

次に、RBS Railsが定義するRake Taskを用意しましょう。

次のコードをlib/tasks/rbs.rakeに置いてください。

require 'rbs_rails/rake_task'

RbsRails::RakeTask.new

これで色々rake taskが定義されます。 とりあえずrbs_rails:all taskだけを使えば良いでしょう。

3. RBS Railsが定義するRake Taskを実行する

rake taskを定義したら、それを実行しましょう。 これによって次の3つの処理が行われます。

  • RBS Railsから、解析に必要なRBSファイルをコピーしてくる
    • (2021-01-01追記: RBS Rails v0.8.0では、この処理がなくなりました)
  • データベースのスキーマ定義から、対応するモデルのRBSファイルを生成する
  • routes定義から、パスヘルパーのRBSファイルを生成する

なお、この2番目と3番目の処理は実際にRailsのアプリケーションを読み込んだ上で実行されます。 そのため多少不安定なことが予想されます。うまくいかなかったらRBS RailsのGitHub Issue私のTwitterアカウントまで教えてください。

$ bin/rake rbs_rails:all

これによってsigディレクトリ下に型定義が生成されます。

RBS Railsからコピーされてきたファイルがsig/rbs_rails/sig下に1、モデルの型定義がsig/app/models/下に、パスヘルパーの定義がsig/path_helpers.rbsに出力されます。 (2020-12-28追記: RBS Rails v0.7.0では、この辺のパスが多少変わりました。)

なおRBS Railsが生成するモデルの型定義は、Railsが自動的に定義するメソッドのみを定義します。 つまり、カラムに対応するメソッドや、has_manyによって定義されるメソッドは型が出力されます。ですが、ユーザーがモデルのファイル内でdefを使って定義したメソッドの型は出力されません。 それらのメソッドは後述する方法で別途自動生成すると良いでしょう。

またパスヘルパーの型定義は、現状_RbsRailsPathHelpersインターフェイスを定義するだけです。 このインターフェイスはデフォルトではどこからも参照されていません。そのため、パスヘルパーの定義を使うには、ユーザーの手でApplicationControllerなどにinclude _RbsRailsPathHelpersなどを書く必要があります。 この辺は将来的になんとかしたいですが、まだ手がつけられていないのが現状です。2

4. gem_rbsをダウンロードする

次に、gem_rbsリポジトリの内容をダウンロードします。 どこにどのような方法でダウンロードしても良いのですが、この記事ではgit submoduleを使います。 他の場所にダウンロードしたい場合には、適宜パスを読み替えてください。

# Rails.root 下に、gem_rbsをgit submoduleとしてダウンロード
$ git submodule add https://github.com/ruby/gem_rbs.git gem_rbs
$ ls gem_rbs/
bin
Gemfile
Gemfile.lock
gems
LICENSE
README.md

5. RBSのvalidateをして、出たエラーを直す

では、ここまでの結果で手に入れたRBSrbs validateコマンドで検証してみましょう。 このコマンドによって、RBSが正しく書かれていることが検証できます。

次のようにrbs validateコマンドを実行します。--repo引数には、先程git submoduleでcloneしてきたディレクトリの中のgemsディレクトリを指定します。 -r引数には、必要なライブラリ、gemの名前を指定します。 -I引数には、その他に書いたRBSが入っているディレクトリを指定します。3

$ bundle exec rbs -rlogger -rpathname -rmutex_m -rdate --repo=gem_rbs/gems -ractivesupport -ractionpack -ractivejob -ractivemodel -ractionview -ractiverecord -rrailties -I sig validate

これを実行すると、おそらく次のようなエラーが表示されたのではないでしょうか。

(2020-12-28追記: RBS Rails v0.7.0ではこの辺のエラーが出なくなりました。)

RBS::NoSuperclassFoundError: sig/app/models/something.rbs:1:0...154:3: Could not find super class: ApplicationRecord

これは、Somethingモデルのスーパークラスに指定されているApplicationRecordの型定義がない、というエラーです。 RBS Railsはモデルの型定義を生成しますが、ApplicationRecordはデータベースのスキーマとしては存在しないため、RBS Railsの守備範囲外です。 そのためApplicationRecordの型の定義はユーザーが行う必要があります。

まずは、適当に空のApplicationRecordの型定義を書いてしまいましょう。 rbs validateを通すため(== steep checkを走らせるため)には、とりあえずクラスの定義のみがあれば充分です。 より良い解析を行うためにはApplicationRecordで定義されているメソッドの型を書く必要がありますが、ひとまずrbs validateを通すことを優先します。

次のコードをsig/main.rbsとして保存します(名前は何でもいいです)。

# sig/main.rbs

class ApplicationRecord < ActiveRecord::Base
end

これでもう一度rbs validateコマンドを実行すると、rbs validateが成功すると思います。 もしくは別のエラーが出るかもしれませんが、その場合も(この段階では)おそらく同様にクラス/モジュール定義を書き足せば動くと思います。

6. Steepのセットアップをする

rbs validateが無事通ると、最低限のRBSの準備ができました。 ですのでSteepの設定をして、Steepを実際に走らせてみましょう。

まず、次のコードをSteepfileとして置きます。4 (2021-01-01追記: RBS Rails v0.8.0ではSteepfileに書く必要があるライブラリが増えました。詳しくはRBS RailsのREADMEを見てください。) このコードでは、先程rbs validateを実行したときと同じ環境で動くよう、Steepも設定しています。

# Steepfile

target :app do
  # 生成・手書きしたRBSファイルがあるディレクトリを指定する。
  signature 'sig'

  # 解析したいRubyコードがあるディレクトリを指定する
  check 'app'

  # 先程ダウンロードした gem_rbs 内の gems ディレクトリへのパスを指定する
  repo_path "gem_rbs/gems"

  # rbs gem, gem_rbs からrequireしたいライブラリ名を指定する。
  library 'pathname'
  library 'logger'
  library 'mutex_m'
  library 'date'

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

これでappディレクトリ下を解析する設定ができました。

7. Steepを実行する

では、steep checkコマンドで実際に型チェックをしてみましょう。

$ bundle exec steep check

おそらく、次のような型エラーが大量に出たのではないでしょうか。(こうならなかったら何かがおかしいので、頑張ってください。)

app/models/user.rb:21:46: NoMethodError: type=::Something, method=do_something (do_something)

おめでとうございます。これで「大量の型エラーは出るけどとりあえずSteepで型検査できる」状態になりました。

型定義を実用的にしていく

ここまででSteepを実行することはできました。ですがまだ大量の型エラーが残っていますし、型定義もほんの少ししかありません。 そのため、ここからは型定義を更に実用的にしていく方法を解説します。5

型定義の改善には、型定義を自動生成するのが簡単でしょう。 型定義を自動生成する方法は次の記事を参考にしてください。

pocke.hatenablog.com

ここではrbs prototype rbを使った方法を紹介します。 rbs prototype runtimeでも、おそらく大きな困難はなく生成が行えると思います。6 TypeProfはうまく行くかわからない…。いい感じだったら教えてください。

基本的には型定義を生成した後、rbs validateで型定義を検証し、エラーが出たら直してまたrbs validateし、の繰り返しです。

rbs prototype rbを使ってRBSを生成する

RBSを生成していく際にはまず依存の少ないところから生成していくのが良いでしょう。 ここで言う依存はクラスの継承ツリーです。つまり、継承ツリーのよりルートに近いものから生成していきます。

たとえば、concernは依存が少なくて楽なのでは…と予想しています。そこで、モデルのconcernの型だけを生成するコマンドを次に示します。7

$ bundle exec rbs prototype rb app/models/concerns/**/*.rb > sig/concerns.rbs

このコマンドを実行すると、モデルのconcernの型定義をsig/concerns.rbsに吐き出します。

そうそう、先程手書きしたApplicationRecordも、rbs prototypeで生成してしまっても良いでしょう。


なお、依存関係を考えずにとりあえずアプリケーションのコード全てに対してRBSを生成してしまう手もあります。つまり、bundle exec rbs prototype rb app/**/*.rb lib/**/*.rbのように実行します。 この方法ならばアプリケーション内の型定義を一気に生成できます。ただし、次に解説するrbs validateを実行してエラーを直す作業にかかる時間が長くなるので、細かく分割したほうが楽かなあ、と予想しています。8

rbs validateする

型定義を生成したら、rbs validateコマンドで生成した型定義を検証しましょう。

# 再掲
$ bundle exec 1rbs -rlogger -rpathname -rmutex_m -rdate --repo=gem_rbs/gems -ractivesupport -ractionpack -ractivejob -ractivemodel -ractionview -ractiverecord -rrailties -I sig validate

おそらくApplicationRecordが足りなかったときと同様に、何かしらのエラーが出ると思います。 これらのエラーを全て修正することで、steep checkが動くようになります。

次によく見ることになりそうなエラーを2つと、その対処法を解説します。

モジュールやクラスが足りていない場合

モジュールやクラスが足りていなさそうなエラーが出た場合、ApplicationRecordと同様にそれを適当に足してください。 具体的には次のエラーが該当します。(ほかにもあるかも)

  • RBS::NoSuperclassFoundError
    • superclassの型がない
  • RBS::NoMixinFoundError
    • include/extendしているモジュールの型がない
  • RBS::NoTypeFoundError
    • class A::BのようにBクラスを定義しているときに、Aの型がない
    • エラーの名前的に他の状況でも出そうだけど、自動生成の文脈ではこのケースが多いかな

アプリケーション内で定義しているモジュールやクラスの型定義が足りなければ、先にそれをrbs prototypeで自動生成すると良いでしょう。

gemの型定義が足りていなければ、ApplicationRecordでやったようにとりあえず空の型定義を手書きしてしまうのが楽でしょう。9 もしくは型定義がgem_rbsに存在するgemの場合、Steepfilerbs validateでそのgemを指定することもできます。 ただし2020-12-25現在では、まだほとんどgemの型定義はありません(Rails系を除くとlisten, rainbow, redis, retryableの4つのみ)。 gemの型定義を書く気力があれば、トライしてみるのも良いと思います。 https://github.com/ruby/gem_rbs/tree/main/gems

RBS::DuplicatedMethodDefinitionError

RBSでは1つのクラスに同名のメソッドが2つ定義されていると、RBS::DuplicatedMethodDefinitionErrorクラスのエラーになります。

# test.rbs

class C
  def foo: () -> String
end

class C
  # 別の箇所でもう一度 foo を定義
  def foo: () -> Integer
end
$ rbs -I . validate --silent
/path/to/lib/rbs/definition_builder/method_builder.rb:30:in `block in validate!': test.rbs:4:2...4:24: ::C#foo has duplicated definitions in test.rbs:9:2...9:24 (RBS::DuplicatedMethodDefinitionError)

(snip)

そのため、どちらかのメソッド定義を削除するか、どちらかのメソッド定義に| ...を付け足す必要があります。

# test.rbs

class C
  def foo: () -> String
end

class C
  # これは () -> Integer | () -> Stringという意味になる。
  def foo: () -> Integer | ...
end

Steepで解析する範囲を狭くする

ここまでは型定義を充実させる方法について解説してきました。一方、型解析を実用的にしていくためには、ひとまずSteepで解析する範囲を狭くしてしまうのも1つの方法です。

先に挙げたSteepfileでは、appディレクトリ下全てを解析対象としていました。 これだと当然ながら、解析範囲がとても大きくなってしまって、エラーに全て目を通すことはむずかしいです。

そのため最初のうちは解析しやすいファイルを個別に見ていくのが良いと思います。

そのためには、Steepfilecheckに指定するパスを変更します。 たとえば私が関わっているRailsアプリケーションではapp/services/下にPOROのコードが集まっていました。 POROは変なことをしていないため解析がしやすいです。また、モデルを触ることが多いので、RBS Railsによって自動生成された型が動いていることも確認しやすいです。

次のようなSteepfileを書くとapp/servicesのみを解析対象にできます。

# Steepfile

target :app do
  signature 'sig'

  # check 'app'
  check 'app/services' # ← app/services のみをチェックする

  repo_path "gem_rbs/gems"

  # rbs gem, gem_rbs からrequireしたいライブラリ名を指定する。
  library 'pathname'
  library 'logger'
  library 'mutex_m'
  library 'date'

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

まとめ

RailsアプリケーションにSteepを導入する方法を紹介しました。 Ruby 3もリリースされたことですし、冬休みにはRailsアプリケーションにSteepを導入してみてはいかがでしょうか。


  1. このディレクトリ配置おかしい気がする、そのうち変えるかも

  2. パスヘルパーがRailsのどこで定義されているのか、私がまだちゃんと理解できていないので…

  3. 長いですね。そのうちもうちょっとマシにできるんじゃないかと思っています。

  4. steep initコマンドでもSteepfileを生成できます。

  5. といっても、冒頭でも書いたとおりSteepを実運用に乗せるにはまだ遠いかなと思っています。

  6. 私はあまり試せてないのでRBS Railsとの組み合わせで何かが悪かったら教えてください

  7. **を解釈するシェルが必要です

  8. 私はアプリケーション内の全ての型定義を一度に生成したあと、心を無にしてエラーを直し続けました。わりとつらい

  9. これ、結構大変なので自動化したいなあと考えています。