STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

orthoses gemを使って型生成を自動化してみた ~RubyKaigi2022 体験記~

こんにちは、22卒の新卒エンジニアとして4月から STORES にjoinしている id:m11o です。

12月1日からスタートした STORES Advent Calendar 2022 も今日で最終日です。

自分が担当した STORES Advent Calendar 2022 1日目の記事 では、RubyKaigi 2022に登壇されたFu-ga (@fugakkbn) さんのセッションに触発されて、Sorcery gemの型情報(RBS)を記述し、 gem_rbs_collection にコントリビュートしたという記事を書きました。

gemの型情報を記述することによって、型情報の少なさを改善するとともにさまざまな恩恵が受けられると感じることができました。しかし、その一方で型を書くのが難しかったり、gemだけではなくプロジェクト内のコードにも型情報を記述する必要があります。さらに、その型情報はコードとは別ファイルで管理する必要があるため、コードと乖離してしまう可能性があると強く感じました。

そこで、本記事では1日目の記事の後編として、普段自分達が記述するプロジェクト内のコードに対して、型情報を常に自動で作成できるようにしてしてみました。

RBSとは

1日目の記事でも解説しましたが、復習としてRBSとはと、個人的にどのような課題があると感じているかを説明します。

まず、RBSを初めて聞く方のために、かんたんに説明すると

Rubyに型情報を記述できる言語

のことです。

def plus(a, b)
  a + b
end

上記のようなplusメソッドがあった場合、RBSは下記のようになります。

def plus: (Numeric a, Numeric b) -> Numeric

コードを見ていただくとわかるとおり、メソッドのシグネチャ部分のみを記述するだけと、とてもシンプルです。

課題

今回のメソッドはシンプルなメソッドだったので型情報もシンプルに記述できました。しかし、ときに複雑なメソッドが書かれていることもあり、その型情報を書こうとすると自分がまだRBSに慣れていないことを差し引いてもかなり難しいと感じています。

さらに、RBSはコードとは別ファイルで記述する必要があります。なので、適切に運用をしていないとコードの実態と型情報が乖離してしまう可能性があると強く感じています。

型生成のエコシステムを作った話

ここで、上記の問題を解消する方法を提案してくれた ksssさん (@ksss) のセッションを紹介します。

speakerdeck.com

このセッションで特に印象に残っていることは、

型がありふれた存在であり、いつでもどこでも使うことができるなら "Happy Hacking" につながる

という言葉です。 型を書くことは、さまざまな恩恵があることを理解しつつメンテナンスコストを考えて導入せずに踏みとどまっていたのでとても共感できました。 ksssさんのセッションでは、 型がありふれた存在であり、いつでもどこでも使うことができる を実現するためのライブラリを開発し、紹介してくれました。 そのフレームワークの名はorthosesです。

github.com

orthosesはrackアーキテクチャに倣って開発されており、middlewareを組み合わせてアプリケーションに必要な型だけを生成できます。

このgemを使用して、Railsアプリケーションに記述したコードのRBSを自動で生成してみたので、その方法を余すことなく共有します。

orthoses gem

設定方法

先ほども述べたとおりrackアーキテクチャに倣って実装されているため、下記のようなイメージで設定していきます。

use Middleware1
use Middleware2
use Middleware3
run -> { /* 実行コード */ }

今回はRailsアプリケーションに導入するので、実行コードの箇所には Rails.application.eager_load! を実行するようにしてみました。 以下は、自分が設定してみたサンプルです。

Orthoses::Builder.new do
  use Orthoses::CreateFileByName,
      base_dir: Rails.root.join('sig/out')
  use Orthoses::Filter do |name, _content|
    !name.to_s.match?(/^Active(Record|Storage|Support)|^Action(Controller|Cable|Dispatch|Mailer|Mailbox|Text)/)
  end
  use Orthoses::LoadRBS, paths: Dir.glob(Rails.root.join('sig', 'out', '**', '*.rbs').to_s)
  use Orthoses::RBSPrototypeRB,
      paths: Dir.glob(Rails.root.join('app', 'models', '**', '*.rb').to_s)
  use Orthoses::ActiveRecord::BelongsTo
  use Orthoses::ActiveRecord::HasMany
  use Orthoses::ActiveRecord::HasOne
  use Orthoses::ActiveRecord::GeneratedAttributeMethods
  use Orthoses::ActiveSupport::ClassAttribute
  use Orthoses::ActiveSupport::MattrAccessor
  use Orthoses::Constant, strict: false
  use Orthoses::Autoload
  run -> { Rails.application.eager_load! }
end.call

他にもさまざまなmiddlewareがあるのですが、今回はモデルのみの型情報を生成したかったので、モデルに特化した設定にしました。

middlewareの説明

下記で設定内容をかんたんに説明します。詳しく知りたい方は、Githubからコードなどを参照してください。

まずは、1行目の Orthoses::Builder.new についてです。これはorthosesのインスタンスを作成し、ブロック内でuseメソッドを使用することでmiddlewareを登録でき、さまざまな設定ができます。

2行目以降は、middlewareを登録しています。今回は自分が使用したmiddlewareのみ紹介しますが、他にもたくさんあるので、ぜひ興味のある方はGithubを覗いてみてください。 middlewareの実行順序は下から順番に実行されていくので、middlewareの説明も下から順番に紹介していきます。

  1. run -> { Rails.application.eager_load! }

    orthoses実行の起点となる処理を記述します。今回はRailsアプリケーションの読み込みを起点にしています。

  2. Orthoses::Autoload

    autoloadしているクラスやモジュールなどをloadします。autoloadはTracePointを拡張したCallTracerで検知します。

  3. Orthoses::Constant

    定数の型情報を追加します。

  4. Orthoses::ActiveSupport::MattrAccessor, Orthoses::ActiveSupport::ClassAttribute

    クラスやモジュールに定義されている cattr_readerやmattr_reader, cattr_accessor, mattr_accessorなどの型情報を追加します。

  5. Orthoses::ActiveRecord::GeneratedAttributeMethods

    モデルにカラムを設定すると動的に定義されるメソッドの型情報を出力します。 例えば、 UserモデルにString型のemailカラムが設定されている場合には、

     def email: () -> String
     def email=: (String) -> String
     def email?: () -> bool
     def email_changed?: () -> bool
     def email_change: () -> [ String?, String? ]
     def email_will_change!: () -> void
     def email_was: () -> String?
     def email_previously_changed?: () -> bool
     def email_previous_change: () -> Array[String?]?
     def email_previously_was: () -> String?
     def email_before_last_save: () -> String?
     def email_change_to_be_saved: () -> Array[String?]?
     def email_in_database: () -> String?
    

    のような型が出力されます。

  6. Orthoses::ActiveRecord::HasOne, Orthoses::ActiveRecord::HasMany, Orthoses::ActiveRecord::BelongsTo

    モデルに定義されているassociationで動的に定義されるメソッドの型情報を出力します。 例えば、Userモデルに has_many :tweets と定義されている場合、

     def tweets: () -> User::ActiveRecord_Associations_CollectionProxy
     def tweets=: (User::ActiveRecord_Associations_CollectionProxy | Array[Tweet]) -> (User::ActiveRecord_Associations_CollectionProxy | Array[Tweet])
     def tweet_ids: () -> Array[Integer]
     def tweet_ids=: (Array[Integer]) -> Array[Integer]
    

    のような型情報が出力されます。

  7. Orthoses::RBSPrototypeRB

    pathオプションで設定したファイルに対して、RBSが標準で用意している rbs prototype rb コマンドを実行して型情報を出力しています。今回は、モデルのみを対象にしているので、app/models配下のrubyファイルにのみ適応しています。

  8. Orthoses::LoadRBS

    既存で設定されているRBSファイルを読み込むことができます。このmiddlewareを追加することによって、すでに設定済みのRBSを上書きすることなく新規で追加されたメソッドのみに型情報を追加できます。

  9. Orthoses::Filter

    自動で生成された型情報のうち、生成する型だけに絞り込むことができます。今回は、Rails関連クラスの型情報は不要なので除外しています。

  10. Orthoses::CreateFileByName

    名前のとおり、取得した型情報をファイルに書き込んでいきます。

また、orthosesの処理をエンジニア側が意識せずに気づいたら型情報が常に追加されている状態にしたかったので、今回は試験的にテスト実行前に必ずorthosesが実行されるように rails_helper 内に記述しました。

これで、テストを実行すれば新規で追加したメソッドは常に型情報が追加できるようになりました。

実演

実際にモデルの型情報を自動で生成するにあたって、少しでも実践的なモデルが良いと思い、簡易的なTwitterのデータモデルを考えてみました。

下記にかんたんなER図を載せています。

ER

早速、Userモデルから実装してみます。

class User < ApplicationRecord
  has_many :followers, class_name: 'Follow', foreign_key: 'followee_id'
  has_many :followees, class_name: 'Follow', foreign_key: 'follower_id'
  has_many :tweets, dependent: :destroy
  has_many :retweets, dependent: :destroy

  validates :email, presence: true, uniqueness: true

  def follow!(user)
    followees.create!(followee: user)
  end
end

まずは、ここでテストを実行してみました。 今回は、RSpecを使用しているので、いつも通りRSpecを実行するコマンドを実行します。

$ bundle exec rspec

すると想定どおりUserモデルのRBSとそれに付随するRBSファイルが作成されました。作成されたファイル構造は下記のとおりです。

root/
  └─ sig/
      └─ out/
          └─ user/
              └─ attribute_methods
                  └─ generated_attribute_methods.rbs
              └─ attribute_methods.rbs
              └─ generated_association_methods.rbs
          ├─ user.rbs

まず、user.rbsはモデル内のfollow!メソッドの型情報が記述されています。また、カラムごとのメソッドやassociationごとのメソッドは別ファイルに定義されているので、それらをincludeする形になっています。

class User < ::ApplicationRecord
  include User::AttributeMethods::GeneratedAttributeMethods
  include User::GeneratedAssociationMethods
  def follow!: (untyped user) -> untyped
end

follow!メソッドに関しては rbs prototype rb で作成しただけなのですべてuntypedになっています。こちらは実装に合わせて型を記述する必要があります。follow!メソッドだけ型を書き換えると下記のようになります。

def follow!: (User user) -> void

さらに user/attribute_methods/generated_attribute_methods.rbs の中身を見てみると、

module User::AttributeMethods::GeneratedAttributeMethods
  def id: () -> Integer
  def id=: (Integer) -> Integer
  def id?: () -> bool
  def id_changed?: () -> bool
  def id_change: () -> [ Integer?, Integer? ]
  def id_will_change!: () -> void
  def id_was: () -> Integer?
  def id_previously_changed?: () -> bool
  def id_previous_change: () -> Array[Integer?]?
  def id_previously_was: () -> Integer?
  def id_before_last_save: () -> Integer?
  def id_change_to_be_saved: () -> Array[Integer?]?
  def id_in_database: () -> Integer?
  def saved_change_to_id: () -> Array[Integer?]?
  def saved_change_to_id?: () -> bool
  def will_save_change_to_id?: () -> bool
  def restore_id!: () -> void
  def clear_id_change: () -> void
  def email: () -> String
  def email=: (String) -> String
  def email?: () -> bool
  def email_changed?: () -> bool
  def email_change: () -> [ String?, String? ]
  def email_will_change!: () -> void
  def email_was: () -> String?
  def email_previously_changed?: () -> bool
  def email_previous_change: () -> Array[String?]?
  def email_previously_was: () -> String?
  def email_before_last_save: () -> String?
  def email_change_to_be_saved: () -> Array[String?]?
  def email_in_database: () -> String?
  def saved_change_to_email: () -> Array[String?]?
  def saved_change_to_email?: () -> bool
  def will_save_change_to_email?: () -> bool
  def restore_email!: () -> void
  def clear_email_change: () -> void
end

カラムを設定した際にRailsが動的に定義するメソッドに対して型情報が設定されています。

また、 user/generated_association_methods.rbs を見てみると、has_manyで設定した際に定義されるメソッドなどにも型情報が定義されました。

module User::GeneratedAssociationMethods
  def followers: () -> User::ActiveRecord_Associations_CollectionProxy
  def followers=: (User::ActiveRecord_Associations_CollectionProxy | Array[Follow]) -> (User::ActiveRecord_Associations_CollectionProxy | Array[Follow])
  def follower_ids: () -> Array[Integer]
  def follower_ids=: (Array[Integer]) -> Array[Integer]
  def followees: () -> User::ActiveRecord_Associations_CollectionProxy
  def followees=: (User::ActiveRecord_Associations_CollectionProxy | Array[Follow]) -> (User::ActiveRecord_Associations_CollectionProxy | Array[Follow])
  def followee_ids: () -> Array[Integer]
  def followee_ids=: (Array[Integer]) -> Array[Integer]
  def tweets: () -> User::ActiveRecord_Associations_CollectionProxy
  def tweets=: (User::ActiveRecord_Associations_CollectionProxy | Array[Tweet]) -> (User::ActiveRecord_Associations_CollectionProxy | Array[Tweet])
  def tweet_ids: () -> Array[Integer]
  def tweet_ids=: (Array[Integer]) -> Array[Integer]
  def retweets: () -> User::ActiveRecord_Associations_CollectionProxy
  def retweets=: (User::ActiveRecord_Associations_CollectionProxy | Array[Retweet]) -> (User::ActiveRecord_Associations_CollectionProxy | Array[Retweet])
  def retweet_ids: () -> Array[Integer]
  def retweet_ids=: (Array[Integer]) -> Array[Integer]
end

テスト実行しただけで型情報を生成できてすごいですね 👏

さらに、他のモデルたちにも型情報を追加してみました。長くなるので詳細はぜひGithubを見ていただきたいのですが、Userモデル同様のRBSファイルが自動で生成されました。

ここでさらにUserモデルにメソッドを追加した際に型情報がどのようになるか確認していきます。

今回は、 User#unfollow! , User#following? , User#followed? メソッドを実装してみました。 メソッドと出力されたRBSは以下のとおりです。

# user.rb
class User < ApplicationRecord
  # ...

  def unfollow!(user)
    return unless following?(user)

    followees.find_by(followee: user).destroy!
  end

  def following?(user)
    followees.exists?(followee: user)
  end

  def followed?(user)
    followers.exists?(follower: user)
  end
end

# user.rbs
def unfollow!: (untyped user) -> (nil | untyped)
def following?: (untyped user) -> untyped
def followed?: (untyped user) -> untyped

ちゃんと早期returnでnilを返している箇所も型情報に反映されていました。 ただ、これでは型情報としては不十分です。なので、以下のように書き換えました。

def unfollow!: (User user) -> void
def following?: (User user) -> bool
def followed?: (User user) -> bool

メソッドを追加しても自動で型情報を追加してくれて、これで型情報の記述漏れがなくなりそうです。

今回、記事の中では断片的にしかコードを示すことができませんでした。もし、詳しくコードを読んでみたい方は下記Githubを覗いてみてください。

github.com

感想

いかがだったでしょうか。 orthoses gemを使用することによって、エンジニアが型情報を意識することなく、自動でRBSを生成できました。これによって、別ファイルに定義する必要があり、型情報のメンテナンスコストを下げることができるのではないでしょうか。 RBSにメリットがあると思いつつ、別ファイルに記述することが面倒で書くことを避けてきた自分にとっては、とても感動的な体験でした。

orthosesがとても便利で、革新的な技術であり、これからのRuby開発を変えうるフレームワークなのではないかと個人的には感じました。

とは、言いつつ、良いところばかりではないとも感じています。以下にデメリットや機能として不足していると感じた点を挙げていきます。

middlewareを組み合わせるのが難解すぎる

本当にmiddlewareの組み合わせは難しかったです。ドキュメントや実装例などがまだまだ充実していないのも原因の1つだとは思うのですが、同じmiddlewareでも並び順が違うとエラーになったり、全く別のrbsファイルが作成されてしまうなど、結構悩まされました。最終的には全部のmiddlewareのコードを読んで自分の目的としたファイルが生成されるように調整しました。一度エコシステムが完成してしまえば、そこまで修正することは無くなると思うのですが、少しでも修正する際は現時点ではコードを読みながらmiddlewareを組み合わせる必要があるので、要改善だなと感じました。

ただ、orthosesを使って今回のエコシステムを構築した際にさまざまな知見やバグを見つけたので、IssueやPull Requestをあげてcontributeしていきたいです。

メソッドを削除したときにRBSは削除されない

本記事ではメソッドを追加した際にRBSが追加されることを確認しました。しかし、実はメソッドを削除した時には型情報は削除されませんでした。これは、型情報を本当の意味で自動化するためには必要な機能かなと思いました。

もしかしたら、別のmiddlewareを組み合わせたり、並びを変えることによって実現できるのかもしれないですが、自分がコードを読んだ限りではわかりませんでした。 ( もしご存じの方がいたら教えてください🙏 )

基本的に最初は全てuntyped

これは2つ目と同様、デメリットというより必要だなと思った機能です。

今回、orthosesでは Orthoses::RBSPrototypeRB middlewareを使用しているので、 内部で rbs prototype rb を実行して型情報を生成しています。rbs prototype rbは型の雛形を生成するだけなので、基本的には untyped な型で生成されてしまいます。しかし、型推論ができてそれを自動生成してくれるとさらに型を書かなくても型の恩恵を受けられるのではないかと感じました。セッション内でも述べられていましたが、TypeProfをmiddlewareとしてエコシステム内に組み込むことができれば型推論した型情報が出力できるのかなとワクワクしました。

(自分がいろいろ試している間に、最近TypeProfをmiddlewareとして組み込むための開発が始まったようです。watchしつつ試していきたいです)

github.com

最後に

前編と本記事を通して、RBSについて取り上げてきました。

前編では型が記述されている既存のgemの割合が 約0.025% であることからSorcery gemの型を記述し、gem_rbs_collectionにcontributeしてみました。ただ、課題として自分達が記述するコードにも型情報がないと本当の意味で型の恩恵を受けることができないが、RBSはコードとは別ファイルに記述する必要があるためメンテナンスコストがとても高いと感じました。

それを踏まえた上で、本記事はorthosesを使用することによって型情報を生成するエコシステムを作成し、テスト実行時に常に不足している型情報を追加できるようになりました。まだまだ、改善できる点はあると感じましたが、Ruby開発を変え、より良くできる技術なのではないかと強く感じました。

今後は、前編でも述べたとおり既存のgemの型情報を増やすためにcontributeしつつ、自分達が普段使用しているprojectでもorthosesを導入することで、型情報が常にある開発環境を実現していきたいです。また、それらの知見をorthosesに還元してRuby全体で型情報を用いた開発がより活発化できることを目指していきます。

参照

orthoses gem

rbs cli

orthoses-typeprof gem