Rubyの型チェッカーのSorbetを導入しました

申告チームでテックリードをやらせてもらっている id:nanjakkun です。

freeeではfreee会計をはじめ多くのプロダクトがRuby on Rails(以下Rails)のアプリケーションとして実装されています。

日々の開発の中で、Rubyでも静的な型の解決ができればなあと思うことがあります。

ということで、Rubyの型チェッカーのSorbetをfreee申告に導入してみました。

Sorbet(ソルベ)とは

sorbet.org

決済代行サービスのStripeを運営しているStripe社が公開している漸進的型チェッカーです。

※余談ですがfreeeアプリストアではStripeを決済手段としたアプリの有料販売ができます。

developers.freee.co.jp

有料アプリ販売の準備をする | freee Developers Community

漸進的型付けとは

漸進的型付け (Gradual Typing) とは動的型付けの言語に後から型宣言を追加することです。

今回取り上げるRubyの他には、Python, TypeScript, Flow などで漸進的型付けが可能です。

Sorbetでできること/できないこと

Union Types

Union TypesはT.anyでできる。

T.any(Integer,String)
nilable

nilの可能性のある型。 他の言語だとMaybeとかOptionalとか呼ばれているものですね。

T.nilable(Integer)

みたいに表現する。

ジェネリクス

ジェネリクスはHashやArrayなどの組み込みクラスに使えます。 https://sorbet.org/docs/stdlib-generics

自作クラスにもジェネリクスが使えるらしいんですが現時点では公式ドキュメントには書いていませんでした。

ソースを見ると自作クラスにもジェネリクスを使えるようです。

型境界の指定や、共変、反変の指定もできるようです。

変位指定

ドキュメントは見つけられなかったんですが、試した限りだと Sorbetのメソッドは引数が反変、戻り値が共変だと思います。 Flowなんかと同様。

ちなみにTypeScriptは関数の引数が双変、戻り値が共変。 で、ちょっと型が健全でなくて実行時エラーになるケースがあります。

Flowは関数の引数が反変、戻り値が共変。

参考: Scalaの変位指定

変位指定 | Scala Documentation

Structual Typing

Structual Typing(日本語だと構造的型付け?)を型安全に行うことはできません。 要はダックタイピングを型安全に行うことはできない。

Nominal Typingだけです。

FAQにも書いてあります。

Frequently Asked Questions · Sorbet

No. You can use an interface instead, or T.untyped if you do not control all of the code.

やりたければinterfaceという機能を使うか、T.untypedにして型安全を捨てるかしろと書いてあります。

運用状況

本番環境で運用するまでの流れ

本番環境で運用を開始するまでに以下のような流れを取りました。

  • 新機能のfeature ブランチにSorbetを導入する
  • Rubyのコードではほぼ新機能のModule以下にだけSorbetの型注釈を付ける
  • 社内の試験環境にデプロイして、数週間、新機能のQAをしたり、社内のフィードバックを受けたりする
    • 新機能は一気に試験環境にデプロイするのではなく、出来た分からデプロイしてスクラムのスプリントレビューなどでお披露目して社内で触ってもらう機会を増やす
    • このときSorbetの機能のためだけのQAは行っていない
  • feature ブランチをmasterブランチにmergeして本番環境にデプロイ
  • 本番環境で動く!

このような過程を取った理由としては以下の点が挙げられます

  • 長めに本番環境に近い状態で動作させて問題発生が起きないか様子を見たい
  • 解決できない問題が発生したときに切り戻ししやすい
  • 本番環境で既存のコードに影響を与えて不具合が起こる可能性を減らしたい
  • 新機能はすぐにトラフィックが沢山来るわけではないので不具合があった場合の被害を減らせる

本番環境で動かしてみてどうか

今の所クラッシュしたり不可解な実行時エラーが起きるのは見たことはありません。

試験環境でエラーは見たことはあって、nilableでないはずの箇所にnilがやってきたもので、

それもそもそもnilが来ないつもりで、UIの制御が足りなくて来ないはずのところに処理が来た、ってところで実行時エラーになっても問題のないケースです。

開発時のこと

良かったこと

型チェックが静的/動的に走る

当たり前なんですが、メソッド呼び出し時の型の不一致を静的/動的にチェックしてくれる。

それと意外にtypoとか、未定義変数とかスコープおかしいとかもかなり検知してくれるのが地味に嬉しい。

ドキュメントとしての意味 / YARDとの関係

Sorbetでメソッドに型注釈を付けると他人がコードを見たときに、 「どんな型の引数を受け取ってどんな型の戻り値を返すか」 というのが分かるのは副次的な効果としてあると思います。

Sorbetの型注釈をつけてさらにYARDを書くのは流石にメソッド本体じゃない部分が多すぎる上に冗長なのでやらなくて良いんじゃないかと思います。

ActiveRecordとSorbet

github.com

Modelの型付けにはsorbet-railsというgemを使っています。

これを使うとModelファイルからrbiファイルを作ってくれます。

ただし、全てのModelにこのgemの機能を適用せずに、一部だけ適用した場合、

rbiファイルがあるものからないものへの参照がある部分で型エラーになることがあって、

そこは手動でコメントアウトする必要があります。

あと、sorbet-railsにはControllerのparamsに型を付ける機能もあるんですが、これはまだ使っていません。

https://github.com/chanzuckerberg/sorbet-rails から引用

class MyCoolController < ApplicationController
  class MyActionParams < T::Struct
    const :id, T.nilable(Integer)
    const :show, T.nilable(T::Boolean)
    const :wands, T::Array[Integer]
  end
  sig { void }
  def my_action
    typed_params = TypedParams[MyActionParams].new.extract!(params)
    # T.reveal_type(typed_params) => MyActionParams
    # T.reveal_type(typed_params.show) => T.nilable(T::Boolean)
  end
end

他サービスのAPIを叩く部分はどう書くべきか?

社内外の他サービスのAPIからのレスポンスをjsonで取得するとしてそれをJSON.parseしたものを そのまま扱おうとすると型がHashになります。

Hashだと、このkeyに対するvalueはこの型で・・みたいなことができず、 valueは全て同じ型になってしまいます。

とすると型システムの旨味が少なくなってしまいます。

なのでPORO(Plain Old Ruby Object)かOpenStructにマッピングして それぞれのvalueについて型を指定できる方が良いんじゃないかと思っていて、 実際には今の所POROにマッピングしています。

運用コスト

通常時の型付け

rbiに手を加えずにRubyファイルに型をつけるだけならあまりみんな困ってないんじゃないかなと思います。

ファイルごとtyped: falseにしてチェックの対象から外したり型をT.untypedにして問題を先送りにすることもできますし。 漸進的型付けの強みですね。

FlowとかTypeScriptも殆どの部分は割と雰囲気で書けるでしょう?

ライブラリのrbiファイルのメンテ

gemのinstall/updateの際はsorbet/rbi以下のファイルのメンテが必要になります。

sorbet-typedに定義が書かれているgemは良いのですが、そうでない場合は自分でrbiファイルを作る必要があります。

特に社内gemだと一番最初にSorbetを導入している都合上、ほぼ必ずrbiを作らないといけません。

あと、将来的にはRails updateのときなんかにも必要になると予想されます。

困ったこと / 不便だと感じること

初期化でコケる!
srb init

を実行してしばらくすると謎のエラーが出て止まりました(ちょっと前の話で、ログを残してなくてすいません)。

なので途中で落ちたところから自力でSorbetが動くところまで持っていきました。

https://github.com/sorbet/sorbet-typed

まずgemに対するrbiファイルをsorbet-typedから/sorbet/rbi/sorbet-typedから必要な分をコピーして持ってきます。

その状態で

bundle exec srb tc

と実行すると、Unable to resolve constantと言うエラーがまだ数千件出ます。

外部ライブラリに存在するClass, Moduleへの参照がある部分でClass, Moduleのrbiがないとエラーになっています。

Rails consoleから

ObjectSpace.each_object(Class).map{|obj| obj.name}.sort.select{|name| puts name }

として定数を洗い出して空のClass(or Module)定義をsorbet/rbi以下に置いて行けばエラーは出なくなります。 (とはいえgem単位でrbiファイルを分けるのがめんどかった・・)

この時点ではメソッドの定義までは行う必要はなくて、ClassとModuleだけrbiに定義されていれば大丈夫です。

メソッド本体と型注釈で記述が冗長

メソッド本体と型注釈部分で2回同じ引数名を書かないといけないのはちょっと面倒だと思うときがあります。

例えば↓のようなのを

sig do
  params(
    arg1: Integer,
    arg2: String
  ).void
end
def some_method(arg1:, arg2:)

end

以下のように書きたい。

def some_method(arg1: Integer, arg2: String)

end

けどこれはRubyの文法として正しくないですね。 あくまでもSorbetはalt RubyではなくRubyのライブラリとして動作するのでRubyの文法から外れられないのです。

JavaScriptに対してのaltjsであるFlowとかTypeScriptみたいにはいかないですね。

とはいえ、YARD書くときだって冗長でしょう?

今後やりたいこと

  • 既存のコードに型注釈を付けていく
  • 社内sorbet-typedみたいなリポジトリを作って、社内gemのrbiの他のprojectへの導入を楽にする(今はコピペ・・)
  • サーバーサイドの型とフロントエンドの型の共有

参考資料

Stripe Blog: Online Payment Solutions Blog

Ruby3で導入される静的型チェッカーのしくみ まつもとゆきひろ氏がRubyKaigi 2019で語ったこと - Part1 - ログミーTech

What is Gradual Typing | Jeremy Siek

他社様事例

Sorbetで「型のあるRuby」の開発体験を試そう

Ruby の型チェッカーの比較 | Wantedly Engineer Blog