はじめに

こんにちは! フィフス・フロアの開発チームリーダーのnotozekiです。

フィフス・フロアでは、Rubyを長年活用しています。
たとえば弊社プロダクトのうちのこまとめぷちのこメーカーはいずれもバックエンドはRuby(Rails)で実装されていますし、そのほかの開発実績もRubyを利用したものが数多くあります。

ところで、このところRubyの周りでは「型」の話題がホットですね。
現在開発が進められているRuby3では静的型チェッカーが導入される予定ですし、今年の6月にはRubyの型チェッカーSorbetがオープンソース化されました

フィフス・フロアにはRubyを活用するプロダクトが多いことと、型は開発(特にチーム開発)を円滑に進めるのに役立つ機能なので、この話題にはとても注目しています。

この記事では、簡単なRailsアプリケーションにSorbetを導入して、「Rubyに型がある」ことでどのような開発体験が得られるのかを試してみたいと思います。
「Sorbet実際どうなの?」「実開発で活用できそう?」と考えているRubyistのみなさまの参考になれば幸いです💎

Sorbetとは

Sorbetは、Rubyの型チェッカーの1つです。
決済プラットフォームを手がけるStripeが開発しており、C++で実装されていて高速に実行できます。

「型チェッカー」というのは、文字通りプログラムの「型」が正しいかどうかをチェックするツールのことです。
Sorbetを使うと、Rubyプログラムの「型」に起因するミス、たとえばメソッド名のtypoや、整数を期待するところに文字列を渡してしまうなどを、実行前にチェックして防ぐことができます。

Sorbet公式サイトの例より引用

Ruby3の型チェッカー

ところで先述したように、Rubyのバージョン3でも、静的型チェッカーの導入が予定されています。
かといって、Ruby3が出たらSorbetは使われなくなってしまうのかというと、実はそうとも限りません。

Ruby3で導入される予定の型チェッカーは、以下の4つのコンポーネントに分かれています。

  • rbiファイル(型定義シンタックス)
  • ライブラリの型定義(rbiで記述)
  • Type Profiler
  • 静的型チェッカー

Ruby3では、これらを言語そのものとは別に周辺ツールとして用意することで、Rubyプログラムへの型付け(さらには型定義の記述を必要としない自動的な型付け)を実現する計画のようです。

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

Sorbetの位置づけ

Sorbetは上記4つのコンポーネントのうちの、「静的型チェッカー」を担うツールの1つという位置づけです(Sorbetの他にはSteepという型チェッカーも開発されています)。
現在は上記の「rbiファイル」との互換性はないものの、将来的に対応すれば、Ruby3の型システムの一部としてSorbetも使えることになります。

ただし、Sorbetも独自の型定義のフォーマットを持っていますが、Ruby3ではそれらはobsoleteになると考えられます。
したがって、Sorbet向けにがんばって型定義を作っても、Ruby3ではそのままは利用できない可能性があることには注意が必要そうです。
とはいえある程度の互換性はありそうなので、変換ツールなどが今後出てきそうな気はします。

SorbetとRails

SorbetはもちろんRailsにも使えますが、Rails向けに特別なサポートはありません。
しかし、Railsでは自動生成される項目(e.g. モデルのアクセサメソッド、URLヘルパなど)が多く、手動でそれらに型をつけるのは大変です。

sorbet-railsというサードパーティ製のツールを使うと、モデルやURLヘルパのSorbet向けの型定義を自動生成してくれるため、型定義を書く手間を省けます。
今回のサンプルでもsorbet-railsを活用しています。

サンプルアプリケーション

さて、前置きが長くなりましたが、Railsでサンプルアプリケーションを作りながら、Sorbetを使った開発を体験していきましょう。

今回作るアプリケーションは、前回の記事を踏襲して、簡単な「本の管理」ができるWeb APIを作っていきます。

前回の記事
OpenAPIとTypeScriptで作る!チーム開発に適したWebアプリケーションの作り方

今回のサンプルアプリケーションのソースコードは以下のGitHubリポジトリに置いているので、参考にしてください。

https://github.com/5thfloor/sorbet-rails-sample-app

セットアップ

今回使用するRubyとRailsのバージョンは以下のとおりです。

$ ruby -v
ruby 2.6.0p0 (2018-12-25 revision 66547) [x86_64-darwin18]
$ rails -v
Rails 5.2.3

まずは通常通りRailsをセットアップします。

$ rails new sorbet-rails-sample-app
$ cd sorbet-rails-sample-app

sorbet-rails

次に、Sorbetに関係するGemをインストールしていきます。
まずはsorbet-railsを追加します。

Gemfileに以下を追記してbundle installします。

gem 'sorbet-rails'

次に、sorbet-railsによって追加されるRakeタスクを使って、URLヘルパとモデルのSorbet向けの型定義ファイルを生成します。

$ bin/rails rails_rbi:routes
$ bin/rails rails_rbi:models
# いくつか警告が出ますが問題ありません

これでsorbet/rails-rbi以下にSorbetの型定義ファイルが生成されます。

$ tree sorbet
sorbet
└── rails-rbi
    ├── activerecord.rbi
    ├── models
    │   ├── active_record
    │   │   ├── internal_metadata.rbi
    │   │   └── schema_migration.rbi
    │   └── active_storage
    │       ├── attachment.rbi
    │       └── blob.rbi
    └── routes.rbi

4 directories, 6 files

なお、Sorbet向けの型定義ファイルも「RBI」(拡張子.rbi)という名前が付いていますが、これはRuby3の「rbiファイル」とは異なるものなので注意してください。

このRakeタスクの実行は、ルートやモデルを追加するたびに必要になります。

sorbet, sorbet-runtime

次にSorbetの本体を追加します。

Gemfileに以下を追記してbundle installします。

gem 'sorbet'
gem 'sorbet-runtime'

次に、Sorbetの初期化をします。

$ bundle exec srb init
# 途中の質問に y と答えます

これでSorbetの設定ファイルや、現在インストールされているGemのRBIファイルなどが生成され、型チェックを行う準備が整います。

さて、最初の型チェックをやってみましょう!

$ bundle exec srb tc
No errors! Great job.

通りました🎉
これでRailsでSorbetを使う準備が整いました。

モデルの作成とRBIの自動生成

ここからはアプリケーションの開発に入っていきます。
まずはモデルを作りましょう。

今回のアプリケーションは「本を管理」するものなので、まずは「本」のモデルを作ることにします。

$ bin/rails g model Book

以下のマイグレーションスクリプトを書いてマイグレーションします。

class CreateBooks < ActiveRecord::Migration[5.2]
  def change
    create_table :books do |t|
      t.string :title, null: false
      t.string :cover_url
      t.date :published_at, null: false

      t.timestamps
    end
  end
end

titleは書名、cover_urlは表紙画像のURL、published_atは発行日の想定です。

cover_urlは、表紙がまだ準備されていないなどのケースを想定してNULLを許可しています。
それ以外は必須項目です。
カラムがnullableかどうかは、sorbet-railsで自動生成される型定義にも反映されます(!)

モデルのRBIを生成する

セットアップでやったのと同じ手順で、モデルのRBIを更新します。

$ bin/rails rails_rbi:models

すると、sorbet/rails-rbi/models/book.rbiに、先程作ったBookモデルに対応するRBIが生成されます。

一部を抜粋すると以下のような感じになっています:

module Book::InstanceMethods

  sig { returns(T.nilable(String)) }
  def cover_url(); end

  sig { returns(Date) }
  def published_at(); end

  sig { returns(String) }
  def title(); end

end

各カラムに対応する型の定義が自動的に生成されています。
nullableなカラムに対応するgetterはnilableな型に、そうでないカラムは非nilableな型になっています。便利ですね。

サービスを書こう

前回の記事で利用したNestJSに合わせて、「サービス」層を導入することにします。
サービスはビジネスロジックを実装する場所です。

今回は、「本の一覧を取得する」や「指定されたIDをもとに本を探す」というロジックを実装します。
これだけならサービスを設けずともActiveRecordだけで事足りますが、Sorbetによる型の恩恵をわかりやすくするためにあえて設けています。

# typed: strong
class BooksService
  extend T::Sig

  sig { returns(ActiveRecord::Relation) }
  def find_all
    Book.all
  end

  sig { params(id: String).returns(Book) }
  def find_one(id)
    Book.find(id)
  end
end

型チェックしてみましょう。

$ bundle exec srb tc
No errors! Great job.

良さそうです👍

💡POINT: このように、Sorbetを使った開発では、こまめにsrb tcを実行して型チェックを回す運用になりそうです(自動化したいところですが今回はそこまでは踏み込みませんでした)。

strictness level

ファイルの先頭のコメントのtyped: strongは、strictness levelを指定するものです。
strictness levelとは、どのくらいの型エラーを報告するかという5段階の指標です。
詳しくは以下の公式ドキュメントを参照してください。
https://sorbet.org/docs/static#file-level-granularity-strictness-levels

typed: strongは最も強いstrictness levelで、Sorbetで検出できる全ての型エラーが報告されます。
基本的にはこのレベルを使い、何らかの不具合がある場合は段階的にレベルを下げる、という運用が良さそうです。

メソッドのシグネチャ

メソッド定義の直前に現れるsigが、メソッドのシグネチャを定義する部分です。
これを各メソッドについて書いていくのが、Sorbetを使った開発のキモになります(ちなみにこれを「書かなくても良い」ようにするのがRuby3の計画ですね)。

sigの中身はほぼ読んだままの意味ですが一応説明すると、find_allは「ActiveRecord::Relationのインスタンスを返す」メソッド、find_oneは「Stringのインスタンスを第1引数に取りBookのインスタンスを返す」メソッドであることを表しています。
sigの書き方の詳細は以下の公式ドキュメントを参照してください。
https://sorbet.org/docs/sigs

コントローラを書こう

次にコントローラを書きます。
今回は本の情報を返すBooksControllerを用意し、本の一覧を返すindexアクションと、個別の本の情報を返すshowアクションを設けます。

# typed: true
class BooksController < ApplicationController
  def index
    books_service = BooksService.new
    @books = books_service.find_all
    render json: @books, each_serializer: BookSerializer
  end

  def show
    books_service = BooksService.new
    book_id = T.let(params[:id], String)
    @book = books_service.find_one(book_id)
    render json: @book, selializer: BookSerializer
  end
end

strictness levelがtyped: trueになっていますが、typed: strict以上にすると、Rails のコントローラでよくあるパターンの「アクション内でのインスタンス変数への代入」で怒られてしまいます。

app/controllers/books_controller.rb:6: Instance variables must be declared inside `initialize` https://srb.help/5005
     6 |    @books = T.let(book_service.find_all, ActiveRecord::Relation)
            ^^^^^^

これはさすがに厳しいので、コントローラはtyped: trueにとどめています。
あと、コントローラのアクションは基本的に引数も戻り値もないのでシグネチャはsig { void }になりますが、typed: trueだとその記述も省略できるのが良いですね。
コントローラは薄く保つのが良いプラクティスなので、それを実践している限りtyped: trueくらいの保護でも問題ないと考えます。

この状態で、試しにサービスに存在しない適当なメソッドを呼んでみたら、ちゃんとエラーになります。いいですね!

$ bundle exec srb tc
app/controllers/books_controller.rb:5: Method foobar does not exist on BooksService https://srb.help/7003
     5 |    book_service.foobar
            ^^^^^^^^^^^^^^^^^^^
    https://github.com/sorbet/sorbet/tree/51504253c985d0a967d3df6a39ac44b25db2c481/rbi/core/kernel.rbi#L626: Did you mean: Kernel#format?
     626 |  def format(format, *args); end
            ^^^^^^^^^^^^^^^^^^^^^^^^^
Errors: 1

シリアライザを書こう

試したところ、ビューのテンプレートは型チェックされなかったため、型チェックが働く部分をなるべく増やすためにシリアライザを作ることにしました。
シリアライザはactive_model_serializersを使って実装します。

# typed: strict
class BookSerializer < ActiveModel::Serializer
  extend T::Sig

  attributes :id, :title, :cover_url, :published_at

  sig { returns(T.nilable(String)) }
  def cover_url
    book.cover_url.sub(/\Ahttp:/, 'https:')
  end

  sig { returns(String) }
  def published_at
    book.published_at.iso8601
  end

  private

  sig { returns(Book) }
  def book
    object
  end
end

typed: strongにすると、objectメソッドやattributesクラスメソッドがuntypedなため型エラーになってしまいました。

app/serializers/book_serializer.rb:14: This code is untyped https://srb.help/7018
    14 |    object
            ^^^^^^

app/serializers/book_serializer.rb:5: This code is untyped https://srb.help/7018
     5 |  attributes :id, :title, :cover_url, :published_at
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

typed: strictだとuntypedなメソッドコールも許容されるので、今回はそれを使って回避します(このあたりは真面目にやるとactive_model_serializerの型定義を作って対応すべきなのでしょうかね…?)。

さて、Bookモデルのgetterには自動生成された型が付いていました。今こそその型の恩恵を実感するときです。
実は、上記のコードは型チェックを通りません。
よくやるうっかりミスの例として、nilableな値をnilチェックせずに使ってしまっています。

  sig { returns(T.nilable(String)) }
  def cover_url
    book.cover_url.sub(/\Ahttp:/, 'https:') # cover_urlはnilのこともある
  end

(いい例を思いつかなかったので、https対応をべた書きするというあまり良くないコードになっています。例ということでご容赦ください。)

すると、ちゃんと型チェックで怒られます。いいですね。

$ bundle exec srb tc
app/serializers/book_serializer.rb:9: Method sub does not exist on NilClass component of T.nilable(String) https://srb.help/7003
     9 |    book.cover_url.sub(/\Ahttp:/, 'https:')
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

ぼっち演算子を使って修正しましょう。

  sig { returns(T.nilable(String)) }
  def cover_url
    book.cover_url&.sub(/\Ahttp:/, 'https:')
  end
$ bundle exec srb tc
No errors! Great job.

GJ👍

まとめ

この記事では、簡単なRailsアプリケーションにSorbetを導入して、実際の開発にSorbetが活用できそうかどうか検証を行いました。

良かった点:

  • 予想以上によくできていると感じました。小さなプロジェクトなら実戦でも使えるのでは?とも思います。
  • Rubyでやりがちな、メソッド名のタイプミスや、nilableな値をnilチェックせずに使ってしまうなどのミスを実行前に検証できるのは大きいですね。これだけでも導入の価値がありそうです。
  • さすがC++で実装されているだけあってsrb tcの実行は速いです。

課題点:

  • srb tcを毎回やるのはやや面倒に感じます。エディタでリアルタイムに型チェックの結果が見れれば理想的ですが、少なくともドキュメントにはそのような機能の記載はありませんでした(トップページにはIDE-readyと書かれていますが…)。
  • 現状では、strictness levelの指定が一貫しないなどの「諸事情」があり、ローカルルールを導入して対応することになりそうです。複数人開発のときにその認識合わせをするのは少し厄介そうです。
  • モデルやルートの追加や変更があるたびrails rails_rbi:{models,routes}をするのが面倒あるいは忘れそうです。

やはり「型チェックがある」という開発体験はとても良いものでした。
あまり伝わらないかもしれませんが、型チェックがないときと比べて、常に認知的なエントロピーの水準が少し下がった状態でコーディングできるという感覚があります。
今回は簡単なサンプルで試しただけなので、実プロダクトへの導入はまだ若干ハードルがあるかもしれませんが、要所に使うだけでも効果はあるかもしれません。

みなさまのより良いRuby開発の参考になれば幸いです☺️


付録: トラブルシューティング

Gemを追加したらSorbetから見えてないっぽい

srb rbi updateしましょう。

最初は srb rbi gemsでいけるかと思ったのですが、その状態でsrb tcしたらいろいろ型エラーになってしまいました。
Gemとの相性もあるのかもしれません(今回はactive_model_serializersを追加したときに実行しました)。

参考: https://sorbet.org/docs/rbi#autogenerated-rbis-for-gems

undefined method `sig' for XXX (NoMethodError)で怒られる

extend T::Sigしましょう。

これが無くてもsrb tcは通るときがあるので気づかないこともありますが、基本的にはsigするところにはextend T::Sigが要るようです。

個人的には、ちょっとコードにノイズが増えるので無くせるなら嬉しいですが、仕方ないですね。

ActiveRecord::Relationの代わりにXXX::ActiveRecord_Relationを使うと実行時エラーになる

うまい解決法が見つかりませんでした😢

まず、XXX::ActiveRecord_Relationという型定義について簡単に説明します(筆者の推測を含んでいます)。
sorbet-railsを使うと、<モデル名>::ActiveRecord_Relationという型定義が生成されます。たとえばBookモデルに対しては以下のような型定義が作られます。

class Book::ActiveRecord_Relation < ActiveRecord::Relation
  include Book::ModelRelationShared
  extend T::Generic
  Elem = type_member(fixed: Book)
end

見ての通りActiveRecord::Relationを継承しているので、別にActiveRecord::Relationを直接使っても良さそうに見えるのですが、たとえば極端な例では以下のようなケースを防げると考えられます。

class BooksService
  sig { returns(ActiveRecord::Relation) }
  def find_all
    User.all # まちがってUserって書いちゃった
  end
end

Bookの一覧をActiveRecord::Relationで返す想定が、Userの一覧を返してしまっています。
しかし、どちらもActiveRecord::Relationであるのは間違いないので、これは型チェックを通ってしまいます。
このようなケースを防ぐために、Bookに特化したBook::ActiveRecord_Relationが存在すると考えられます。

さて、喜び勇んでこれをsigで使うと、srb tcは通るものの、実行時エラーになってしまいます。

# typed: strong
class BooksService
  extend T::Sig

  sig { returns(Book::ActiveRecord_Relation) }
  def find_all
    Book.all
  end
end

rails sをして、上記コードを使うルートにアクセスすると、以下のようなuninitialized constantエラーが発生します。

NameError (uninitialized constant Book::ActiveRecord_Relation):
  
app/services/books_service.rb:5:in `block in <class:BooksService>'
app/controllers/books_controller.rb:9:in `index'

Book::ActiveRecord_RelationはRBIにだけ存在するため、通常のRailsからは見えないようです。

一応、app/models/book/active_record_relation.rbを以下のように作れば回避できました。

class Book
  class ActiveRecord_Relation
  end
end

ただ、全モデルについてこれを作成するのは手間なのと、無理矢理感が強いのでできれば避けたい手ですね… これ以外に解決する方法は見つけられませんでした。
ひとまず今回はActiveRecord::Relationを使って回避しました。

参考