型のあるRails開発を試してみる(2021/01/07)

7 min read読了の目安(約6800字

試してみたログです。

前提知識

rubyの型周りをキャッチアップできてない人は、この辺を読んでおくと良い。

https://techlife.cookpad.com/entry/2020/12/09/120454

https://pocke.hatenablog.com/entry/2020/12/18/230235

rbs,typeprof,steepを試してみる

参考サイトは以下(以下の通りにやっただけなので詳細は省略)

https://qiita.com/Anharu/items/d2ac8b0670e8eb5a2f8d

準備

$ ruby -v
# ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin19]
$ gem install steep

実際

以下の実装を元に説明する。

def plus(x, y)
  x + y
end

p plus(1, 2)

まず型情報であるrbsファイルを typeprof で生成する。

$ typeprof app.rb -o sig/app.rbs

rbsの中身は現状だと以下のようになる。

# Revealed types
#  app.rb:5 #=> Integer

# Classes
class Object
  private
  def plus: (Integer x, Integer y) -> Integer
end

次に、 steep init コマンドを使うとSteepfileが生成されるので、これを以下のように編集する。
Steepfileの記法を詳しく解説しているページが見つからなかったが、pockeさんの説明を以下の通りに書く。

https://pocke.hatenablog.com/entry/2020/12/25/183535
target :lib do
  # rbsファイルがあるディレクトリ の指定
  signature "sig"

  # 解析したいrubyコードのディレクトリ/ファイル
  check "app.rb"

  # (以下、今は不要だが一旦載せておく)
  # gem_rbs 内の gems ディレクトリへのパスを指定
  # repo_path "gem_rbs/gems"

  # rbs gem, gem_rbs からrequireしたいライブラリ名を指定
  # library 'pathname'
end

ここまでで、型検査ができるようになる。

$ steep check
# エラーがなければ、何も表示されない

plus に渡す引数を文字列などにすると、検査時にエラーとなる。

$ steep check
# app.rb:5:10: ArgumentTypeMismatch: receiver=::Object, expected=::Integer, actual=::String ("a")

vscodeでの補完を試してみる

vscodeのextensionsでsteepを検索してインストールすればOK。

https://github.com/soutaro/steep-vscode

ドキュメントの通りだが以下に注意。以下が適切に行われていないと何もチェックされない。

  • bundle exec steep langserver を実行して型チェックを行うため、このコマンドが実行できるようGemfileが必要
  • ルートフォルダにSteepfileが必要

インストールすると、エディタ上で補完・検査されるようになった!
Image from Gyazo

配列操作にも補完が効いてくれる。
Image from Gyazo

rbs_railsを試してみる

参考サイトは以下(以下の通りにやっただけなので詳細は省略)

https://pocke.hatenablog.com/entry/2020/12/25/183535

一応、手順通りに作ったレポジトリも公開しておく。

https://github.com/nullnull/rails_with_rbs_rails_sample/commits/main

rbsの生成

手順は参考サイトを参照。以下、詰まった点(2021年1月7日現在。rbs_railsのバージョンは0.8.0)

  • rails new rails_with_rbs_rails_sample --api した後に bin/rake rbs_rails:all すると各種のエラーが発生するので、不要なライブラリを一旦無視する

https://github.com/pocke/rbs_rails/issues/81
  • ブログの通り、 bundle exec rbs -rlogger -rpathname -rmutex_m -rdate --repo=gem_rbs/gems -ractivesupport -ractionpack -ractivejob -ractivemodel -ractionview -ractiverecord -rrailties -I sig validate しようとすると、以下エラー。
/Users/foo/.rbenv/versions/3.0.0/lib/ruby/gems/3.0.0/gems/rbs-1.0.0/stdlib/logger/0/log_device.rbs:3:4...3:24: Could not find mixin: MonitorMixin (RBS::NoMixinFoundError)

後続のSteepはそれなりに動くので、一旦無視する。

steepの実行

$ bundle exec steep check
app/mailers/application_mailer.rb:2:2: NoMethodError: type=singleton(::Object), method=default (default from: 'from@example.com')
app/mailers/application_mailer.rb:3:2: NoMethodError: type=singleton(::Object), method=layout (layout 'mailer')

railsディレクトリを型検査できた。意外とエラーが少なく素晴らしい :clap:
上記は、action_mailerで型定義が足りてないエラーか。

自分でモデルを適当に生成してみる。

$ bin/rails generate model post title:string body:text
$ bin/rails db:migrate

生成した段階だと、Postクラスに相当するrbsファイルがないので型検査は行われない。再度rbsを生成する。
ちなみに、rbs_railsは実際にrailsを実行してrbsファイルを生成するため、例えばrailsでMySQLを使っていればmysqlも起動しておく必要がある。

$ bin/rake rbs_rails:all

Postクラスに適当に実装する

class Post < ApplicationRecord
  def foo
    title.split(',')
  end
end
$ bundle exec steep check
# ...mailerのエラーは省略
app/models/post.rb:3:4: NoMethodError: type=(::String | nil), method=split (title.split(','))

titleがnilableなのでエラーになった!良い感じ。

vscodeのsteep extensionでこの型情報を反映させるには、都度restartが必要なようなので、vscodeのコマンドパレット(Shift+Cmd+P)より Steep: Restart all する。
ちなみに、 title&.split のように書くとエラーは防げるのだが、vscode上の補完だとstring型のメソッドが候補に上がってこないので使いづらそうだった。

コントローラーを実装してみても問題ないことを確認

class PostsController < ApplicationController
  def show
    post = Post.last
    {
      title: post.title # 型が補完されている
    }
  end
end

型定義を追加していく

ここまでは rbs_rails が自動で生成してくれる型定義しか使えず、例えば上記の Post#foo のような自作のメソッドは、自分で型を追加する必要がある。
まずブログで紹介されている通り、rbs prototype を使って生成してみる。

# サンプル実装
class Post < ApplicationRecord
  def foo
    [1].map { |x| x + 1 }
  end

  def bar
    title
  end
end
$ bundle exec rbs prototype rb app/models/**/*.rb > sig/models.rbs

出力結果は以下の通り。prototypeの名前の通り、自力で型定義をしていく上での原型を生成してくれる(=戻り値は単純なケースを除き基本的にuntypedとなる)。

# sig/models-prototype.rbs

class ApplicationRecord < ActiveRecord::Base
end

class Post < ApplicationRecord
  def foo: () -> untyped

  def bar: () -> untyped
end

typeprofでも同じくやってみると以下。fooについては型が付く。

$ typeprof app/models/**/*.rb -o sig/models.rbs
# sig/models-typeprof.rbs

# Classes
class ApplicationRecord
end

class Post < ApplicationRecord
  def foo: -> Array[Integer]
  def bar: -> untyped
end

Foo#barFoo#title を利用しているので、titleの型定義が書かれた sig/rbs_rails/app/models/post.rbs をtypeprofで参照できれば良さそうだが、この方法が分からなかった。。

(2021/01/10追記) typeprofのドキュメントに普通に書いてあった :bow:

https://github.com/ruby/typeprof/blob/master/doc/doc.ja.md

以下でいけそうだが、現状だとtypeprofのエラーになる。(また、もう少し良い感じにrbsファイルを指定できそう)

$ typeprof gem_rbs/gems/**/*.rbs sig/rbs_rails**/*.rbs app/models/post.rb -o sig/post.rbs
/Users/foo/.rbenv/versions/3.0.0/lib/ruby/gems/3.0.0/gems/typeprof-0.11.0/lib/typeprof/import.rb:262:in `each_class_decl': undefined method `decls' for nil:NilClass (NoMethodError)

issueを立てておいた。

https://github.com/ruby/typeprof/issues/27

一旦barを自力で定義すれば問題なく型チェックが行えるので、次に進む。

実際のRailsアプリに入れてみる

上記の手順を運用中のrailsアプリに実行してみる(手順は省略)。
以下エラーとなった。

$ bundle exec steep check
sig/rbs_rails/app/models/report.rbs:168:2...168:46	DuplicatedMethodDefinitionError: class_name=::Report, method_name=actual
sig/rbs_rails/app/models/report.rbs:178:4...178:43	DuplicatedMethodDefinitionError: class_name=::Report::ActiveRecord_Relation, method_name=actual

エラーの名前の通り、定義が重複しているらしい。コードをみてみると、enumとscopeで二重にメソッド定義しているコードがあったので修正。こういうのは型検査ありがたい(重複定義とか滅多にしないが)(とか言いつつしてしまっていた顔)
修正後再度型検査すると、大量にエラーがでる。ほぼNoMethodErrorなので、いったんtypeprofで一通り自作のメソッドを定義してみる。
と、再度同じようにrbsでいくつかエラーが出る。

  • enumをオーバーライドしていると、 DuplicatedMethodDefinitionError になる。そもそもオーバーライドするなという話ではありつつ、どうしてもその必要があったときに、この型の修正が面倒臭そう。
  • InvalidTypeApplicationError よく分からないが、EnumerableやRangeなどでエラーになっていた。この辺のオブジェクトを読み込めていないためか?一旦消して対応。

これを乗り越えても大量に NoMethodError がでるが、残りのエラーをざっくりみた限り、以下っぽい。

  • 依存gemのrbs不足(これがかなり多そう)
  • よくわからない理由のエラー(steepがダメなのか、こちらが何かしらダメなのか判別できなかった)

まとめ

ようやくrubyでも型安全な開発の未来が少し見えてきたかなという感じ。実用でいうとまだだと思うが、応援していきたい(rbs界隈の方々に本当に感謝!)。

いちユーザーとして、とりあえず目の前で必要そうと感じたのは以下。

  • (型定義を極力書きたくない前提で)rbs_railsでrbs生成->typeprofで適当な形でrbs生成->どうしても必要な型だけ書く->steepで自動で読み込んでくれてエディタに反映 というフローが綺麗に回るようにしたい
  • 依存gemのrbs
    • また、rds定義が不足している場合にサクッと追加できるようにrubiestのrbs力が高まらないと厳しそう