RBSをCIに導入して型の恩恵を得たい

こんにちは,相生ゆら(@Little_Rubyist)です.最近配信でクロノトリガーをやっているんですがあまりにも面白すぎてありとあらゆる知人に「クロノトリガーをやれ」と言っています.

さて,入社から半年以上経過しましたがその話はまた別でやるとして,私の所属している部署では勉強習慣というのがあって,自分の興味のあることを業務時間中に勉強してもいい時間が存在します.各々色んなことをやっていますが,私はその時間で少しずつRBSを導入しています.まだあまり触れていないプロジェクトなのでコードリーディングをしながらゆっくり入れていこうと思っています.

CI導入まで済んだのでこれを一区切りとし,どういったことをやっていたのかをご紹介します.

型導入の準備

とってもシンプルです.steep, rbs_rails, katakata_irbをGemfileに追加します.ライブラリの型も必要なので,rbs collectionを用いてgem_rbs_collectionを追加します.

katakata_irbはproduction環境のrails consoleを早くしたいという意図もあるのでproductionでも使えるように設定します.

(2023-10-05追記)元々irbに対して補完メソッドの読み込みが重たいなという感想を抱いており,katakata_irbが型を見てくれることで補完の読み込みが早くなる(特にHoge..と誤字してしまった時など)効果がありました. プロダクトコードに型を定義していない状態で導入してもgemの型や標準ライブラリの型がそれなりにあるので,早期にrbs導入の恩恵を得られることも大きなメリットです.

ちなみに,rbsは3系からは標準で入っているので steepなどにより依存関係として自動でインストールされるので*1,Gemfileに書く必要はありません.

# Gemfile

gem 'katakata_irb'
group :development, :test do
  gem 'rbs_rails', require: false
  gem 'typeprof', require: false
  gem 'steep', require: false
end

gem_rbs_collectionはGem本体のリポジトリに型が書かれていないGemのためのリポジトリです.Gemfileから依存関係などを解析して,各Gemに対応したRBSファイルがあればそれを手元にダウンロードします.

まずはrbs_collectionの設定ファイルを用意して,そのままRBSファイルをダウンロードします.

$ rbs collection init
$ rbs collection install

RBSファイルは path:で設定された場所にダウンロードされます.ダウンロードされるファイルは沢山あるのでgitから除外しておきます.

$ echo /.gem_rbs_collection/ >> .gitignore

基本は特に編集せずにrbs collection installを行います.Gemなどで依存している標準ライブラリは現状検知できないので,そういったライブラリは rbs_collection.yamlに記載していくことになります.

# 例
# rbs_collection.yaml

gems:
  - name: erb

この時点でライブラリの型付けは概ね完了になります.用意されていないものについては自身で gem_rbs_collectionにcontributeしたり,手元に秘伝の型*2を用意する必要があります.

自動生成出来る型は自動生成する

自動生成の手段はいくつかあります.rbsに用意されているprototypeやtypeprof, railsアプリならrbs_railsを用いるとよいでしょう.*3

今回は少しずつ入れたいのでrbs_railsはmodelだけ*4,prototypeもmodel層のみを対象に実行します

$ bin/rails g rbs_rails:install
$ rails rbs_rails:generate_rbs_for_models

今回のプロジェクトにはcomposite_primary_keysが入っており素直に実行できなかったのでモンキーパッチを当てています.

どうせモンキーパッチを当てるならと,models層に書かれた attributesが自動生成出来るように追加でパッチを書きました.*5

prototypeではsig/protorype以下に app/models/...を生成します.

$ bundle exec rbs prototype rb --out-dir=sig/prototype --base-dir=app app/models

その他にもgemの中で自動生成できそうなものがあればrbs_railsに相乗りすることで生成が可能なので少しだけ生成しました.*6

rbs validateを通す

rbsには正しく記述されているか確認するコマンドがあります.「正しい記述」には呼び出されているのに定義のないmoduleやclassなども含むのでそういったものを追記していきます.

おかしなコードを書いていると偶におかしな型が生成されるので,そういったものは手で修正をします.

例えば

# rubyの予約語と衝突するのを避けるためにおかしなことをしているコード
alias original_hash hash
alias hash original_hash

がプロダクトコードにかかれていると,型も

alias original_hash hash
alias hash original_hash

になってしまいます.しかし,これだと Circular method alias is detectedと怒られてしまうので(当たり前)

def original_hash: () -> untyped
def hash: () -> untyped

にして誤魔化します.必要であれば後からuntypedな部分は修正します.

rbs validateが通ると何も対象に取っていないsteep checkが通るので,CIに組み込むことが可能になります.

CIに組み込む

ここではCircleCIを採用しているので,以下のようなconfigを書いて任意のworkflowにsteep checkを組み込みましょう.

steep_check:
    executor: ruby
    steps:
      - run:
          name: configure bundler
          command: |
            gem install bundler
      - run:
          name: rbs collection install
          command: |
            bundle exec rbs collection install
      - run:
          name: steep check
          command: |
            # steepはjob数とvCPUを一致させる必要がある
            # なので、CircleCIの`resource_class`(vCPU数)に応じてジョブ数を変更させる
            VCPU_NUM=`echo $(($(cat /sys/fs/cgroup/cpu/cpu.shares) / 1024))`
            bundle exec steep check --jobs=$VCPU_NUM

gem_rbs_collectionはgitignoreしているのでCIの中でも忘れずにinstallする必要があります.

また,コメントにあるようにjob数をvCPUの数と一致させる必要があります.これはずっとCIが通らなくて,諦めてruby-jpで相談して教えていただきました.vCPU数よりjob数が大きくなってしまうとThreadがエラーを吐いてタスクが終わらなくなるようです.

job数を指定しない時に何故そういう事が起こってしまうかというと,steepはConcurrent.physical_processor_countを使ってデフォルトのjob数を計算しているようで,手元で実際に確認したところvCPU数が2に対してConcurrent.physical_processor_countは18で乖離していました.

ローカル環境ではどちらも2だったので乖離は発生せず,steep checkも問題なく動いたのでCIなどのコンテナ環境でのみ起こるものだと思われます.

他の方の話では「vCPU数を超えた値を --job で指定すると ThreadのErrorが起きた記憶があります。」とのことだったので,そのような症状の場合にも同じ対応で解消するかもしれません.

ちなみに,この件を相談した時にsteep開発者であるsoutaroさんが対応を考えてくれていたので,近い将来にこの問題は根本的に解決する可能性があります.

(2023-10-05追記)今回の記事を報告した際に対応をしていただきました!内容としては「環境変数に CI があった時にjobのデフォルト数を2にする」というものです.soutaroさんありがとうございます… :pray:

github.com

まとめ

最終的にはruby-jpの皆さんを頼りましたが,何はともあれ無事にRBSを用いたRubyの型チェックをCIに導入することが出来ました.Rubyは動的型付けの言語ですが,型による補助で安全なコードがもっと沢山書けるといいなと思っています.これから色んなメソッドに型を付けていく作業が待っているんですが,それは少しずつやっていこうと思います.

最後に

我々は新しいことに一緒に挑戦してくれるようなエンジニアを歓迎しています.新しくないことをやってくれるエンジニアも歓迎しています.興味のある方は下記採用ページからご連絡ください!*7

www.bookwalker.co.jp

*1:2023-10-05追記

*2:秘伝の型を簡単にcontributeする手段も用意されています

*3:参考: https://pocke.hatenablog.com/entry/2020/12/18/230235

*4:他の場所への依存が少ないのでmodel層の定義だけで済むことが多いです

*5:結構色々いじっていて長くなるので別の機会に話します

*6:今回はInum gemの一部を生成しています

*7:やんわり話を聞きたい場合は私のX(旧Twitter)でも大丈夫です!