メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

Railsプロジェクトへの「頑張らない型導入」のすすめ

こんにちは。サーバーサイドエンジニアの三村(@t_mimura)です。

主に保険薬局と患者さまを繋ぐ「かかりつけ薬局」化支援アプリ kakariのサーバーサイド開発(Ruby on Rails)を担当しています。

突然ですが!
この度kakariプロジェクトは「型導入」をしました!

kakariのRailsリポジトリに型導入PRがマージされた様子

皆さんのプロジェクトは「型導入」していますか?
「型導入」しているRailsプロジェクトはまだ少ないのではないでしょうか

なぜ型導入しないのか

型を導入すると何かしらが便利になることは分かっているのに何故やらないのでしょうか(煽り気味)

「型の恩恵」と「型を自分たちで書くコスト」の2点を比較していませんか?
RubyKaigi 2023開催前の私がまさしくそう考えていました。
本当にその2点を比較するべきなのかをここで再考してみましょう。

「型導入」とは何か

まず「型導入」とは何を指すのかを整理するために、「型定義」と「型活用」で分けて考えます。

「型定義」について

ここではRuby公式のRBSについて考えます。

github.com

Railsプロジェクトにおいての「型(RBS)定義」の方法は主に5種類あります。

  • ruby/gem_rbs_collectionや各Gemのリポジトリに定義されている型を取得
  • pocke/rbs_railsなどのツールによる型の自動生成
  • typeprofによる型の静的解析
  • rbs prototypeによる型のプロトタイプの自動生成
  • 自分で型を書く

※ 各ツールの詳細はここでは割愛します。

他にもYARDからRBSを生成するツールなんかもあるようですが、現時点で主流なのは上記の5つだと思います。

ここで分かることは「自分で型を書く」以外にも型定義の方法があるという点です。

「型活用」について

次に「型活用」について考えます。

RBSは型が定義されたファイルでしかないため、それを何らかの形で活用しないと意味がありません。

現時点で私が観測できた型の活用方法は以下の3つです。

  • Steepを利用した型検査
  • コーディング時の補完強化
  • KatakataIrbによるirb補完強化

一つずつ深掘りしていきましょう。

Steepを利用した型検査

github.com

「型導入」という言葉を聞いて一番最初に思い浮かべるのはこれではないでしょうか。 型検査による利点は知り尽くされているためここでは省略します。

Railsアプリケーションで型検査を意味あるレベルで運用するにはいくつかのハードルがあります。

まずは、Railsアプリケーションコードに対応する型を手動で定義する必要がある点です。
rbs prototypeを活用しプロトタイプな型の自動生成はできますが、「意味あるレベルの型検査」となると手動での型定義は避けられない現状です。 当たり前ですがこの型定義は「型導入」初回の一度きりではなく、アプリケーションコードの変更の都度更新する必要があります。

「型定義・型検査はそもそもそういうものだから今更何を言っているのか」と厳しいご指摘もあるかと思います。

そこで二つ目のハードルとして、この手動での型定義作業をチームメンバーに強制する点が挙げられます。
一人プロジェクトならまだしも、我々はチームで一つのRailsプロジェクトを開発しているため独断で決めるわけにはいきませんね。
ただ作業を強制するだけでなく「型を定義し活用し続ける文化」を今まで型と無縁だったRubyエンジニアに浸透させるのは簡単な話ではないでしょう。

ちなみに、偉そうに語っている私は自分で型を書きたくありません

つらつらと書きましたが、現時点でRailsプロジェクトで「Steepを利用した型検査」を運用するのはコストが高いと考える人は多いかと思います。

※ 「modelsなど特定のディレクトリのみ型検査対象にする」「型検査はするがCIには組み込まず、エラーも無視して良い体制にする」など折衷案はありますが、今回の話の本質ではないため割愛します。

コーディング時の補完強化

上述のSteepにはLSPを提供する機能があるため、これを補完強化に活用することができます。 他にもRubyのLSPとして有名なsolargraphについてもRBSをサポートしていくと明言しています。

KatakataIrbによるirb補完強化

github.com

KatakataIrbとは型定義を活用することでirbの補完を強化するツールです。 作者のtompngさんによる記事もここで紹介します。 qiita.com

Rubykaigi 2023でも紹介されましたね。 rubykaigi.org

本セッションを聞いたことにより「Steepを利用した型検査」以外の活用方法について意識が向くようになり、kakariプロジェクトの型導入と本ブログの執筆が始まりました。


まだまだRBSの活用方法は多くはありませんが、導入ハードルが高い「Steepを利用した型検査」以外にも活用方法があることが分かります。

また、「コーディング時の補完強化」と「KatakataIrbによるirb補完強化」に関しては、一部のクラス・メソッドの型が定義されているだけでその分恩恵を受けることができます。

つまり「型導入」とは

「型定義」と「型活用」を整理してみました。

「自分で型を書く」以外の「型定義」 + 「Steepを利用した型検査」以外の「型活用」 を採用する選択肢が見えてきました。

この形も立派な「型導入」だと提唱します。
今までの「型を自分で書きたくないから型導入しない」という考えから、「これならちょっとお試しで型導入してみようかな」という気持ちになった人もいるのではないでしょうか。

そんな方のためにちょっとお試しで型導入するための手順を以下に記します。
※ 本ブログ執筆(2023/05/29)時点の情報です。RBS周りは進化が早いため常に最新の情報を参照することをオススメします。

お試し型導入の流れ

ここでは実際にkakariプロジェクトに「型導入」した際の手順をベースに流れを記します。 各作業の詳細については、それぞれの公式ドキュメントなどを参照してください。

0. KatakataIrbの導入

下準備としてKatakataIrbを導入します。

KatakataIrbは「型を活用する」と前述しましたが、型定義がなくともirb補完が強化されるため型に興味がない方にもオススメします。

以下の記事が詳しいためこちらを参照ください。 www.timedia.co.jp

1. RBS Gem導入

ここからが「型導入」です。
まずはともかくRBS GemをRailsプロジェクトに導入します。

gem 'rbs', require: false

production環境では不要なためgroupの指定や require: false をお忘れなく。

2. RBS Collectionのセットアップ

次に以下のコマンドでGemの型定義をインストールします。

$ rbs collection init # https://github.com/ruby/gem_rbs_collection に登録されているgemの型定義を利用するための初期設定
$ rbs collection install # gem_rbs_collectionの型定義を取得

git ignore対象への追加を忘れずに

# .gitignore
/.gem_rbs_collection/

# 型検査を真面目に活用する場合は型定義のバージョンもGit管理下にするべき
# 現時点では型定義のバージョン管理するほど真面目に型を活用していない
rbs_collection.lock.yaml

今回kakariプロジェクトでは rbs_collection.lock.yaml をGit管理下にしない方針にしました。 メンバー間で型のバージョンを揃える必要性は低く、それ以上に「型バージョン更新が無関係のコミットに混ざる」「型バージョン更新だけのPullRequest」が現状ノイズに感じられるためです。

DependabotでのGem更新PRで rbs_collection.lock.yaml も更新してくれる未来が来ると話が変わってきそうですね。

kakariプロジェクトの rbs_collection.yaml が以下です。
meta-tags の型定義が破損しているためスキップしています。 gem_rbs_collectionではなく各Gemのリポジトリに型が定義されているとこういう問題もあるようですね。

# Download sources
sources:
  - type: git
    name: ruby/gem_rbs_collection
    remote: https://github.com/ruby/gem_rbs_collection.git
    revision: main
    repo_dir: gems

# You can specify local directories as sources also.
# - type: local
#   path: path/to/your/local/repository

# A directory to install the downloaded RBSs
path: .gem_rbs_collection

gems:
  # Skip loading rbs gem's RBS.
  # It's unnecessary if you don't use rbs as a library.
  - name: rbs
    ignore: true

  # 型情報が破損しているためスキップする
  # https://github.com/kpumuk/meta-tags/issues/253
  - name: meta-tags
    ignore: true

ここまでの恩恵

ActiveSupportが提供しているメソッドの型定義がKatakataIrbの補完で活用される様子(Before)

Before

After

3. RBS Railsの導入

ここまでででも型の恩恵は得られていますが、更に便利にするためにRBS Railsを導入しましょう。

導入手順はシンプルなので公式READMEをご参照ください。

こちらのPRで対応されていますが、 bin/rails g rbs_rails:install で生成されたrake taskがproduction環境で読み込まれないように考慮する必要がある点にご注意ください。

rbs_rails:all タスクを実行することでsig/rbs_rails配下にActiveRecordが自動生成するメソッドの型情報が出力されます。
それにより以下のように補完が強化されます。

ActiveRecordが自動定義している「アカウントテーブルのemailカラムに関するメソッド」の型定義がKatakataIrbの補完で活用される様子

kakariプロジェクトでは現在sig配下をgit ignoreとしています。「型は自動生成のみ」に振り切っての型導入としているためバージョン管理は不要という判断です。
手動での型定義の運用方法が定まってきたらこの辺の設定は変更すると思います。

4. Steepの導入

一旦型定義についてはここまでとし、型活用に目を向けましょう。 Steepを導入することでコーディング時の型補完が強力になります。

Steep Gemの導入手順もシンプルなので公式READMEをご参照ください。

kakariプロジェクトでは「型検査はしない」と割り切ったためSteepfileは以下の形で利用しています。

D = Steep::Diagnostic
target :app do
  signature 'sig'

  check 'app'

  # 型検査はせずに補完強化用途でのみSteepを利用する
  configure_code_diagnostics do |hash|
    D::Ruby::ALL.each do |error|
      hash[error] = nil
    end
  end

  # https://github.com/soutaro/steep/pull/800 の対応がリリースされたら↓に書き換えること
  # configure_code_diagnostics(D::Ruby.silent)
end

Steepが提供しているLSPを活用する手順はご利用のテキストエディタによって変わります。以下は一例です。

また、RubyMineに関してはSteepのLSPを必要とせずに自前でRBSを参照して便利にしているようです。

VSCodeのSteep拡張を導入して、それとなく補完が強化されている様子

NeoVimにcoc.nvimとSteep LSPを組み込んで補完が強化されている様子

5. 便利Rake Taskを定義

最後に仕上げで日々の運用上便利なRake Taskを定義します。 と言っても、pockeさんがRubyKaigi 2023で発表していたRake Taskをありがたく流用させていただきました。
後述しますが、一部アレンジをしています。

return unless Rails.env.development?

require 'rbs_rails/rake_task'

namespace :rbs do
  task setup: %i[clean collection rbs_rails:all]
  # prototype+subtractを活用したいところだが、自前の型定義が必要になるため保留中
  # task setup: %i[clean collection prototype rbs_rails:all subtract]

  task :clean do
    sh 'rm', '-rf', 'sig/rbs_rails/'
    sh 'rm', '-rf', 'sig/prototype/'
    sh 'rm', '-rf', '.gem_rbs_collection/'
  end

  task :collection do
    # lockファイルに定義されているバージョンに従わず最新の方情報を取得したいためinstallではなくupdateを利用している
    sh 'rbs', 'collection', 'update'
  end

  task :prototype do
    sh 'rbs', 'prototype', 'rb', '--out-dir=sig/prototype', '--base-dir=.', 'app'
  end

  task :subtract do
    sh 'rbs', 'subtract', '--write', 'sig/prototype', 'sig/rbs_rails'

    prototype_path = Rails.root.join('sig/prototype')
    rbs_rails_path = Rails.root.join('sig/rbs_rails')
    subtrahends = Rails.root.glob('sig/*')
                       .reject { |path| path == prototype_path || path == rbs_rails_path }
                       .map { |path| "--subtrahend=#{path}" }
    sh 'rbs', 'subtract', '--write', 'sig/prototype', 'sig/rbs_rails', *subtrahends
  end

  task :validate do
    sh 'rbs', '-Isig', 'validate', '--silent'
  end
end

RbsRails::RakeTask.new do |task|
  # If you want to avoid generating RBS for some classes, comment in it.
  # default: nil
  #
  # task.ignore_model_if = -> (klass) { klass == MyClass }

  # If you want to change the rake task namespace, comment in it.
  # default: :rbs_rails
  # task.name = :cool_rbs_rails

  # If you want to change where RBS Rails writes RBSs into, comment in it.
  # default: Rails.root / 'sig/rbs_rails'
  # task.signature_root_dir = Rails.root / 'my_sig/rbs_rails'
end

kakariプロジェクトは現在ここまで対応しています。 いかがでしょうか、立派に「型導入」しているように見えます?が一つも自分で型を書いていません

すごい勢いでRBS周りのエコシステムが便利になってきているおかげで、自分で型を書かずとも型の恩恵を受けることができるようになっています。ありがたい!

また、本導入方法のアピールポイントとして「型に興味がある人は型を活用でき、型に興味がない人にデメリットがない状態」となっていることが挙げられます。 このレベルの型導入であれば負債になりませんし方針転換も容易です。

「型導入をしない理由」を考えるまでもなく「とりあえず型導入してみる」と気軽に試せるのではないでしょうか。

今後の展望

ここまで自慢げに型導入について解説しましたが、正直なところまだまだ入り口段階だと思っています。

型に対するチームの成熟度に沿って以下の取り組みについて継続してチャレンジしていきたいと考えています。

「rbs prototype( + subtract)」を活用して、独自クラス・メソッドの型のプロトタイプを自動生成

rbs prototypeにより、クラスやメソッドのI/F(引数情報なども含め)が定義されている型のプロトタイプを生成することで、上述の補完が更に便利になることが予想できます。

当初はここまで対応しようと考えていましたが、prototypeで生成した型が不十分でSteepでエラーが発生し今回は見送りました。 実際のところこのエラー自体は一つ一つ解消すれば良いだけの話なのですが、「クラスやメソッド更新の都度rbs prototypeとsubtractを実行し、エラーが発生した場合は対処する」というフローを現段階のチームに組み込むのはハードルが高く感じられました。

僕自身RBS周りはまだ未熟なので過剰にビビっているだけかもしれませんが、今回は「意味のある形で、尚且つ普段の開発速度が低下することなく型を導入する」を目標にしていたため今回は見送ったという経緯です。

typeprofの活用

typeprofを活用するとrbs prototypeより詳細な型情報が自動生成されるため更なる型補完強化が望めます。

解析速度について現状(v1)は課題がありますが、v2で大きく改善されるとRubyKaigi 2023で発表されましね。とても楽しみです!

正直のところまだほとんど触れられていないため、Railsプロジェクトでの解析精度がどの程度か不明な状況です。 型の目的を「型検査」ではなく「補完強化」に振り切っている現状は精度が完璧である必要はないため十分に実用段階かもしれません。

積極的に活用していこうと思います!

Gemの型定義追加を含むOSS貢献

ここまではひたすらRBS周辺OSSの恩恵を頂いているだけなので、私たちもRBS周辺の成長に貢献していくべきです。 gem_rbs_collectionの型定義更新やRBS,Steepなどのツール群への貢献は然り、 RBS Railsのような型自動生成ツールの開発にも興味があるのでチャンレンジしていきたいところです。 (ActiveHash の型定義なんかもうまいこと自動生成したいですよね。)

また、今回のRBS導入とブログ執筆の最中にRBSの些細な不具合を見つけたのでIssue起票しました。

github.com

github.com

このような不具合だけでなくRBS導入のハードルを下げるための提案なども積極的にしていきたいと思っています。
まだまだRBS周辺は貢献できる余地が多く敷居が低い領域なので「OSS貢献」の文化をチームに浸透させる良い機会として精進していきます!

まとめ

以上、型導入の敷居を下げるための記事でした。

「型定義なし」と「型検査バリバリ組み込んでいる」の間の選択について理解し、「ちょっと弊プロジェクトにも型導入してみようかな」と思えてくれたら幸いです。

小さく型導入してみて、チームが型に馴染んできたらもう一歩踏み込んでいく

そんな形で型を徐々に浸透していけると良いのではないでしょうか。

参考記事

本ブログを執筆する際に多くの記事を参考にさせていただきました。 紹介しきれませんが一部列挙します。


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。
ご応募をお待ちしております!

■募集ポジションはこちら

medpeer.co.jp

■エンジニア紹介ページはこちら

engineer.medpeer.co.jp