2023年 12月 07日
この記事では Kaigi on Rails の conference-app に型をつけてみた話を紹介します。
以前公開した前編では、以下の状態まで作業を進めました。
rbs prototype rb
コマンドをつかってプロトタイプの型を抽出した今回は型をさらに書き足しながら、conference-app の改善にチャレンジしようと思います。
さて、アプリケーションに型を付けていく際はどこから手を付けていくと良いでしょうか。
私がよく見聞きする方針のひとつに「よく使われるコンポーネントに型をつける」というものがあります。
アプリケーションの中にはモデルやコントローラ、ビューやサービスクラス、Concern モジュールなど、さまざまなコンポーネントが存在します。この方針はこれらのコンポーネントのうち、他のコンポーネントから使われている回数の多いコンポーネントに着目しようという作戦です。
利用されるコンポーネントに型をつけた場合、
ということが期待できるため、一度の型付け作業でいくつものコンポーネントの型検査ができるので、2度おいしい、3度おいしい可能性があります。
Rails アプリケーションでいうと、モデルや Concern モジュール、Service クラス、ApplicationController などの共通親クラスなどが他のコンポーネントから利用される回数が多めですよね。
この方針の他にも、次のようなアイディアも耳にしたことがあります。
どこから手を付けるのが正解である、とは一概には言いづらい部分がありますが、これらの観点を参考に作業順序を考えてみるとよさそうです。
上記の方針をもとに、最初に手を付けたのは 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!
にリネームし、動きを変更する
変更後のコードは以下のようになります。
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 に関するものは見落としがちなので、ツールの手助けによってこういう問題が浮かび上がってくるのが型検査のメリットのひとつですね。
今回は以下の内容をお伝えしました。
型はうまく利用すると、コンポーネントのインターフェースを明らかにし、プログラムの不具合を見つけてくれる道具になります。
いよいよ次回は後編です。今回の記事に収まりきらなかったバグ修正についてお伝えする予定です。お楽しみに。