こんにちは! フィフス・フロアの開発チームリーダーのnotozekiです。
フィフス・フロアでは、Rubyを長年活用しています。
たとえば弊社プロダクトのうちのこまとめやぷちのこメーカーはいずれもバックエンドはRuby(Rails)で実装されていますし、そのほかの開発実績もRubyを利用したものが数多くあります。
ところで、このところRubyの周りでは「型」の話題がホットですね。
現在開発が進められているRuby3では静的型チェッカーが導入される予定ですし、今年の6月にはRubyの型チェッカーSorbetがオープンソース化されました。
フィフス・フロアにはRubyを活用するプロダクトが多いことと、型は開発(特にチーム開発)を円滑に進めるのに役立つ機能なので、この話題にはとても注目しています。
この記事では、簡単なRailsアプリケーションにSorbetを導入して、「Rubyに型がある」ことでどのような開発体験が得られるのかを試してみたいと思います。
「Sorbet実際どうなの?」「実開発で活用できそう?」と考えているRubyistのみなさまの参考になれば幸いです💎
Sorbetは、Rubyの型チェッカーの1つです。
決済プラットフォームを手がけるStripeが開発しており、C++で実装されていて高速に実行できます。
「型チェッカー」というのは、文字通りプログラムの「型」が正しいかどうかをチェックするツールのことです。
Sorbetを使うと、Rubyプログラムの「型」に起因するミス、たとえばメソッド名のtypoや、整数を期待するところに文字列を渡してしまうなどを、実行前にチェックして防ぐことができます。
ところで先述したように、Rubyのバージョン3でも、静的型チェッカーの導入が予定されています。
かといって、Ruby3が出たらSorbetは使われなくなってしまうのかというと、実はそうとも限りません。
Ruby3で導入される予定の型チェッカーは、以下の4つのコンポーネントに分かれています。
Ruby3では、これらを言語そのものとは別に周辺ツールとして用意することで、Rubyプログラムへの型付け(さらには型定義の記述を必要としない自動的な型付け)を実現する計画のようです。
参考: Ruby3で導入される静的型チェッカーのしくみ まつもとゆきひろ氏がRubyKaigi 2019で語ったこと - Part1 - ログミーTech
Sorbetは上記4つのコンポーネントのうちの、「静的型チェッカー」を担うツールの1つという位置づけです(Sorbetの他にはSteepという型チェッカーも開発されています)。
現在は上記の「rbiファイル」との互換性はないものの、将来的に対応すれば、Ruby3の型システムの一部としてSorbetも使えることになります。
ただし、Sorbetも独自の型定義のフォーマットを持っていますが、Ruby3ではそれらはobsoleteになると考えられます。
したがって、Sorbet向けにがんばって型定義を作っても、Ruby3ではそのままは利用できない可能性があることには注意が必要そうです。
とはいえある程度の互換性はありそうなので、変換ツールなどが今後出てきそうな気はします。
SorbetはもちろんRailsにも使えますが、Rails向けに特別なサポートはありません。
しかし、Railsでは自動生成される項目(e.g. モデルのアクセサメソッド、URLヘルパなど)が多く、手動でそれらに型をつけるのは大変です。
sorbet-railsというサードパーティ製のツールを使うと、モデルやURLヘルパのSorbet向けの型定義を自動生成してくれるため、型定義を書く手間を省けます。
今回のサンプルでもsorbet-railsを活用しています。
さて、前置きが長くなりましたが、Railsでサンプルアプリケーションを作りながら、Sorbetを使った開発を体験していきましょう。
今回作るアプリケーションは、前回の記事を踏襲して、簡単な「本の管理」ができるWeb APIを作っていきます。
今回のサンプルアプリケーションのソースコードは以下の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に関係する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の本体を追加します。
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を使う準備が整いました。
ここからはアプリケーションの開発に入っていきます。
まずはモデルを作りましょう。
今回のアプリケーションは「本を管理」するものなので、まずは「本」のモデルを作ることにします。
$ 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を更新します。
$ 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
を実行して型チェックを回す運用になりそうです(自動化したいところですが今回はそこまでは踏み込みませんでした)。
ファイルの先頭のコメントの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が活用できそうかどうか検証を行いました。
良かった点:
srb tc
の実行は速いです。課題点:
srb tc
を毎回やるのはやや面倒に感じます。エディタでリアルタイムに型チェックの結果が見れれば理想的ですが、少なくともドキュメントにはそのような機能の記載はありませんでした(トップページにはIDE-ready
と書かれていますが…)。rails rails_rbi:{models,routes}
をするのが面倒あるいは忘れそうです。やはり「型チェックがある」という開発体験はとても良いものでした。
あまり伝わらないかもしれませんが、型チェックがないときと比べて、常に認知的なエントロピーの水準が少し下がった状態でコーディングできるという感覚があります。
今回は簡単なサンプルで試しただけなので、実プロダクトへの導入はまだ若干ハードルがあるかもしれませんが、要所に使うだけでも効果はあるかもしれません。
みなさまのより良いRuby開発の参考になれば幸いです☺️
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
を使って回避しました。