Kaigi on Rails の conference-app に型をつけてみた (中編)


2023年 12月 07日

この記事では Kaigi on Rails の conference-app に型をつけてみた話を紹介します。

以前公開した前編では、以下の状態まで作業を進めました。

  • 元々あった型定義に加えて、 rbs prototype rb コマンドをつかってプロトタイプの型を抽出した
  • 依存している gem の型や一部のコードに対する型を手書きして補足した
  • Rails アプリケーション全体を型検査できるようにした
  • Steep のデフォルトモードで型検査を行い、指摘は 0件を維持している

今回は型をさらに書き足しながら、conference-app の改善にチャレンジしようと思います。

共通で使われているコンポーネントに型をつける

さて、アプリケーションに型を付けていく際はどこから手を付けていくと良いでしょうか。

私がよく見聞きする方針のひとつに「よく使われるコンポーネントに型をつける」というものがあります。
アプリケーションの中にはモデルやコントローラ、ビューやサービスクラス、Concern モジュールなど、さまざまなコンポーネントが存在します。この方針はこれらのコンポーネントのうち、他のコンポーネントから使われている回数の多いコンポーネントに着目しようという作戦です。

利用されるコンポーネントに型をつけた場合、

  • コンポーネント自身の実装の型検査ができる
  • そのコンポーネントを呼び出している (依存している) コンポーネントの型検査ができる

ということが期待できるため、一度の型付け作業でいくつものコンポーネントの型検査ができるので、2度おいしい、3度おいしい可能性があります。

Rails アプリケーションでいうと、モデルや Concern モジュール、Service クラス、ApplicationController などの共通親クラスなどが他のコンポーネントから利用される回数が多めですよね。

この方針の他にも、次のようなアイディアも耳にしたことがあります。

  • ロジックの複雑度
  • アプリケーションの中での重要性
  • 作業のしやすさ (型の組み込みやすさ、書きやすさ)

どこから手を付けるのが正解である、とは一概には言いづらい部分がありますが、これらの観点を参考に作業順序を考えてみるとよさそうです。

SessionsHelper に型付けする

上記の方針をもとに、最初に手を付けたのは SessionsHelper です。

SessionsHelper は #logged_in?#current_user という2つの認証メソッドを提供するヘルパーモジュールです。ApplicationController から include され、ほぼすべてのコントローラで利用されている超重要モジュールです。

ここに型付けすると、型検査の対象となるコードを一気に増やすことができます。

それでは、対象となる SessionsHelper の実装を見てみましょう。

module SessionsHelper
  def current_user
    if session[:user_id]
      @current_user ||= User.find(session[:user_id])
    end
  end

  def logged_in?
    !!current_user
  end
end

このヘルパークラスの型を書き出してみると以下のような RBS になります。

module SessionsHelper
  def current_user: () -> User?
  def logged_in?: () -> bool
end

この型定義を sig/handwritten/app/helpers/sessions_helper.rbs に保存した上で、 rails rbs:setup コマンドを実行して型定義ファイルを更新します。前編で導入した rbs.rake は手書きの型は sig/handwritten/ ディレクトリ配下に配置することを想定しているので、このディレクトリの下に Rails アプリと同じファイル構成で RBS ファイルを作ると良いでしょう。

型定義ファイルを更新した後、Steep で型検査をしてみると検査結果が変化します。これまでエラーにならなかった箇所がエラーを出すようになりました。

これまでは #logged_in?#current_user の返り値が untyped だったため型検査が行われていませんでしたが、具体的な型が指定されたため、型検査されるようになったことによる変化です。

vscode@00e48be021af> bundle exec steep check

# Type checking files:

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

app/controllers/profile_images_controller.rb:5:25: [error] Type `(::User | nil)` does not have method `profile`
│ Diagnostic ID: Ruby::NoMethod
│
└     image = current_user.profile.images.find_by(id: params[:id])
                           ~~~~~~~
app/controllers/profile_images_controller.rb:8:47: [error] Type `(::User | nil)` does not have method `profile`
│ Diagnostic ID: Ruby::NoMethod
│
└     redirect_to edit_profile_path(current_user.profile)
                                                 ~~~~~~~
app/controllers/admin_controller.rb:9:34: [error] Type `(::User | nil)` does not have method `organizer?`
│ Diagnostic ID: Ruby::NoMethod
│
└     if logged_in? && current_user.organizer?
                                    ~~~~~~~~~~
(省略)
Detected 9 problems from 6 files

すべてのエラーメッセージが “Type `(::User | nil)` does not have method `〜`” ですね。エラーが起きている行を見てみると、いずれも #current_uesr メソッドを使っている箇所です。

エラーの意味としては (::User | nil) 、つまり「User オブジェクトもしくは nil」に対して、〜メソッドを呼び出そうとしているがそんなメソッドは存在しない(= NoMethodError)、というものです。

暗黙の型の絞り込み

エラーになっているファイルをひとつ取り上げて、内容を見てみましょう。

class AdminController < ApplicationController
  ...

  private def require_organizer
    if logged_in? && current_user.organizer?
      # pass
    else
      flash[:alert] = "Require organizer role"
      redirect_to root_path
    end
  end
end

たしかにこのコードで #current_user が nil を返した場合、 NoMethodError が起きてしまいそうですよね。

実はこのコード、#current_user#logged_in? がペアで使われており、#logged_in? が true を返した場合は #current_user はかならず User オブジェクトを返すことを期待しています。

コードで表現すると、以下のような振る舞いを暗黙的に期待しています。

if logged_in?
  current_user  #=> User
else
  current_user  #=> nil
end

この動作は型の絞り込み (type narrowing)と呼ばれる動きの一種です。型の絞り込みはある分岐に入った場合や、あるループに入った場合など、コードの文脈に応じて型を絞り込む動きを指します。しかし、現在の RBS/Steep はふたつのメソッド間の型の絞り込みには対応していません (私の知る限りでは他の言語でもあまり見かけません)。

暗黙的な期待値と実装を合わせる

前述のように、RBS/Steep ではこの実装に対して適切な型付けが難しいこともあり、今回の作業では実装を調整することにしました。

既存のコードを確認したところ、 #current_user を呼び出している箇所は、ほぼすべてログイン状態であることを前提としており、非ログイン状態で #current_user を呼び出している箇所はごく僅かでした。

この知見を活かし、SessionsHelper のメソッドたちを以下のように見直すことにしました。

  • #logged_in? は bool を返すメソッドとする (現状のまま)
  • #current_user を #current_user! にリネームし、動きを変更する
    • ログイン状態の場合は User を返す
    • 非ログイン状態の場合は例外を投げる

変更後のコードは以下のようになります。

module SessionsHelper
  class UnauthorizedError < StandardError
  end

  def current_user!
    raise UnauthorizedError unless session[:user_id]
    @current_user ||= User.find(session[:user_id])
  end

  def logged_in?
    current_user!.present?
  rescue UnauthorizedError
    false
  end
end
module SessionsHelper : ActionController::Base
  class UnauthorizedError < StandardError
  end

  @current_user: User

  def current_user!: () -> User
  def logged_in?: () -> bool
end

この変更は型検査をしてみよう、型付けしやすいコードに書き換えようという動機から始まっていますが、もともとの実装における暗黙の期待をコードでわかりやすく表現できています。

型付けを進めていくという行為は (ときに) プログラムの中で暗黙的に表現されているものを浮かび上がらせてくれます。型を書きやすいコードが常に正解とまでは断言できないものの、型検査を通してインターフェースを見直すということは、暗黙的なコードを明示的に表現できるチャンスでもあります。

そして、型検査という機械的なチェックを行うことで、コードの品質を上げるチャンスに繋がります。実際、#current_user! メソッドへの置き換えを進めていったところ、ログイン判定を行わずにユーザ情報を取得している箇所を見つけることができました

型付けがきっかけでバグに気づく

SessionsHelper に型付けしたことにより、他にもいくつかバグを見つけました。たとえば、プロフィール画像を削除している以下のコードにバグがありました。

image = current_user!.profile.images.find_by(id: params[:id])
image.destroy

どこに不具合があるかわかるでしょうか。

 

実は、この行を実行した際に、必ずしも画像ファイルが見つかるとは限りません。ID が誤っている場合や、すでに削除済みである場合などでは、変数 image は nil になってしまいます。

Steep に型を調べてもらったところ、この変数 image は予想通り ActiveStorage::Attachment | nil 型であることを教えてくれました。そのため、image.destroy の行は Ruby::NoMethod であると指摘されていました。

このコミットでは、削除部分を image&.destroy という呼び出しに変えることで、画像ファイルが見つかった場合のみ削除を実行するように書き換えました。

この不具合自体はマイナーなものですが、nil に関するものは見落としがちなので、ツールの手助けによってこういう問題が浮かび上がってくるのが型検査のメリットのひとつですね。

まとめ

今回は以下の内容をお伝えしました。

  • conference-app のよく使われるコンポーネントである SessionsHelper に型付けを行った
  • オリジナルのコードは暗黙的な期待(型の絞り込み)を含んでおり、型で表現しづらかったこともあり、リファクタリングを行った
  • 型付けをきっかけにバグを見つけた

型はうまく利用すると、コンポーネントのインターフェースを明らかにし、プログラムの不具合を見つけてくれる道具になります。

いよいよ次回は後編です。今回の記事に収まりきらなかったバグ修正についてお伝えする予定です。お楽しみに。