👻

既にあるRailsプロジェクトにSorbetの静的型チェックを導入しました

2024/02/02に公開

こんにちは。
PharmaX でエンジニアをしている諸岡(@hakoten)です。

この記事の概要

この記事では、次の対応を行ったときのプロセスやハマりポイントなどを書いています。

  • ① sorbet-rails から tapiocaへのマイグレーション
  • ② 既存のRailsアプリケーションにSorbetの静的チェック(srb tc)を導入

この記事を読む前に、そもそもSorbetとtapiocaがどんなものかを知りたい方は、次の記事も一読ください。

https://zenn.dev/pharmax/articles/6e3acb8c10c87c

背景

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へと切り替える方法を紹介します。

この移行作業はそれほど難しくなく、公式からはマイグレーションに関するガイドが提供されています。移行を検討されている方は、ぜひ以下の公式ドキュメントをご一読ください。

https://github.com/Shopify/tapioca/wiki/Migrating-to-Tapioca

「今まで生成されていたRBIファイルを全て削除し、tapiocaを使ってRBIファイルを作成し直す。」というのが基本の流れです。

tapioca のインストール

まずは、sorbet-railsを削除して、tapiocaをインストールします。

Gemfile
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 ディレクトリ配下に置かれます。

② 既存のRailsアプリケーションにSorbetの静的チェック(srb tc)を導入する

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の不適切な定義に関連していたようです。

3. typed: ignoreのsigilをtyped: falseに変更

残りのエラーを確認すると、以下のようなエラーが多く見られました。

app/models/insurance_card.rb:3: Unable to resolve constant ApplicationRecord https://srb.help/5002
     3 |class InsuranceCard < ApplicationRecord

これは、ApplicationRecordが型として認識されないためのエラーです。ApplicationRecordのファイルを調べると sigiltyped: 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まで減らすことができました!

4. tapioca gemで作成されなかったRBIをrequire.rbに追加してもう一度作成

残るエラーとしては、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に書き出されないこともあるようです。

https://github.com/Shopify/tapioca?tab=readme-ov-file#manually-requiring-parts-of-a-gem

この場合は、 tapioca init 時に作成された sorbet/tapioca/require.rb ファイルに個別にrequireを書くことで解決できます。

Google::Cloud::Storageの場合、次のように requireを追加します。

sorbet/tapioca/require.rb
# 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個になりました。

5. specディレクトリをsrb tcの対象から外す

エラーの中には、RSpecに起因する型エラーやテストファイル内でのエラーも含まれていました。現行の運用では、rspecを用いたテストファイルに対して型注釈を施していないため、テストファイルにおける静的チェックは行わない方針で対応しました。

srb tcは対応する設定ファイル(sorbet/config)を持っており、通常このファイルはtapioca initの際に生成されます。

https://sorbet.org/docs/cli#config-file

この設定ファイルを利用することで、srb tcの除外ファイルや対象ディレクトリを指定でき、これらの設定はsrb tcを実行する際に自動的に読み込まれます。

今回、specディレクトリをチェックの対象外とするために、このファイルを下記のように編集しました。

sorbet/config
--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があるようです。

https://zenn.dev/hiko1129/articles/36c122c6610fdd

こちらの記事が大変参考になりました。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環境に対応していないようで、いくつか問題が発生しました。

https://github.com/sorbet/sorbet/issues/4119

【問題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テックブログ

Discussion