zackey推し

IT系のこと書くぞい

【Ruby】型チェックをしてくれるGem「 Sorbet 」を試してみた(Railsへ導入〜動作確認まで)

f:id:kic-yuuki:20190714143720p:plain

個人的に今年から毎週読ませていただいている週間Railsウォッチにて、「Sorbet」というGemが紹介されました。

※アイコンかわゆい。

よければプロダクトにも導入してみたいな〜と思い、軽い気持ちで触ってみました。

この記事なに? 🙄

この記事では、Gem「Sorbet」をRuby on Rails環境に導入、軽く動作確認した際の手順と自分なりに咀嚼した内容を記載します。

もしよろしければ、一読・参考にしていただければと思います。 🙇‍♂️

Sorbetとはなんぞや? 🤔

Sorbetとは、Rubyの型チェックを行ってくれるGemのことです。

ざっくり解釈した内容は以下な感じです。

  • 型チェックとは何をしてくれるのか
    • 未定義のクラス名を記述したらエラー検出してくれる
    • メソッドの先頭に型定義(引数や戻り値の型を記述)することで、メソッド呼び出し側の型に誤りがないかチェックしてくれる
  • 使用イメージ
    • rubocopのような感じ
      • srb tc or bundle exec srb tcでチェックしてくれる
    • sorbet-runtimeによって、動作時の型チェックも行ってくれる
      • rails s → 型チェックに引っかかったら例外送出みたいな感じ
        • 静的型付き言語(C#Javaなど)で、サーバー側で異なる型を扱った際のエラーと同じように扱える

もっと正確に概要を知りたい方は、週間Railsウォッチでも紹介されていた、RubyKaigi2019のスライドを見てみると良いかなと思います。

※全編英語ですが、英語力が低い自分でもさっくり内容を理解することができましたので、一読推奨です。 👍

本編(導入〜動作確認)

実際に以下PR(ブランチ)で試したことを書いていきます。

環境

以下の環境を使用しました。

  • ruby (2.6.3)
  • rails (5.2.2)
  • sorbet (0.4.4429)

導入

公式のGET STARTEDから進むといい感じです。

sorbetの初期化

以下をGemfileに追記します。

gem 'sorbet', :group => :development
gem 'sorbet-runtime'

その後、以下で「sorbet」を導入&初期化します。

bundle install

# bundle install終了後...
bundle exec srb init

すると、以下のような初期設定が行われます。

  • sorbet/ディレクトリが生成、配下にsorbetの設定情報が出力される
  • 各rbファイルの先頭へ typed: 〜 が挿入される

詳しい内容は、こちらのコミットログ - srbの初期化を参照ください。

typed: 〜については後ほどちょろっと記載します。

sorbet配下に生成されるrbiファイルについて

Gemfileに記述、requireしたGemに対して、型チェックに必要な情報を解析→自動出力してくれているようです。

rbiファイルを更新するには、bundle exec srb rbi gemsでいける感じのようです。

型チェックの処理を記述

型チェックは以下の実装を行う必要があるっぽいです。

  • 該当するclass / moduleT::Sigを継承させる
  • typed: 〜true / strict / strong のいずれかに設定
  • signatureをメソッドの先頭に定義
    • メソッドやパラメータの型定義方法はこちら参照

実際に実装した内容は以下です。

# app/controllers/application_controller.rb
# typed: strong
class ApplicationController < ActionController::Base
  extend T::Sig

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

# app/controllers/pages_controller.rb
# typed: true
class PagesController < ApplicationController
  extend T::Sig

  # typed: strong だとsignatureを定義しないとエラーとなる
  def about
    # コメント化されている部分はSorbetによる型チェックエラーとなる
    @value = ''
    @value += sorbet_test_to_s CONST_INT
    @value += sorbet_test_to_s CONST_FLOAT
    # @value += sorbet_test_to_s CONST_STRING
    @value += sorbet_test_to_s_app CONST_INT
    # @value += sorbet_test_to_s_app CONST_FLOAT
    # @value += sorbet_test_to_s_app CONST_STRING
  end

  private

  CONST_INT = 123
  CONST_FLOAT = 45.6
  CONST_STRING = '7-8-9'

  sig {params(x: T.any(Integer, Float)).returns(String)}
  def sorbet_test_to_s(x)
    x.to_s
  end
end

T.anyについてはUnion Typesを参照ください。

詳しい内容は、こちらのコミットログを参照ください。

動作確認

では実際に動作確認をしていきます。

型チェックを実行

ソースコードを以下の状態にして、型チェックを実行します。

# app/controllers/application_controller.rb
# typed: strong
class ApplicationController < ActionController::Base
  extend T::Sig

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

# app/controllers/pages_controller.rb
# typed: true
class PagesController < ApplicationController
  extend T::Sig

  # typed: strong だとsignatureを定義しないとエラーとなる
  def about
    # コメント化されている部分はSorbetによる型チェックエラーとなる
    @value = ''
    @value += sorbet_test_to_s CONST_INT
    @value += sorbet_test_to_s CONST_FLOAT
    @value += sorbet_test_to_s CONST_STRING
    @value += sorbet_test_to_s_app CONST_INT
    @value += sorbet_test_to_s_app CONST_FLOAT
    @value += sorbet_test_to_s_app CONST_STRING
  end

  private

  CONST_INT = 123
  CONST_FLOAT = 45.6
  CONST_STRING = '7-8-9'

  sig {params(x: T.any(Integer, Float)).returns(String)}
  def sorbet_test_to_s(x)
    x.to_s
  end
end
# 型チェック実行
bundle exec srb tc

# 結果
app/controllers/pages_controller.rb:11: Expected T.any(Integer, Float) but found String("7-8-9") for argument x https://srb.help/7002
    11 |    @value += sorbet_test_to_s CONST_STRING
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    app/controllers/pages_controller.rb:30: Method PagesController#sorbet_test_to_s has specified x as T.any(Integer, Float)
    30 |  sig {params(x: T.any(Integer, Float)).returns(String)}
                      ^
  Got String("7-8-9") originating from:
    app/controllers/pages_controller.rb:21:
    21 |  CONST_STRING = '7-8-9'
          ^^^^^^^^^^^^

app/controllers/pages_controller.rb:13: Expected Integer but found Float(45.600000) for argument x https://srb.help/7002
    13 |    @value += sorbet_test_to_s_app CONST_FLOAT
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    app/controllers/application_controller.rb:13: Method ApplicationController#sorbet_test_to_s_app has specified x as Integer
    13 |  sig {params(x: Integer).returns(String)}
                      ^
  Got Float(45.600000) originating from:
    app/controllers/pages_controller.rb:20:
    20 |  CONST_FLOAT = 45.6
          ^^^^^^^^^^^

app/controllers/pages_controller.rb:14: Expected Integer but found String("7-8-9") for argument x https://srb.help/7002
    14 |    @value += sorbet_test_to_s_app CONST_STRING
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    app/controllers/application_controller.rb:13: Method ApplicationController#sorbet_test_to_s_app has specified x as Integer
    13 |  sig {params(x: Integer).returns(String)}
                      ^
  Got String("7-8-9") originating from:
    app/controllers/pages_controller.rb:21:
    21 |  CONST_STRING = '7-8-9'
          ^^^^^^^^^^^^
Errors: 3

signatureに定義している内容に反している、以下実装がエラー検出されていることが確認できています。

@value += sorbet_test_to_s CONST_STRING
@value += sorbet_test_to_s_app CONST_FLOAT
@value += sorbet_test_to_s_app CONST_STRING

rails s→動作確認

先ほどのソースの状態のまま、bundle exec rails sで起動、http://localhost:3000/about(pages#about)をブラウザで開いてみます。

すると、以下のようにエラー検出されていることが確認できます。

f:id:kic-yuuki:20190714141001p:plain

問題となっている以下コード or signatureをコメント化すると、問題なく表示できることが確認できます。

# 問題のコード
@value += sorbet_test_to_s CONST_STRING
@value += sorbet_test_to_s_app CONST_FLOAT
@value += sorbet_test_to_s_app CONST_STRING

実行時エラーを無視するには?

この仕組みはGemfileに記述したsorbet-runtimeによって実現されているようです。

コードをより良くするため、この仕組みはとても良いと思うのですが、

  • ローカル or テスト環境では有効 他(ステージング、本番)環境では無効に設定

のような運用方法が考えられます。

上記のようなことは、Runtime Configuration · Sorbetから設定できそうな感じに見受けられるのですが...それはまた別の機会に試そうかなと思います。

おわり

「sorbet」=「ソルベ」って読むんですね...。

ソルベッ(sorbetto)と読んでいた。
 いい加減英語覚えないとなーと思う、思っている。

間違い等ありましたら、FBいただけると幸いです。🙇‍♂️

参考記事

所感

テストをしっかり書いてなかったりすると、Ruby on Railsのコードはどんどんメンテしにくいものになっていく傾向があると感じています。

上記のような問題点を改善していくために、このsorbetは有効(最高)だな...と感じています。

(sorbet配下に出力されるファイルが多いため、CIで回すとかがいいかもですね。)

まだ制約(includeしたメソッドは対象外となる...?)もあるっぽいので、更新状況や使い勝手を試しながら、プロダクト開発へ導入提案するか、検討しようかなと思います。