Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

SorbetでRailsアプリの型シグネチャ作成とメンテを行ってみた(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

本記事アイキャッチ画像のSorbetアイコンは、Apache License 2.0に基づいてhttps://github.com/sorbet/sorbetより引用しました。

SorbetでRailsアプリの型シグネチャ作成とメンテを行ってみた(翻訳)

最小限の手間でRailsアプリに型チェックを追加する

Sorbetは、Stripe社が開発したRuby用の段階的型チェッカーです。リリース前の約1年間は冷やかされ気味でしたが、2019年初頭にTypeScriptに惚れ込んでいた私は、Sorbetのパブリックリリースも心待ちにしていました。そして2019年6月にめでたくリリースされ、数週間もすると私のRailsアプリvglistに取り入れられました。

connorshea/vglist - GitHub

私はvglistでSorbetを使い始めて以来、sordやparlourやsorbet-railsなどのSorbet関連プロジェクトにもコントリビュートし、皆にもすすめてきました。

AaronC81/sord - GitHub

AaronC81/parlour - GitHub

chanzuckerberg/sorbet-rails - GitHub

Sorbetについて耳にする主な不満は(構文を別にすれば)「Rubyは型付けされるべきではない、なぜなら型は制約だから」というものでした。ごもっとも!しかしSorbetの型付けは「段階的」であり、時間をかけて型付けを進められますし、何なら型付けをしなくてもよいのです。機能をとりあえず実装し、「その後」コードが確定してから型付けするのもありです。

このおかげで、後から他の人がコードを扱いやすくなります。これらのメリットを得るために、高速なイテレーションを手軽に作成できる新しいRubyコードの柔軟性を犠牲にする必要はありません。

あなたが型シグネチャ導入に全面的に反対であれば、私から申し上げられることはあまりありません。しかし私は、型があることで大規模かつ複雑なアプリケーションがずっと掌握しやすくなり、チームが今よりも自信を持ってコードをリリースできるようになると考えています。

Sorbetについて

Sorbetを使ったRubyのサンプルコードは次のような感じになります。

# typed: true
class Foo
  extend T::Sig

  sig { params(num: Integer).returns(Integer) }
  def self.double(num)
    num * 2
  end
end

Foo.double('bar') #=> Expected Integer but found String("bar") for argument num
T.reveal_type(Foo.double(10)) #=> Revealed type: Integer

Rubyに慣れ親しんでいる人にとって、このコードは奇妙に思えるかもしれません。通常のRubyと異なる点は、主にsigメソッドです。 sigブロックは、関連するメソッドのパラメータと戻り値を定義します。これを美しくないと言う人をたくさん見てきましたが、実を言うと私はとても気に入っています。これらは単なるバニラ(純粋な)Rubyコードであり、既存のツール(シンタックスハイライトやlinterなど)と非常によく調和します。

Sorbetでは、ソースコードとは別にRBIファイルに型シグネチャを書くこともできます。RBIファイルは、使うgemからシグネチャを自動生成したり、メタプログラミングで作成したメソッドに型付けしたりするときに有用です。

たとえば、上のコードのRBIファイルは以下のようになります。

# typed: true
class Foo
  sig { params(num: Integer).returns(Integer) }
  def self.double(num); end
end

Sorbetの仕様やSorbetでシグネチャを書く詳しい方法については、Sorbetのドキュメントがかなり充実しているので一読をおすすめします。

Sorbetを手っ取り早く試してみたいのであれば、sorbet.runサイトですぐにでも触れます。

Sorbetを採用するメリット

エディタとの統合

SorbetにはMicrosoftのLanguage Server Protocolを利用した言語サーバーが搭載されていて、テキストエディタと簡単に統合できます。Stripe社内では公式にSorbet VS Code拡張が使われていますが、残念ながらまだ実験状態であり、現時点では一般公開されていません。しかし入手は特に難しいことではなく、私は2019年半ば頃から使っています。VimやSublimeなどのエディタ向け非公式アダプタもありますが、公式のVS Code拡張以外は使ったことがありません。

VS CodeでTypeScriptをしばらく使っているうちに、Ruby用のまともなIntelliSenseサポートも欲しくなりました。Solargraphはよくできていますがまだ不完全で、多くのメソッドが取りこぼされます。Sorbetでは、型エラーの診断(エディタでの波線表示)に加えて、定義や参照へのジャンプや、マウスオーバーすることで型シグネチャやドキュメントなどの情報表示も使えます。

メソッドの所在を把握するのがとても楽になります(「む、このコードで使われているGame.create_genreはRailsが生成したメソッドか、これは自分たちのgem由来のメソッドなのか、あるいはGameクラスに追加されたメソッドかも」といった具合です)。これにより、特に大規模で複雑なコードベースで新人エンジニアの研修体験が改善され、全員が幸せになれます。

バグの発見

Sorbetの売りのひとつは、ファイルの型チェックを有効にして、既存の問題の発見をSorbetに任せられることです(nilableな変数のメソッド呼び出しによるNoMethodErrorの可能性など)。他所ではそのような成功例を見かけますが、私の場合はそもそもこのような状況になったことがありません。単に私のコードの作りが違うのかもしれませんし、テストカバレッジが90%ぐらいの小規模なコードベースではバグが見つかりにくいだけかもしれません。

自分にとって説得力のあるユースケースはバグが生まれたその瞬間にバグを見つけてくれることです。テストを実行したり、アプリケーションを開いてメソッド名のタイプミスに気づく代わりに、Sorbetがエディタ内で警告を発してくれます。Stripeはこれを「型チェッカーとペアプログラミングしているようなもの」と表現していますが、私も同感です。私にとってSorbetの最大のセールスポイントのひとつであることは間違いありません。

Sorbetの型シグネチャの取得と生成

Rubyのコードに実際に型付けする方法はいくつかあります。もちろん手書きも可能です(実際カスタムコードの大半はそうせざるを得ないでしょう)。かといって、使いたいgemひとつひとつについて型シグネチャを作成してメンテナンスするというのは現実的ではありません。Sorbetを実際のアプリケーションで効果的に活用するには「自動生成されたシグネチャ」と「コミュニティが管理するシグネチャ」の両方が不可欠です。

srb init

Sorbet gemを初めてインストールすると、最初にsrb initを実行するようプロンプトが表示されます。詳しくはSorbetのドキュメントに記載されていますが、基本的にはリポジトリ内の全コード(およびインストールされたgemのすべてのコード)を実行し、その情報に基づいて型付けされていないメソッドシグネチャを生成します。プロジェクト内にあるどのgemについてもRBIファイルが生成され、Sorbetが由来を特定できなかったメソッド(たいていはメタプロで生成されたメソッド)についてはhidden_definitions.rbiファイルを生成します。

これにより、Sorbetはアプリケーションで呼び出し可能なほぼすべてのメソッドを認識できるようになり、問題の検出に役立てられます(特定のクラスに存在しないメソッドなど)。

初期セットアップが完了した後は、srb initの代わりにsrb rbi updateを実行して型シグネチャを再生成できます。

sorbet-typedリポジトリ

sorbet/sorbet-typed - GitHub

DefinitelyTyped/DefinitelyTyped - GitHub

TypeScriptにはその名もDefinitelyTypedというプロジェクトがあります。DefinitelyTypedは膨大な種類の著名ライブラリ向け型シグネチャを蓄積するコミュニティリポジトリであり、TypeScriptユーザーならおそらくDefinitelyTypedの型シグネチャのお世話になっているでしょう。

Sorbetのsorbet-typedリポジトリは、本質的にDefinitelyTypedと同等です。コミュニティのメンバーは(Active SupportFakerなどの)著名ライブラリの型シグネチャについて貢献しており、srb initまたはsrb rbi sorbet-typedを実行すればSorbetが関連するシグネチャを取得します。著名なRubyライブラリの多くについて基本カバレッジが提供されており、アプリケーションで最初の型カバレッジを設定するのにとても有用です。

sorbet-rails gem

chanzuckerberg/sorbet-rails - GitHub

sorbet-railsはコミュニティが作成したgemで、Railsアプリのすべての動的コードに対してRBIファイルを生成します。すべての属性やリレーション向けの型付きメソッドを用いて、アプリ内のすべてのモデルの型シグネチャを生成します。また、user_pathなどのルーティング メソッド、ジョブ、メイラー、ヘルパーの型シグネチャも生成します。さらに、拡張性が高くわかりやすいプラグインシステムも備わっており、Railsと統合される著名なgem(pg_search、kaminari、friendly_idなど)のプラグインが組み込みで利用できるほか、独自のプラグインも作成可能です。

vglistではSorbetをこんなふうに使っている

私は元々Sorbetのsrb initsrb rbi update、sorbet-typed、sorbet-railsだけを使っていて、それについては問題なくやれました。しかしgemが更新されたり、新しいモデルが導入されたり、モデルに新しい属性が追加されたりするたびに、型シグネチャの再生成という退屈な作業を行わなければなりませんでした。

srb initrb rbi updateを使ったため、大量のコードで型シグネチャが生成されていました。前述したように、型シグネチャはコードベース全体のコードを一行ずつ実行することで生成されるので、それによってプロジェクト内のあらゆるgemのRBIファイルとともに「隠された定義」ファイルも生成されます。hidden_definitions.rbiはSorbetが生成する巨大なファイルで、由来が不明なメソッド (たいていはRailsのdirty trackingfoobar_changed?のような黒魔術で作成されたメソッド)や、Sorbet標準ライブラリの型定義でまだ認識されていないRuby言語のコアメソッド(新しいバージョンのRubyでは一般的)が含まれます。私のプロジェクトでは時間の経過とともにサイズが変化したものの、ほとんど常に25,000行〜40,000行の間でした。この巨大なファイルを「メンテナンス」するためにプルリクで多大なオーバーヘッドが生じました。

Sorbetで必要なRBIファイルをすべて再生成するのに5~10分はかかりました。しかもdatabase_cleaner gemを用いるシード生成コードにさしかかったときにローカルのdevelopmentデータベースが消えてしまったのです(シードはかなり高速だったので副作用としては許容範囲でしたが、それでも腹立たしいことです)。

それだけではありません。特定の問題(オプションの依存関係や無限ループが起きるなど)を持つgemで発生するエラーに過剰に反応することがあり、特定のgemのコードを無視する方法が見つかりませんでした。作業中この問題にさんざん悩まされたものです。よその大規模プロジェクトではsrb rbi updateの実行に1時間もかかったという話を耳にしました。これを長期間続けるのはつらすぎるので、この問題を解決するよりよい代替手段を探し続けていました。

正直に申し上げると、最初の段階ではSorbetを大規模なRailsアプリで責任を持っておすすめできるとまではいきませんでした。Sorbetはほぼ使い物になりますが、型シグネチャ再生成の負担が大きいため大規模なアプリでの採用には難があったのです。

Tapioca gemの導入

Shopify/tapioca - GitHub

TapiocaはShopifyの優秀なメンバーが開発したgemで、Sorbet組み込みのRBIファイル生成機能にある不便な部分の多くを置き換えるためのものです。トレードオフもそこそこあるものの、最終的にはほぼあらゆる面で優れていると思います。

大きな違いは、コードベースのコードを「実行せずに」型シグネチャを生成する点です。

この方法には、メタプロで作成されたメソッドが取りこぼされるという欠点があります。また、Sorbetの標準ライブラリの型定義で定義されていないRubyのコアメソッドも取りこぼされてしまいます(Sorbet標準ライブラリの型カバレッジは非常に優秀なので、これが問題になるのは新しいRubyバージョンで新しいメソッドを使う場合だけです)。

一方この方法には多くのメリットもあります。まず、無限ループの発生やデータベースの消滅といった問題を心配せずに済みます。また、型シグネチャの再生成も非常に高速です。Tapiocaによるgemの評価はgemごとに独立して行われるので、gemのバージョンが変わらなければ単に再生成をスキップします。

おかげで、私のアプリで5~10分かかっていた自動生成が、更新されたgemごとに約1秒で済むようになりました。私の理解では、型の自動生成に30~90分かかる大規模アプリでも、この方法でかなりの程度スケールできます。

Tapiocaは、さまざまなRails DSL向けのシグネチャ自動生成機能もサポートしていますが、今のところsorbet-railsで問題なく動作しているので現在は使っていません。

Sorbet組み込みの自動生成やTapiocaの動作についての詳しい比較については、TapiocaのGitHub Wikiページのトピックをご覧ください。

Railsアプリでの設定方法については、Tapioca READMEの利用法をご覧ください。

rake sorbet:update:all

以下は私がSorbetのすべてのシグネチャを再生成するのに使っているRakeタスクで、モデルの変更時に(アプリに積極的に取り組んでいないときなら数週間に一度)実行します。これにより、アプリケーションの型シグネチャのメンテナンスがとてもシンプルになり、私のMacBook Proでは約45秒で実行完了します。

# sorbet.rake
namespace :sorbet do
  namespace :update do
    desc "Update Sorbet and Sorbet Rails RBIs."
    task all: :environment do
      Bundler.with_unbundled_env do
        # コミュニティ作成の著名なgem(Fakerなど)を引っ張る
        #
        # 何らかの理由でsorbet-typedをforkしたい場合は
        # SRB_SORBET_TYPED_REPOにGit URLを設定し
        # SRB_SORBET_TYPED_REVISIONに"origin/master"などのブランチ参照を設定する
        system('bundle exec srb rbi sorbet-typed')
        # これらのgemのRBIファイルはインクルードしたくない(しても便利にならない)
        puts 'Removing unwanted gem definitions from sorbet-typed...'
        ['rspec-core', 'rake', 'rubocop'].each do |gem|
          FileUtils.remove_dir(Rails.root.join("sorbet/rbi/sorbet-typed/lib/#{gem}"))
        end
        # gem用のRBIファイルをTapiocaで生成
        system('bundle exec tapioca sync')
        # Sorbet Rails RBIを生成
        system('bundle exec rake rails_rbi:all')
        # Tapiocaが解釈できない定数についてはTODO RBIを生成
        system('bundle exec tapioca todo')
        # suggest-typedを実行してファイルの型レベルを必要に応じて上げ下げする
        # (自動生成されたRBIで型がより厳密になるとSorbetがいずれかのファイルで
        # `typed:`を`false`に設定する可能性がある)
        # 自動生成されたRBIで変更が生じてもコードが型チェックをパスするようにする
        system('bundle exec srb rbi suggest-typed')
      end
    end
  end
end

これで、Railsアプリやgemの依存関係で変更が生じたときの型シグネチャの同期がずいぶん楽になりました。

注意点および考察

Sorbetは未完成であり、まだ使えない型もあります。FactoryBotのファクトリーやRSpecのテスト、Rakeタスクのような多くのDSLを理解できません。将来はこれらのDSLもカスタムサポート可能になりますが、現状ではDSLを多用するコードはSorbetを採用するうえで問題になる可能性があります。

Sorbetを使っていてもうひとつ気になった点は、Rubyバージョンが新しくなったときです。通常、Sorbetによる最新のRubyバージョンのサポートは数か月遅れるため、Rubyに新しく導入された構文を使うとパーサーがコケてしまいます。

Sorbet導入の目的は、最初の段階で共通のメソッドにシグネチャを追加することでコードベースの型付けを早いうちに改善することです。あまり利用されないメソッドの更新は後回しになります。

メソッドの型付けが難しい場合は、メソッドが複雑すぎてリファクタリングが必要か、Sorbetが現時点でその型に未対応であることが考えられます。そのような場合は、無理にシグネチャでメソッドの利用法を制限したり極端に複雑なシグネチャを使ったりするよりも、メソッドを型付けなしのままにして先に進む方がよいでしょう。

Sorbetは本当に素晴らしく、採用してよかったと思っています。SorbetのおかげでRubyを書くのが一層楽しくなりましたし(楽しいのは前からですが)、今後も採用が広がることを願っています。本記事を書こうと思った理由は、Sorbetのデフォルト設定に悩んでいる人がたくさんいることを知り、長年改良を重ねてきた自分の設定を書き留めて他の人が学べるようにする価値があると思ったからです。

皆さんのRailsアプリでもSorbetをお試しください。Sorbetを開発・リリースしてくれたStripeおよびSorbetチーム、sorbet-railsを提供してくれたCZI、ParlourとSordを提供してくれたAaron Christiansen、Tapiocaを提供してくれたShopify、そしてSorbetを素晴らしいものにするために貢献してくれたSorbetコミュニティに感謝いたします。

関連記事

Ruby: 静的型付けで解決しない問題とは(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。