STORES Product Blog

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

Sorcery gem の 型情報を書いて gem_rbs_collection にコントリビュートしました 🎉 ~ RubyKaigi2022 体験記 ~

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

さて、 STORES Advent Calendar 2022 の1日目です。本記事では9月上旬に開催されたRubyKaigiにオフラインで参加し、様々な体験と大きな学びがあったので、その一部を自分の感想を交えながら共有します。

RubyKaigiには STORES からはオフライン10名、オンライン6名が参加しました。新卒の我々にも無条件に参加させていただけて、大変貴重な体験でした。

オレオレRubyKaigi 2022の3大トピック

今回RubyKaigiに参加自体が初めての参加でした。そんなワクワクドキドキのRubyKaigi2022では、特に自分が気になったトピックは以下の3つでした。

3つとも非常に興味深いトピックでたくさん学びはあった(3つのトピック以外にももちろんいっぱい学びはあったよ)のですが、今回自分はRBSについて取り上げます。

RBSの課題

まず、RBSをご存じない方のために、かんたんに説明すると、

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

のことです。

個人的な意見として開発時において型安全であることは基本的には理想だと考えており、Ruby 3.0でRBSが登場した際に胸を躍らせたことを今でも覚えています。しかし、個人的にはRBSはまだそこまで普及していないと感じています。

あまり普及していないと考える最大の理由は 型情報を記述しているgemが圧倒的に少ない からだと考えています。

業務ではRuby on Railsを使用して実装しているため、自分達が書くコードとは別に大量のgemを内部で使用して実装しています。それらのgemに型情報が記述されていないため、いくら自分達のプロジェクトで型情報を書いてもその恩恵は一部でしか受けることができません。下記で紹介するセッションでも取り上げられているのですが、現在型情報が記述されているgemの割合は 約0.025 % なのだそうです。

本記事では上記の課題に真っ向から立ち向かい、その取り組みを紹介してくれたセッションについて取り上げます。

Gemの型を書こうよって話

まず、1つ目のセッションとして紹介したいのが、Fu-ga (@fugakkbn)さんの Types teaches success, what will we do? です。

rubykaigi.org

スライドはこちら

speakerdeck.com

このセッションでは、型情報が記述されているgemが少ないことを紹介した上で、gem_rbs_collection というgemの型情報をまとめているgemに対してcontributeする方法を紹介してます。

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

gem内のすべてのAPIの型を登録することはとてつもなく大変。なので、自分が使用しているAPIの型だけでOK。足りない部分は必要になった人がきっと追加してくれる 👍

という言葉です。

gem_rbs_collectionというgemの存在自体は知っていましたが、すべてのメソッドを登録する必要があると思いこみ、勝手に足踏みしていました。

なので、今回Fu-gaさんのアドバイスをもとにSorceryというgemの型情報を記述からmerge までの自分が歩んだ軌跡を余すことなく共有します。

Sorcery gemに型情報を書いてみた

まず、Sorceryというgemは、ユーザー認証をシンプルに実装できるgemです。

認証と言えば device が有名ですが、deviseは機能が多いゆえに複雑性が増しているので試しに型情報を記述するには不向きであると考えました。また、Sorceryはコードがスッキリと記述されているので個人的に好きなgemの1つなので型情報を書いて少しでも貢献したいと思い、このgemにしました。

早速スライドやgem_rbs_collectionのREADMEに記述されている方法で環境構築をして、型情報を記述してみました。

ステップは大きく4つです。

  1. 環境構築
  2. テスト記述
  3. RBSによる型定義
  4. PR作成

最初のステップの環境構築は、gem_rbs_collectionのCONTRIBUTING.md が最も詳しいのでそちらにお任せして、以下よりRBSによる型情報の記述について書いていきます。

Fu-gaさんもおっしゃっているとおり必要なメソッドだけ記述すれば良いので、今回はSorceryを使用する上で最も使用する下記の9個のメソッドの型情報を書いてみました。

require_login
login(email, password, remember_me = false)
auto_login(user)
logout
logged_in
current_user
redirect_back_or_to
User.authenticates_with_sorcery!
User.authenticate

Sorceryがシンプルなgemであることから、名前からなんとなく何をするメソッドかはお分かりいただけると思います(もし詳しい処理を知りたい方はこちらを見てみてください)

まずは、テストを書いてみました。

テストといってもRSpecやminitestのようにテストケースを書いて、期待値などを書いていくわけではなく、アプリケーション上で普段使用しているとおりにコードを書いていきます。

require "sorcery"

class User
  extend Sorcery::Model
  authenticates_with_sorcery!
end

User.authenticate('hoge@example.com', 'password')

class TestController
  include Sorcery::Controller
end

controller = TestController.new
controller.require_login
controller.login('hoge@example.com', 'password')
controller.login('hoge@example.com', 'password') { |user| user }
controller.current_user
controller.logout

user = User.new
controller.auto_login(user)

controller.redirect_back_or_to('/')
controller.redirect_back_or_to('/', { notice: 'success' })

また、テスト用に用意したクラスがどのような型を持つのかも定義します。

class User
  extend Sorcery::Model
  extend Sorcery::Model::ClassMethods[User]
end

class TestController
  include Sorcery::Controller
  include Sorcery::Controller::InstanceMethods[User]
end

こうすることによって、各クラスがどのような型情報を保持しているかを記述できます。Railsで言うとUserがApplicationRecordを継承したモデル、TestControllerがApplicationControllerを継承したコントローラーをイメージして記述しています。

その上で、各メソッドのRBSも記述してみました。

module Sorcery
  module Model
    def authenticates_with_sorcery!: () -> void

    module ClassMethods[T]
      def authenticate: (*String credentials) ?{ () -> untyped } -> (T | nil)
    end
  end

  module Controller
    module InstanceMethods[T]
      def require_login: () -> (nil | void)
      def login: (*String credentials) ?{ (T, nil) -> untyped } -> (T | nil | untyped)

      def logged_in?: () -> bool
      def current_user: () -> (T | nil)
      def logout: () -> (nil | void)
      def auto_login: (T, ?bool) -> T
      def redirect_back_or_to: (String url, ?Hash[(String | Symbol), String] flash_hash) -> (nil | void)
    end
  end
end

見た目はパッと見はRubyのコードですが、signatureしか書いていないことがわかります。RBS自体はsyntax.rbを参考にしながら記述し、特につまずくことなく、このときは書けたなと思いました。

ここから、テストが通るか確認します。

テストには環境構築時に作成される _scripts/test コマンドを使用します。このコマンドは内部でsteep checkを実行してくれて、型の静的解析をしてくれます。

$ cd /path/to/gems/_scripts
$ ./test

# Set RBS_DIR variable to change directory to execute type checks using `steep check`
RBS_DIR=$(cd $(dirname $0)/..; pwd)
cd $(dirname $0)/..; pwd
dirname $0
# Set REPO_DIR variable to validate RBS files added to the corresponding folder
REPO_DIR=$(cd $(dirname $0)/../../..; pwd)
cd $(dirname $0)/../../..; pwd
dirname $0
# Validate RBS files, using the bundler environment present
bundle exec rbs --repo $REPO_DIR -r sorcery:0.16 validate --silent

cd ${RBS_DIR}/_test
# Run type checks
bundle exec steep check
# Type checking files:

....................................................................

No type error detected. 🫖

無事、テストがとおりました 🎉

無事テストが通ったのでgem_rbs_collectionに対してPRを作りました。以下リンクが作成したPRです。

github.com

gem_rbs_collection 作者のpockeさんに色々なレビューをいただいた後、mergeしていただけました。

最終的なSorceryの型情報は以下のとおりです

module Sorcery
  type failure_reason = :invalid_login | :invalid_password | :inactive

  module Model
    def authenticates_with_sorcery!: () -> void

    module ClassMethods
      def authenticate: (*String credentials) ?{ (instance, (failure_reason | untyped)) -> untyped } -> (instance | nil)
    end
  end

  module Controller
    module InstanceMethods[T]
      def require_login: () -> void
      def login: [U] (*String credentials) ?{ (T, (failure_reason | nil | untyped)) -> U } -> (T | nil | U)

      def logged_in?: () -> bool
      def current_user: () -> (T | nil)
      def logout: () -> (nil | void)
      def auto_login: (T, ?bool should_remember) -> void
      def redirect_back_or_to: (String url, ?Hash[(String | Symbol), String] flash_hash) -> void
    end
  end
end

試しに、

$ rbs collection install

すると自分が作成したsorceryのrbsファイルがproject内にダウンロードされました 🎉

やってみて気づいたこと

型があるって便利

TypeSciptをやったことがある人なら型の恩恵を享受したこともあると思いますが、同じことをRubyで実現できるのはとても良い開発体験になるのではと思いました。

例えば、自分の場合、開発はRubyMineで行うのですが、今回追加した型情報をRubyMineでは読み込んでコードを書く際に補助情報として表示していくれるという粋な計らいをしてくれます。

Suggestions for RubyMine

↑の例だとfailure_reasonがnilを返す可能性を示唆してくれているため、条件分岐を迷わず書くことができます。また、blockの1つ目の引数がUserのinstanceであることも明確にわかります。

また、メソッドによっては非互換の引数が渡されていることも指摘してくれたりします。

auto_login の型情報を表示すると

@params のところにSorcery::ModelをincludeしたUser instanceと記述されており、どのような値を渡せばいいのかもすぐにわかります。

これらは、チーム開発をしているときにより力を発揮するのではと思いました。

このメソッドは、「どんなkeyがあってどんな値を取りうるのか、どんな返り値が返ってくるのか、nilが返る可能性はあるのか」 などの情報がチーム内で共有されていると、正確なドキュメントになるので開発が楽になるのではと思いました。

型を書くのが意外と難しい

型があるとすごく便利であると分かった上で、RBSは意外と書くのが難しいのではないかと思いました。RBSはメソッド等のsignatureを書くだけなのでかんたんに書けるのではという最初の見積もりから、レビューでの指摘から色々と考えることが意外とあり、結構頭を使うなと感じました。

例えば、 untyped は、TypeScriptでいうanyと同じ型で、どんな型か不明な際に使用できる型です。 Symbol | nil | untyped みたいな型を受け取るメソッドがある場合に、これは untyped のみでも同義です。つまり、どこまで明確にするかは記述する人に依存しています。また、 instance という型もありますが、これはジェネリック型でも代替可能です。これらはチーム内で方向性を決めておかないと型情報が乱立してしまう危険性があると思いました。

もしかしたら、rbsにもrubocopのようなcoding styleを統一してくれるライブラリが必要なのかもしれないと感じました。

一旦まとめ

今回、gemに型情報をまとめる方法をFu-gaさんの方法をもとに試してみました。型情報を記述するのは思ったよりかんたんではなく、考える必要があることと分かった上で、型情報があることによって享受できるメリットも多く、TypeScript などからも分かるとおり型安全であることで開発体験が向上すると感じました。他の業務で使用しているgemの型情報を書いていくことによって、半年後、1年後に、より良い開発環境にできるように、積極的にcontributeしていきたいです。

という良さげな感想を書きましたが、とはいえ自分達が書くコードも大量にありそれらにも型情報が記述されていないと本当の意味で型情報のありがたさは享受できないと感じました。しかし、それらの型情報はTypeScriptのように同じファイル上に記述できるわけではなく、RBSの仕様上、別ファイルに記述していく必要があります。これは、個人的にすごく面倒だと感じてしまいます。せっかくRubyでコードを書いているので楽しくコードを書いていたい。

「型情報は欲しいのに、書きたくない」という矛盾した気持ちになりそうです 😇

しかし、こんな矛盾した状態を補助してくれるgemが今回のRubyKaigiで紹介されました。名前を orthoses と言います。

github.com

後編では、このgemについての紹介と触ってみた所感を共有します。