📑

作って学ぶRuby3 静的型解析・RBS入門

2023/02/27に公開

Ruby 3 の静的型解析について

私が業務で Ruby を書いていた時期は数年前で最近の Ruby についてキャットアップが遅れていました。
最近は Go・TypeScript など型を書くことがほとんどなので Ruby 3 系で導入された型解析について気になっていました。
Ruby って型が無いよねと肩身が狭い生活をしているので(型だけに)、Ruby でも出来らあっ!というところを見せたいと考え書いてみることにしました。

作るもの

HackerNews API を利用して HackerNews の API Client を作ります。要件は以下です。

  1. gem として実装する
    これはなんやかんやで私が gem を書いたことがないので勉強の一環です。特に gem で書く必要はありません

  2. rake タスクを実行すると Hacker News のトップページに相当する情報(タイトル, points, author, 日付)が出力される。ページネーションは実装しない

  3. 全ファイルに型情報をつける
    型情報というのは RBS ファイルのことです

  4. 静的解析ツールは Steep を利用する

作ったもの

ネタバレですが完成系のソースはこちらです。
https://github.com/teitei-tk/hackernews_api_client_rb

gem の作成

Ruby は 3.2.1 を利用します。

$ ruby --version
ruby 3.2.1 (2023-02-08 revision 31819e82c8) [arm64-darwin22]
$ bundle gem hackernews_api_client

デフォルトで sig ディレクトリが生成されています

$ tree
.
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
│   ├── console
│   └── setup
├── hackernews_api_client.gemspec
├── lib
│   ├── hackernews_api_client
│   │   └── version.rb
│   └── hackernews_api_client.rb
├── sig
│   └── hackernews_api_client.rbs
└── spec
    ├── hackernews_api_client_spec.rb
    └── spec_helper.rb

5 directories, 14 files

型定義をまとめる Steepfile を生成します。

$ bundle add steep
$ bundle exec steep init
Writing Steepfile...
$ ls | grep Steepfile
Steepfile

Steepfile が生成されるので、ソースコードの lib/Gemfile を対象にします。

# D = Steep::Diagnostic
#
target :lib do
  signature "sig"

  check "lib"                       # Directory name
  check "Gemfile"                   # File name
  # ignore "lib/templates/*.rb"

  # library "pathname", "set"       # Standard libraries
  # library "strong_json"           # Gems

  # configure_code_diagnostics(D::Ruby.strict)       # `strict` diagnostics setting
  # configure_code_diagnostics(D::Ruby.lenient)      # `lenient` diagnostics setting
  # configure_code_diagnostics do |hash|             # You can setup everything yourself
  #   hash[D::Ruby::NoMethod] = :information
  # end
end

# target :test do
#   signature "sig", "sig-private"
#
#   check "test"
#
#   # library "pathname", "set"       # Standard libraries
# end

触れてみる

デフォルトで生成された rbs ファイルには HackernewsApiClient::VERSION の型が定義されています。これを Integer に変更してみます。

module HackernewsApiClient
  VERSION: String
  # See the writing guide of rbs: https://github.com/ruby/rbs#guides
end
$ bundle exec steep check
# Type checking files:

.......................................................F..........................F

lib/hackernews_api_client.rb:6:8: [warning] Cannot find the declaration of class: `Error`
│ Diagnostic ID: Ruby::UnknownConstant
│
└   class Error < StandardError; end
          ~~~~~

lib/hackernews_api_client/version.rb:4:2: [error] Cannot assign a value of type `::Integer` to a constant of type `::String`
│   ::Integer <: ::String
│     ::Numeric <: ::String
│       ::Object <: ::String
│         ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::IncompatibleAssignment
│
└   VERSION = 1
    ~~~~~~~~~~~

Detected 2 problems from 2 files

定義されていない Error と、VERSION が Integer であるエラーが出力されました。

VSCode との連携

steep には VSCode の拡張があり、steep-vscode を導入することでエラーからさらに型補完までできるようになります。
https://marketplace.visualstudio.com/items?itemName=soutaro.steep-vscode


デフォルトパッケージの定義

次に Logger を実装したいと思います。実装内容は以下の通りです。

require "logger"

module HackernewsApiClient
  class Logger < ::Logger
    def self.default
      return @default if @default

      logger = new STDOUT
      logger.level = Logger::WARN
      @default = logger
    end
  end
end

が、このコードだと標準パッケージの Logger Class を解析できず、UnknownConstant とエラーになります。

$ bundle exec steep check
# Type checking files:

..........................................F.........................................

lib/hackernews_api_client/logger.rb:4:8: [warning] Cannot find the declaration of class: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└   class Logger < ::Logger
          ~~~~~~

lib/hackernews_api_client/logger.rb:4:19: [warning] Cannot find the declaration of class: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└   class Logger < ::Logger
                     ~~~~~~

lib/hackernews_api_client/logger.rb:8:19: [error] Unexpected positional argument
│ Diagnostic ID: Ruby::UnexpectedPositionalArgument
│
└       logger = new STDOUT
                     ~~~~~~

lib/hackernews_api_client/logger.rb:9:21: [warning] Cannot find the declaration of constant: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└       logger.level = Logger::WARN
                       ~~~~~~

lib/hackernews_api_client/logger.rb:9:13: [error] Type `::Object` does not have method `level=`
│ Diagnostic ID: Ruby::NoMethod
│
└       logger.level = Logger::WARN
               ~~~~~

lib/hackernews_api_client/logger.rb:10:6: [error] Cannot find the declaration of instance variable: `@default`
│ Diagnostic ID: Ruby::UnknownInstanceVariable
│
└       @default = logger
        ~~~~~~~~

Detected 6 problems from 1 file

Ruby の標準パッケージを Steep に理解させるには Steepfile の library に追加をします。

diff --git a/Steepfile b/Steepfile
index 2ff289b..e5d1548 100644
--- a/Steepfile
+++ b/Steepfile
@@ -4,6 +4,7 @@ target :lib do
   signature "sig"

   check "lib"                       # Directory name
+  library "logger"
   # check "Gemfile"                   # File name
   # ignore "lib/templates/*.rb"

追加することでライブラリを理解したことが確認できます。

$ bundle exec steep check
# Type checking files:

........................................................F.................................

lib/hackernews_api_client/logger.rb:10:6: [error] Cannot find the declaration of instance variable: `@default`
│ Diagnostic ID: Ruby::UnknownInstanceVariable
│
└       @default = logger
        ~~~~~~~~

Detected 1 problem from 1 file

RBS ファイルの実装

続いて logger.rb の RBS ファイルを実装します。ここで 1 から実装しても良いのですが、元となるコードがある場合は TypeProf を利用して RBS ファイルを生成できます。 TypeProf というのは Ruby のソースコードを解析し、型定義ファイルを生成してくれるツールのことです。

$ typeprof lib/hackernews_api_client/logger.rb
# TypeProf 0.21.3

# Classes
module HackernewsApiClient
  class Logger < Logger
    self.@default: Logger

    def self.default: -> Logger?
  end
end

-o でファイルに出力ができます。

$ typeprof lib/hackernews_api_client/logger.rb -o sig/logger.rbs

が、完璧ではなくこのままだとエラーになります。

$ bundle exec steep check
# Type checking files:

........................................F.................................................F

lib/hackernews_api_client/logger.rb:4:2: [error] UnexpectedError: sig/logger.rbs:5:2...9:5: Detected recursive ancestors: ::HackernewsApiClient::Logger < ::HackernewsApiClient::Logger
│ Diagnostic ID: Ruby::UnexpectedError
│
└   class Logger < ::Logger
    ~~~~~~~~~~~~~~~~~~~~~~~

sig/logger.rbs:5:2: [error] Circular inheritance/mix-in is detected: ::HackernewsApiClient::Logger <: ::HackernewsApiClient::Logger
│ Diagnostic ID: RBS::RecursiveAncestor
│
└   class Logger < Logger
    ~~~~~~~~~~~~~~~~~~~~~

Detected 2 problems from 2 files

rbs ファイルを見るとわかるのですが、Logger Class の指定が絶対指定ではないのが原因です。絶対指定に直すとエラーが修正されました。

$ bundle exec steep check
# Type checking files:

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

No type error detected. 🫖

外部パッケージの利用

HTTP Client として Faraday を利用したいので gemspec を更新します。

$ git diff
diff --git a/hackernews_api_client.gemspec b/hackernews_api_client.gemspec
index 3069b32..41f9c90 100644
--- a/hackernews_api_client.gemspec
+++ b/hackernews_api_client.gemspec
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
   spec.require_paths = ["lib"]

   # Uncomment to register a new dependency of your gem
-  # spec.add_dependency "example-gem", "~> 1.0"
+  spec.add_dependency "faraday", "~> 2.7"

   # For more information and examples about making a new gem, check out our
   # guide at: https://bundler.io/guides/creating_gem.html

Faraday の connection を定義します。

# frozen_string_literal: true

module HackernewsApiClient
  module Faraday
    module Connection
      private

      def connection
        @connection ||=
          begin
            options = {
              headers: {
                'Accept' => 'application/json; charset=utf-8',
                'User-Agent' => "HackerNews Ruby Client/#{HackernewsApiClient::VERSION}"
              }
            }

            url = "https://hacker-news.firebaseio.com"
            ::Faraday.new(url, options) do |connection|
              connection.request :multipart
              connection.request :url_encoded
              connection.response :json, content_type: /\b*$/
              connection.response :logger, HackernewsApiClient::Logger.default
              connection.adapter ::Faraday.default_adapter
            end
          end
      end
    end
  end
end

先ほどと同様に TypeProf を利用して RBS ファイルを出力してみます。

$ typeprof lib/hackernews_api_client/faraday/connection.rb
# TypeProf 0.21.3

# Classes
module HackernewsApiClient
  module Faraday
    module Connection
      @connection: untyped

      private
      def connection: -> untyped
    end
  end
end

@connectionが untyped となってしまいます。これも当然で Steepfile にライブラリを定義していないため、解析できず untyped になります。

標準パッケージ以外の型情報は gem_rbs_collection というリポジトリにまとめられています。
https://github.com/ruby/gem_rbs_collection

Ruby 3.1 以前では型検査のためローカルに取得する必要がありましたが、3.1 からは rbs collection コマンドが実装されました。
このコマンドは Gemfile から依存する Gem を解析し、steepTypeProfを利用した際の依存性も解決されるようになります。

$ bundle exec rbs collection init
created: rbs_collection.yaml
teitei.tk >> !(main) ~/repos/src/github.com/teitei-tk/hackernews_api_client
$ cat rbs_collection.yaml
# Download sources
sources:
  - name: ruby/gem_rbs_collection
    remote: https://github.com/ruby/gem_rbs_collection.git
    revision: main
    repo_dir: gems

# 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

rbs_collection.yamlというファイルが作成されます。これは Gem でいうところの Gemfile になります。

前述の通り Gemfile を読んで依存関係を見ています。今回は gem として実装しており、そのままでは rbs の定義が二重になってしまいエラーになります。さらに steep gem も解析するようになりエラーとなるので、ignore を追加し、解析対象から外す様にします。

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

# 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
  - name: steep
    ignore: true
  - name: hackernews_api_client
    ignore: true
  - name: logger
  - name: faraday

Steepfile に collection_config を追加し、 rbs_collection.yaml へのパスを定義します。前述に追加した library も削除し、rbs_collection.yaml に型定義を統一します。

# D = Steep::Diagnostic
#
target :lib do
  signature "sig"

  check "lib"                       # Directory name
  collection_config "rbs_collection.yaml"
  # check "Gemfile"                   # File name
  # ignore "lib/templates/*.rb"

  # library "pathname", "set"       # Standard libraries
  # library "strong_json"           # Gems

  # configure_code_diagnostics(D::Ruby.strict)       # `strict` diagnostics setting
  # configure_code_diagnostics(D::Ruby.lenient)      # `lenient` diagnostics setting
  # configure_code_diagnostics do |hash|             # You can setup everything yourself
  #   hash[D::Ruby::NoMethod] = :information
  # end
end

# target :test do
#   signature "sig", "sig-private"
#
#   check "test"
#
#   # library "pathname", "set"       # Standard libraries
# end

最後に、install コマンドを実行することでローカル環境に型情報が取得されます。

$ bundle exec rbs collection install

install 後、typeprof コマンドを実行すると型が認識されていることがわかります。

$ typeprof lib/hackernews_api_client/faraday/connection.rb
# TypeProf 0.21.3

# Classes
module HackernewsApiClient
  module Faraday
    module Connection
      @connection: Faraday::Connection

      private
      def connection: -> Faraday::Connection
    end
  end
end

あとはゴリゴリ開発をするだけです。そして出来上がったものがこちらです
https://github.com/teitei-tk/hackernews_api_client_rb

所管

ネガティブな点

登場人物が多い

Ruby の静的解析ツール周りだけでも 5 つはあるのでパッと理解するのが難しい印象を受けました。

  1. rbs
  2. rbs collection
  3. steep
  4. sorbet
  5. TypeProf

ちょっと捻った書き方をすると型定義の書き方が分からない

Web 上に存在する記事の大体が触ってみたレベルなので捻った書き方(メタプログラミング等)をする場合、型定義が分からない場合があります。
まだベストプラクティスが固まっていない印象でした。

rbs collection にある gem の数が少ない

これは我々コミュニティの問題ですが、比較的よく利用するであろう aws-sdk, activerecord といった gem は存在しますが、まだまだ数が少ないので少しでもマイナーな gem を利用しようと考えると恩恵に授かりづらいです。
https://github.com/ruby/gem_rbs_collection/tree/main/gems

ポジティブな点

プロダクションでも利用できる

しばらく Ruby から離れており情報を追いきれておらずプロダクションで利用するのはまだ先かなと勝手に思っていましたが、周辺ツールが整っていることもありプロダクションでも利用できると判断しました。
rbs collection や VSCode の Extension など周辺ツールはかなり整っている印象でした。

じゃあ多くのプロダクトで利用されているであろう Rails はどうだという話ですが、まだ辛い部分はあるものの導入して型の恩恵を受けることはできそうと考えています

https://blog.agile.esm.co.jp/entry/example-of-introducing-and-using-rbs

最後に

思った以上に道は整っていたのであとはひたすら書くだけだなという印象でした。一方実際の業務で利用している Rails アプリケーションに導入するには一苦労必要になりそうという感想です。
私が導入するのであれば一気に導入するのではなく、少しずつ型定義を反映していくやり方になるだろうと考えました(app/を全てみるのではなく、app/controllers/hoge_controllerなど一つずつ適応という方針です。)
または全てに型定義を反映することは後回しにし、重要なビジネスロジックのコード(課金処理・バッチ処理 etc)をファイルや gem に切り出し、ひとまずそこだけメンテナスをするという方向もありだなと考えています。

GitHubで編集を提案

Discussion