既にあるRailsプロジェクトにSorbetの静的型チェックを導入しました
こんにちは。
PharmaX でエンジニアをしている諸岡(@hakoten)です。
この記事の概要
この記事では、次の対応を行ったときのプロセスやハマりポイントなどを書いています。
- ① sorbet-rails から tapiocaへのマイグレーション
- ② 既存のRailsアプリケーションにSorbetの静的チェック(
srb tc
)を導入
この記事を読む前に、そもそもSorbetとtapiocaがどんなものかを知りたい方は、次の記事も一読ください。
背景
PharmaXのバックエンドでは、Ruby on Railsを用いており、型チェックにはSorbetを採用しています。
導入当初からRuby on RailsのRBI生成にはsorbet-railsを利用していました。しかし、現在はtapiocaの使用が推奨されているため、sorbet-railsからtapiocaへの移行を行うことになりました。
一方、SorbetはCLIによる静的チェックとランタイムにおける動的チェックの二つの機能を提供していますが、PharmaXではこれまで動的チェックのみを行ってきました。これは、静的チェックの初期設定に伴うエラーの解決が困難であったため、導入の迅速化を優先した結果と聞いています。
今回tapiocaへの移行を機に、Sorbetの静的チェック機能の導入も検討することになり、それに伴う対応を行ったという背景になります。
① sorbet-rails から tapiocaへのマイグレーション
ここでは、RBI生成をsorbet-railsからtapiocaへと切り替える方法を紹介します。
この移行作業はそれほど難しくなく、公式からはマイグレーションに関するガイドが提供されています。移行を検討されている方は、ぜひ以下の公式ドキュメントをご一読ください。
「今まで生成されていたRBIファイルを全て削除し、tapiocaを使ってRBIファイルを作成し直す。」というのが基本の流れです。
tapioca のインストール
まずは、sorbet-rails
を削除して、tapioca
をインストールします。
gem 'sorbet', :group => :development
gem 'sorbet-runtime'
- gem 'sorbet-rails'
+ gem 'tapioca', require: false, :group => [:development, :test]
tapiocaは、CLI でのみ使用するため require: false
で定義しています。
既存のRBIファイルの削除
tapiocaを使って全てのRBIファイルをもう一度つくるため、sorbet-runtime
で作成したRBIファイルをすべて削除します。(sorbet/rbi/
配下のファイルは全て削除)
tapiocaの初期化
bundle exec tapioca init
tapiocaをinitコマンドで初期化します。
コマンドを叩くと、binディレクトリ配下に実行ファイルが配置されるのと設定ファイルが作成されます。
ここまでで、tapiocaの準備は完了です。
tapiocaによる型の生成
ここからは、tapioca を使って新しくRBIを作成していきます。
前述の「tapioca のインストール」で tapioca init
による初期化処理を行っていない場合は、先に初期化を行って下さい。
1.tapioca dsl
Ruby言語において、DSLやメタプログラミングを駆使してクラスが定義されることがありますが、これらによって定義された型は、Sorbetでは検出されません。
この問題を解決するため、Railsで構築されたDSLやメタプログラミングにより生み出された型のRBIファイルと作成するためにtapioca dsl
コマンドを実行します。Railsの領域では、ActiveRecordのモデルやActiveJobなどが、独自のクラスとしてのRBIファイルとして作成されることになります。
./bin/tapioca dsl
作成されたRBIファイルは sorbet/rbi/dsl
ディレクトリ配下に置かれます。
注意点として、tapioca dsl
では、実際に、アプリケーションコードをロードしてRBIファイルを生成するため、Railsアプリケーションが正しく起動できる環境で実行する必要があります。
2.tapioca gem
次に、Gemfileでアプリケーションから参照しているライブラリのRBIを生成します。
tapioca gem
では、bundleディレクトリ配下にあるgemファイルを参照して、RBIファイルを作成してくれます。
./bin/tapioca gem
作成されたファイルは sorbet/rbi/gems
ディレクトリ配下に置かれます。
tapioca gem
によって生成されるRBIファイルはバージョン指定されているため、Gemのアップデートがあった際にはRBIも更新が必要です。全てのgems配下のRBIを再生成する場合は、以下のコマンドを実行してください。
./bin/tapioca gems --all
3.tapioca annotations
tapioca gem
コマンドで生成されるRBIには、 型注釈(sig{<型>}で書かれるRubyファイルに埋め込む注釈)
が入っていません。
この点を補うため、主要なGemについてはShopifyが管理するリポジトリ(rbi-central)に型注釈入のRBIが用意されています。
このRBIをダウンロードするコマンドが、 tapioca annotations
になります。
作成されたファイルは sorbet/rbi/annotations
ディレクトリ配下に置かれます。
srb tc
)を導入する
② 既存のRailsアプリケーションにSorbetの静的チェック(tapiocaを用いたRBIファイルの生成が終わったところで、次には既存のアプリケーションにSorbetの静的チェックを取り入れるプロセスについて書いて行きます。
1. まずは1回実行してみる
まずは、現状でどれほどのエラーが生じているのかを把握するために、srb tc
コマンドを実行してみました。
bundle exec srb tc
...
Errors: 1104
1104個の型エラーがありました(悲)。
当然このままでは、CIフローに乗せることはできません。
エラーの詳細を見てみると、以下のようなエラーメッセージが数多く見られました。
To use sig, this file must declare an explicit # typed: sigil (found: none). If you're not sure which one to use, start with # typed: false https://srb.help/5038
これは、ファイル内で extend T::Sig
が使用されているにもかかわらず、静的チェックの際に参照される「sigil」という厳格レベルを示す宣言が含まれていないことに起因するエラーです。
(エラー例)
# 本来は以下のような宣言(sigil)が必要
- # typed: false
module Reception
module Requests
class ReceptionGetRequest
# 動的チェックでT::Sigは宣言されている
extend T::Sig
また、別の問題として、sigilsの宣言が厳しすぎて、権限にかかっているファイルもいくつかありました。
2. suggest-typed コマンドで適切な sigilを付与する
Sorbetには、srb rbi suggest-typed
というCLIコマンドが用意されています。このコマンドは、sigilを適切なものに更新するために非常に便利で、次のような修正をしてくれます。
- sigilが未定義のファイルに新たに追加
- sigilの厳格レベルに関するエラーがある場合、適切なsigilに更新
最初にこのコマンドを実行し、sigilを適切なものに更新してみました。
bundle exec srb rbi suggest-typed
この手順の後、再度 srb tc
を実行したところ、結果は以下のようになりました。
bundle exec srb tc
...
Errors: 217
この対応だけで、エラー数を217まで大幅に減少させることができました!多くのエラーがsigilの不適切な定義に関連していたようです。
typed: ignore
のsigilをtyped: false
に変更
3. 残りのエラーを確認すると、以下のようなエラーが多く見られました。
app/models/insurance_card.rb:3: Unable to resolve constant ApplicationRecord https://srb.help/5002
3 |class InsuranceCard < ApplicationRecord
これは、ApplicationRecordが型として認識されないためのエラーです。ApplicationRecordのファイルを調べると sigil
が typed: ignore
で定義されています。
# typed: ignore
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
...
end
sigil
の仕様として、 typed: ignore
で宣言されているファイルは、ファイル自体が読み込まれず、そのファイルで定義された型情報も参照されません。
そこで、これを typed: false
に変更し、型を正しく読み込めるようにしました。
詳細わかっていませんが、 srb rbi suggest-typed
でも完全に修正できないものがあるようです。
ApplicationRecordだけでなく、他にもignore設定されているファイルがあったため、それらもtyped: false
に更新して、再びsrb tc
を実行しました。
bundle exec srb tc
...
Errors: 67
この対応により、エラーを67まで減らすことができました!
tapioca gem
で作成されなかったRBIをrequire.rb
に追加してもう一度作成
4. 残るエラーとしては、tapioca gem
コマンドのRBIを作成の時に、ライブラリ内の一部の型が生成されていないという問題で型参照のエラーがありました。
例えば次のようなものです。
app/transfer/cloud_storage_transfer.rb:12: Unable to resolve constant Storage https://srb.help/5002
12 | @client ||= Google::Cloud::Storage.new(
^^^^^^^^^^^^^^^^^^^^^^
PharmaXでは、google-cloud-storage
という gemを使って、Google CloudのStorageサービスを使用していますが Google::Cloud::Storage
の型が解決できていませんでした。
tapioca gem
では、全ての定義がデフォルトのrequireに含まれていないgemにおいては、gem内のすべての型がRBIに書き出されないこともあるようです。
この場合は、 tapioca init
時に作成された sorbet/tapioca/require.rb
ファイルに個別にrequireを書くことで解決できます。
Google::Cloud::Storage
の場合、次のように requireを追加します。
# typed: strict
# frozen_string_literal: true
require "google/cloud/storage"
require.rbに個別のrequireを追記したものは tapioca gem <gemの名前>
をもう一度実行する必要があります。
google/cloud/storage
の場合は、次のようにコマンドを実行します。
./bin/tapioca gem google-cloud-storage
これで作成されたRBIファイルに、新しい型が追加されます。
これらの作業を繰り返し、型解決できていないものについてはtapioca gem <gem名>
でRBIファイルの更新を行いエラーを解消していきました。
最終的なsrb tc
の結果は次のとおりです。
bundle exec srb tc
...
Errors: 35
半分までエラーは減り35個になりました。
srb tc
の対象から外す
5. specディレクトリをエラーの中には、RSpecに起因する型エラーやテストファイル内でのエラーも含まれていました。現行の運用では、rspecを用いたテストファイルに対して型注釈を施していないため、テストファイルにおける静的チェックは行わない方針で対応しました。
srb tc
は対応する設定ファイル(sorbet/config
)を持っており、通常このファイルはtapioca init
の際に生成されます。
この設定ファイルを利用することで、srb tc
の除外ファイルや対象ディレクトリを指定でき、これらの設定はsrb tc
を実行する際に自動的に読み込まれます。
今回、spec
ディレクトリをチェックの対象外とするために、このファイルを下記のように編集しました。
--dir
.
--ignore=/vendor/bundle
# 以下を追加
--ignore=/spec
srb tc
の結果は次のとおりです。
bundle exec srb tc
...
Errors: 23
もう少しです。。
6. 誤った型エラーを修正する
ここまでで、RBIの欠如や参照の問題に関する対応は完了しており、残りは既存の型注釈に必要な修正をしました。。
例として、以下のようなエラーが挙げられます。
app/context/reception/entities/medical_notebook_entity.rb:27: T.nilable(T.untyped) is the same as T.untyped https://srb.help/5070
27 | file: T.nilable(T.untyped),
^^^^^^^^^^^^^^^^^^^^
このエラーは、T.nilable(T.untyped)
が冗長であるため、T.untyped
に修正するべきだという内容です。
他にも、以下のようなエラーがありました。
- 存在しない型(定数)が使用されている
- 引数の型が誤っている
存在しない型に関しては、実際には使用されていないデッドコードであり、これを削除することで問題に対処しています。引数の型に誤りについては、T.nilable
が必要な箇所にそれが指定されていないなど、実運用上ではエラーが発生しないものの、型シグネチャ上の誤りがいくつか存在していました。
特にデッドコードに関しては、動的なチェックだけでは完全に検知は難しく、静的チェックの有用性を改めて実感することができました。
% bundle exec srb tc
No errors! Great job.
ついに、すべてのエラーを消すことができました!
初めてsrb tc
を実行した際には1000件を超えるエラーが発生し、正直絶望的な気持ちでしたが、エラーの大部分はSorbetの厳格レベルの設定やRBIの参照に関連するものであったため、実際には1日程度で対応を完了することができました。
ハマったポイント
作業をしていていくつかハマりポイントがあったため、書き残しておきます。
1. tapiocaでは生成されない型がある
一部の型生成において、sorbet-railsでは生成されるが、tapiocaでは生成されないRBIがあるようです。
こちらの記事が大変参考になりました。ActiveRecord系の一部の型は、tapiocaの生成コマンドでは対応できないようです。
正確には、記事のとおりで、PrivateRelationという型は生成されるため、静的チェックは通りますが、動的チェック(実行時)で参照できないためエラーが発生します。
PharmaXアプリケーションでも、該当の型を使用しており、同じエラーになった箇所がありましたが、幸いにもレイヤードアーキテクチャを採用しているため、ActiveRecordを直接戻り値や引数として扱う関数は限られており、対象は1箇所のみでした。
T.untyped
で置き換えて対応。
【対策】 対象の箇所が1箇所しかなく、かなり限定的な使い方であったため、今回は T.untyped
に置き換えて対応しました。
現在のアーキテクチャでは、今後増えることもなさそうなので、このような対応を行いましたが、ActiveRecordのリレーションの型を頻繁にやり取りする場合には、記事で書かれていたように、sorbet-rails
を使うなど検討が必要かもしれません。
2. m1 Mac上のDocker imageだと上手く動かない問題
PharmaXでは、ローカル環境にDockerを使用しており、各種CLIもDokerコンテナに入って、叩く想定でいました。しかし、Sorbetはarm64のLinux環境に対応していないようで、いくつか問題が発生しました。
【問題1】 srb tcコマンドが動作しない
一つは、m1 macのDockerコンテナ上で、srb tc
コマンドが動作しないという問題です。
これは、前述のようにSorbetの実行ファイルがarm64のLinux環境に対応していないことが原因だと思われます。
root@404adfa3b638:/myapp# bundle exec srb tc
/myapp/vendor/bundle/ruby/3.2.0/gems/sorbet-0.5.11219/bin/srb: line 46: 407 Illegal instruction "${sorbet}" "${args[@]}"
別途有志で、対応のgemを作成してくれている方がいらっしゃるようでしたが、今後の運用も考えて今回は使用しませんでした。
【問題2】 tapioca gem コマンドで型が正しく出力されない
tapioca gem
コマンドをDockerコンテナ内で実行した時に、「RBIファイル自体は出力されるが、ファイルに型定義が正しく生成されていない(空ファイルになる)」という問題がありました。
# typed: true
# DO NOT EDIT MANUALLY
# This is an autogenerated file for types exported from the `google-cloud-core` gem.
# Please instead update this file by running `bin/tapioca gem google-cloud-core`.
<何も生成されず空ファイルになる>
こちらは、直接Sorbetとは関係ないため、アーキテクチャの問題では無いかもしれませんが、色々試しましたが、解決できませんでした。(有識者の方がいらっしゃいましたら、コメントいただければ幸いです!)
【問題1,2の対策】 m1 Mac上で実行する
これらの問題は、DockerのLinuxコンテナには入らず、Mac上で直接コマンドを実行することで回避しました。
srb tc
は主にローカルでの動作確認を目的として実行し、基本的にCI上での実行を前提としているため、大きな問題にはならない見込みです。一方で、tapioca gem
の実行についてはMac上で行うのは若干面倒なため、引き続き調査していくつもりです。
終わりに
以上、tapioca へのマイグレーションと静的チェック(`srb tc`)の導入について書かせていただきました。
特に、プロジェクト途中からSorbetの静的チェックの導入を検討されている方々にとって、何かしらの参考になればと思います。
PharmaX では、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。
もし、興味をお持ちの場合は、私の X アカウント(@hakoten)や記事のコメントにお気軽にメッセージいただけますと幸いです。まずはカジュアルにお話できれば嬉しいです!
PharmaXエンジニアチームのテックブログです。エンジニアメンバーが、PharmaXの事業を通じて得た技術的な知見や、チームマネジメントについての知見を共有します。 PharmaXエンジニアチームやメンバーの雰囲気が分かるような記事は、note(note.com/pharmax)もご覧ください。
Discussion