Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

EightではRubyへの型導入を進めています

こんにちは。 名刺アプリ「Eight」でエンジニアをしている鳥山(@pvcresin)です。
最近は、社内の謎解きが好きな人たちとリアル脱出ゲームに参加しています。 10人ほど必要なイベントでもすぐに人数が集まるので、社内にコミュニティーがあるのはありがたいですね。
さて今回は、Eightで昨年末から取り組み始めたRubyへの型導入について紹介します。

目次

背景

EightのバックエンドはRuby on Railsで構築されています。 10年以上にわたって開発が続けられてきたこともあり、コードベースは非常に大きく、コードの挙動や開発者の意図を読み解くのに時間がかかる場面も増えてきました。 もちろん、定期的にリファクタリングや不要な機能の削除をしていますが、それ以外にもコードの可読性や品質を向上させる取り組みを進めていく必要がありました。
私は以前、EightのWebフロントエンドを段階的にTypeScriptへ移行した経験があります。 その際、型を導入したことでコードの可読性や品質が明確に向上していくのを実感できたため、Rubyでも同様の改善が期待できるのではないかと考えました。

静的型付けブーム

近年、静的型付けの言語が広く普及しており、動的型付け言語でも型宣言の導入の動きが進んでいます。 たとえば、JavaScriptに型を導入したTypeScriptは大成功を収め、PythonやPHPにも型宣言の構文が導入されています。
一方、Rubyは作者であるMatzさんの意向により、現時点では型の構文を導入していません。 これは、言語設計への影響やコミュニティーへの混乱を懸念したためです。
また、Matzさんは言語に型を導入しなくても、型推論ツールの発展によって同様の効果が得られる可能性について言及しています。 現在、その一環としてTypeProfが開発されていますが、大規模なRailsアプリケーションに導入できるようになるのはもう少し先になりそうです。
Rubyの方針自体は理解できるものの、現状のコードベースの改善を考えると、実用的なツールを使って早めに型の恩恵を取り入れたいところです。 そこで、Rubyに型と型チェッカーを導入することにしました。

型チェッカーと関連ツールの概要

Rubyの型チェッカーはいくつかありますが、業界内で採用実績が多いSteepSorbetを実際に導入して検証しました。 関連するツールに関しても導入事例が多いものを中心に選定し、最終的に次の2つのパターンで検証しました。

  • Steep関連ツール
    • Steep, RBS, gem_rbs_collection, RBS Rails, RBS::Inline
  • Sorbet関連ツール
    • Sorbet, RBI, Tapioca

まずは、これらのツールについて簡単に説明します。

Steep関連ツール

ツール 説明
Steep RBSという型定義を用いる型チェッカー。静的型チェックが可能。
RBS Ruby 3で公式に採用された型定義構文。Rubyに似た専用の構文を持つ。
gem_rbs_collection Gemに対するRBSファイル(型定義)を集めたリポジトリ。TypeScriptのDefinitely Typedに相当。
RBS Rails RailsのModelなどのRBSファイルを生成するツール。類似ツールにOrthoses::Railsrbs_activerecordがある。
RBS::Inline Rubyファイルのコメント内に書かれたRBS形式の型を読み取り、RBSファイルを生成するツール。

RBS::Inlineを用いたコメントでの型の記述例

class A
  #: (Integer x) -> String
  def bar(x)
    x.to_s
  end
end

生成されるRBSファイル

class A
  def bar: (Integer x) -> String
end

Sorbet関連ツール

ツール 説明
Sorbet RBIという型定義を用いる型チェッカー。StripeやShopifyなどで採用されている。静的型チェックのほかにランタイムでの型チェックも可能。
RBI Sorbet用の型定義構文。完全にRubyの構文であり、コードに埋め込み可能。
Tapioca RailsのModelやGemのRBIファイルを生成するツール。

Sorbetでの型の記述例

class A
  extend T::Sig

  sig { params(x: Integer).returns(String) }
  def bar(x)
    x.to_s
  end
end

SteepとSorbetの実導入による比較検証

リアルな使用感を知るため、Model数が1800を超える規模のEightのバックエンドに対して、SteepとSorbetをそれぞれ導入して検証しました。
導入のゴールは、問題なく静的型チェックが実行できることと、VS Code上で型情報やエラーが可視化されることとしました。
なお、元のロジックはできる限り変更しない方針としました。
使用したバージョンは検証時の最新版であるSteep 1.8.1 / Sorbet 0.5.11592です。

Steepの検証

導入手順

  1. gem_rbs_collection、RBS Rails、RBS::Inlineを使ってRBSファイルを用意
  2. steep validateで型定義の不備を確認し、対応
  3. steep checkで型チェックが正しく行える状態を作る
  4. VS Code拡張で型やエラーの可視化、修正時の体験を確認

所感

RBS::Inlineは、コメントを書いていないメソッドの型もuntypedとしてRBSファイルに出力してくれます。 これによって、rbs prototypeなどを使わなくても雛形が生成されて便利でした。 型定義ファイルの生成速度に関しては、いずれも一瞬でした。
また、Rubyとしては正しいコードでも、Steepが解析できない構文に当たると、実行時エラーが出てしまいました。 例えば、[{ a: 1 }, { b: 2 }].reduce(&:merge)というコードの&:mergeの部分は、解析中にエラーが出てしまうため、[{ a: 1 }, { b: 2 }].reduce { |acc, hash| acc.merge(hash) }に書き換える必要がありました。 このようなコードの書き換えが必要なパターンに何回か遭遇し、型チェックで正しいエラーのみが出力される状況を作るのに苦労しました。 Rubyの高度な表現力に、Steepがまだ対応しきれていない印象を受けました。
重要なSteepの実行速度については少し遅く、型エラーをチェックしない設定にしても、手元のPCで約90秒かかりました。 これは、workerの数を増減させても大きく変わることはありませんでした。 型を書いてからフィードバックされるまでに時間がかかるため、開発者体験としてはイマイチに感じました。

Sorbetの検証

導入手順

公式ドキュメントに豊富な情報があるため、その通り進めました。

  1. tapioca initを実行し、CLIの設定ファイルやRBIファイルを用意
  2. srb tcで型チェックが正しく行える状態を作る
  3. VS Code拡張で型やエラーの可視化、修正時の体験を確認

所感

Sorbetではファイル内の# typed: xxxxコメントで型チェックのレベルを指定できるのですが、何も書いていない場合は# typed: falseとみなされます。 これは型エラーをチェックせず、定数解決や型の構文などに関するエラーのみをチェックするものです。 こうしたエラーを解消するために、コードを一部修正する必要がありました。
例えば、定数(X)を内部に保持する親クラス(Parent)があり、それを継承した子クラス(Child)があるとき、Xを参照するにはChild::XではなくParent::Xと書く必要があります。 これはSorbetの思想として、定義場所を直接参照するほうが可読性や型チェックのパフォーマンスが良いとされているからです。
また、一部のエラーでは自動修正が利用できますが、精度には改善の余地があると感じました。
型定義ファイルの生成速度に関してはこちらも一瞬でしたが、より多くの型定義が生成されている印象でした。
肝心な型チェックの速度に関しては、約6秒とかなり高速でした。 # typed: trueを追記したファイルを増やしても、速度が遅くなることはありませんでした。 この速度であれば、型とコードを同時にメンテナンスできると感じました。

比較の結果

SteepとSorbetをそれぞれ導入して検証し、さまざまな観点で比較した結果、Sorbetを採用することにしました。
まず、最も大きな理由は型チェックの速度が速いことです。 Steepは約90秒かかるのに対し、Sorbetは約6秒と15倍の差があります。 現時点ではRubyの型推論がまだ成熟していないため、今後数年は型を書いていくことになるでしょう。 それを踏まえると、型を書いてすぐにフィードバックが得られるのは開発者体験が良く、型を書くモチベーションに直結すると考えました。 もちろんSteepの速度も改善されていくと思いますが、15倍の差はすぐには埋まらないのではないかと感じました。
次に、決め手になったのはツールとしての完成度の高さです。 今回の検証で、Steepには既にいくつかのサポートされていないRubyの構文が見つかっており、安定感の部分でSorbetに軍配が上がりました。 また、RBIファイルの生成はTapiocaがデファクトになっており、DSLから型を生成するDSL Compilerなどの仕組みも洗練されていると感じました。 ビルトインで30以上のCompilerが用意されており、自作することも可能です。 一方で、RBSファイルの生成は過渡期ゆえにツールが乱立しており、RBSファイルのマージなど考えることが少し増える印象でした。 このあたりは1-2年後にはデファクトのツールが決まっているかもしれません。
全体を通して、海外企業の大規模なアプリケーションで培った経験がSorbetには詰まっている印象で、現時点でEightに最適なのはSorbetだと判断しました。

採用にあたり気になるポイント

もちろん、Sorbetを選ぶ上での懸念もあります。 例えば、Ruby 3で公式に入ったのはRBIではなくRBSなので、RBSを中心としたツールが充実していくことは予想できます。
しかし、SorbetはRBSとの連携を宣言していて、最近ではメソッドや変数の型をRBS::Inlineのスタイルで書ける機能を追加しています。 このことから、RBSの発展はSorbetを採用したプロダクトにもメリットがあると考えています。
そして、もし仮にSteepに載せ替えなくてはならない日が来たとしても、それまでに書いたドキュメントとしての型と、品質の高いアプリケーションロジックは消えません。 それを踏まえると、過剰にツール選定に対して慎重になる必要はないと思いました。

まとめ

今回は、EightのRailsアプリケーションにSorbetを使って型を導入した話をしました。
実は、既に型チェックを行う範囲は拡大しており、約8割のコードが# typed: true(型チェックが有効な状態)になっています。 型によって、いくつもの潜在的なバグに気づくことができており、効果を実感しています。 Rubyの型周りはここ数年特に盛り上がっていてツールもどんどん進化してきているので、今後もウォッチしていこうと思います。

最後に、EightではRubyやReactが好きなエンジニアを募集中です。 興味をお持ちの方は、ぜひお気軽にカジュアル面談へ!

media.sansan-engineering.com

© Sansan, Inc.