Rails: 提案「コントローラから@
ハックを消し去ろう」(翻訳)
少し前に私が書いた記事で、Railsコントローラをメンテしやすくするために私が使っていた、伝統的でない戦略をいくつかご紹介しました。考え方そのものは今でもまったく問題ないと思っていますが、その中でもとりわけ気に入っているものについては、Railsで標準になるべきだとも思っています。
そのために本記事は、Railsのコントローラでデータの読み込みやアクション間での共有、ビューとのやりとりの手法を変更すべきであるという提案を皆さんに納得していただくための事例を作成するために執筆しています。
「Rails Way」のおさらい
私の事例を紹介する前に、「Rails Way」のどの点が素晴らしく、どの点が不十分であるかをしっかり理解しておくことが重要と考えます。それによって、私からの提案がさらに明瞭になればと願っています。
データの読み込みやビューとのデータのやり取りを、冗長な(Rails的でない)方法で行うと次のようになります1。
def show
user = User.find params[:id]
render :show, locals: { user: user }
end
運のよいことに、Railsフレームワークの開発者はどのアクションも最終行が似たり寄ったりであることに気付いたので、テンプレートに渡す必要のあるデータを何度も書くことにきっと嫌気がさしたのでしょう。そしてこうした定型コード(boilerplate)を減らすために賢い方法を2つ編み出しました。
@
変数のハック- 暗黙のレンダリング
では、これら2つのRails的手法を適用するとどうなるかを見てみましょう。
def show
@user = User.find params[:id]
end
暗黙のレンダリングでは、レンダリングするテンプレートをアクション名で決定します。私はこちらのRails Wayを愛していますので、これは変えたくありません。問題にしたいのは、もうひとつの@
変数のハックの方です。
@
変数のハック
そもそも「@
変数のハック」とは何でしょうか。Railsフレームワークの開発者は、何らかのハッシュ的なものを用いてビューに変数を渡すのではなく、その変数にマーキングするだけの方がよいだろうという決定を下したのです。
マーキングはどのようにして行われるかご存知でしょうか?Ruby自身には、変数にマーキングする方法が2とおりあります。変数の先頭に$
記号または@
記号を付けることでマーキングできます。
Rubyで$
記号を付けると、値がグローバルになるのでよくありません。では@
記号はどうかというと、通常であれば、その変数は同一インスタンス上のあらゆるメソッド呼び出しで共有されるべきであるという指定になります。これはRubyや多くのオブジェクト指向言語でネイティブサポートされている「インスタンス変数」と呼ばれるものです。
Railsフレームワークの開発者は、コントローラではインスタンス変数の使いみちがあまりないことに気付きました。コントローラは、概念上(開発者目線では、ですが)以下を行います。
controller = UsersController.new request
response = controller.show
もちろん内部では他にもいろいろやっているのは承知のうえで、概念上は以下にまとめられます。
- インスタンスを1つ作成する
- メソッドを1つ呼び出す
- 作成されたオブジェクトを渡す
これは本質的にオブジェクト指向的というより関数型的です。
コントローラオブジェクトはさほど長生きしませんし、呼び出すメソッドはたった1つなので、コントローラではインスタンス変数の出番があまりありません。Railsフレームワークの開発者はここに目をつけて、@
というマーカーを別の目的に転用する決断を下したのです。
技術的にはインスタンス変数であることは変わりませんが、Railsはこれらの変数を監視してビューにコピーします。これによって、ビューに渡す変数のハッシュを指定する必要がなくなりました。
ある機能を(言語設計者の)意図しない目的に転用するという意味で、私はこれをハックと呼んでいます。「ハックだからよくない」ということではありませんのでお間違いなきよう。定形コードを削減するために未使用の機能を転用するのは賢いやり方です。私が問題にしたいのは、このハックそのものではなく、実際にはこのハックでも満たせないニーズがあるという点に尽きます。
何が問題なのか
@
変数のハックのどのあたりに問題があるのでしょうか?@
マーカーを転用したことが問題なのではなく、読み込みのパターンが複数のアクション間で共有されてしまっていることが問題なのです。次のように、いくつものアクションが同じようなデータを欲しがるというのはよくあることです。
def show
@user = User.find params[:id]
end
def edit
@user = User.find params[:id]
end
他のupdate
やdestroy
などのメンバーアクションも同様です。Railsには、甚だしい繰り返しをコールバックで解決する手法があります。上のように書く代わりに、以下のように書けます。
before_action(only: %i[show edit]) { @user = User.find params[:id] }
def show
end
def edit
end
しかしコールバックによる手法にはいくつもの問題があります。
only:
やexcept:
を用いて特定のアクションだけを対象にしようとするとエラーが発生しやすくなります。これらのリストがちゃんとメンテされていないばかりに誤ったデータを読み込んでいたアプリを山ほど目撃してきました。- アクションの実際の動作が見えづらくなります。アクションを眺めただけでは、一見何もしてないように見えてしまいます。
- 現実世界の巨大なコントローラでコールバックを把握づらくなる可能性があります。コールバックが親クラスで定義されていたりモジュールとして
include
されていればなおさらです。 - ソースコードの順序がシビアになります(ある
before_action
フックで別のbefore_action
で読み込んだデータが必要になるなど)
「メソッドでやろうよ」
私の提案するソリューションはいたってシンプルです。一言で言えば「メソッドでやろうよ」です。
私の提案では私たちのニーズがすべて勘案されているので、よくできた小さな定形コードを素早く構築できます。定形コードはまさに@
ハックが殺そうとしていたものなので、これでは歴史を繰り返しているように思われるかもしれません。私のアイデアがお気に召すかどうかを皆さんにご検討いただくためにも、どうかもうしばらくお付き合いください。最終的には、ささやかなメタプログラミングを用いてあらゆる定形コードをラップし、(@
ハックの)メリットを失うことなく簡潔なコードに作り変えます。
メソッドを定義する
典型的なオブジェクト指向システムにおいて、あるコンポーネントが他のコンポーネントからデータを取得する最もシンプルなソリューションと言えば何だかおわかりでしょうか?最初のコンポーネントが、その情報を担当する次のコンポーネントにメッセージを送信することです。この「メソッドを介するメッセージ送信」は、オブジェクト指向プログラミングの核となるアイデアです。
さて、データをビューに送信しようとするのではなく、両者の立場を逆転させて、ビューが自分の欲しいデータをコントローラに問い合わせる形にしてはどうでしょうか?つまり、データを要求するメッセージはビューからコントローラに送信し、コントローラはレスポンスでデータを返すことになります。
ビューでは以下のような感じになります。
<%= controller.user.name %>
そしてコントローラは次のような感じになります。
def user
User.find params[:id]
end
これは単なるpublicメソッドであり、オブジェクト指向としてはごく普通の考え方です。このメソッドは別のテンプレートからも使えます。このuserをedit
テンプレートとshow
テンプレートのどちらも欲しがっているのであれば、どちらも同じメソッドを呼べばよいのです。index
テンプレートが欲しがっていなければ、index
テンプレートで呼ばなければよいのです。必要もないのにデータを読み込むことはしません。コールバックの定義でonly:
やexcept:
のリストを今後もメンテし続ける必要もありません。
メモ化
userの属性を大量に出力したいが、コントローラで新しいインスタンスを毎回読み込むのは嫌だ。そんなときは次のように読み込みをメモ化(memoize)しましょう。
def user
@user ||= User.find params[:id]
end
上のコードではあえてインスタンス変数を用いていますが、これはインスタンス変数の本来の用い方です(同一インスタンス内にある異なるメソッド呼び出し同士でデータを共有する)。上のコードではインスタンス変数がnil
の場合が考慮されていませんので、もう少しちゃんと書くと次のようになります。
def user
return @user if defined? @user
@user = User.find params[:id]
end
ヘルパー
もはや概念上は「ハック」ではなくなりましたが、その分テンプレートはわずかに冗長になりました(コントローラも冗長になりましたが、これについては後述します)。変数アクセスごとにcontroler.変数
のようなプレフィックスをいちいち付けたくありません。次のようなヘルパーメソッドでコントローラへプロキシしてみてはどうでしょうか。
module UsersHelper
def user
controller.user
end
end
これでテンプレートは以下のように書くだけで済みます。
<%= user.name %>
これはこれでありがたいのですが、データ読み込み系メソッドごとにヘルパーメソッドをいちいち書くのはだるくて仕方ありません。ありがたいことに、Railsにはこんなときに使える手があります。コントローラのどのメソッドでも、helper_method
を呼んでおきさえすればRailsがヘルパーメソッドを代わりにこしらえてくれます。これでコントローラのデータ読み込み部分は次のように書けます。
def user
return @user if defined? @user
@user = User.find params[:id]
end
helper_method :user
メソッドを「代入可能」にする
これらのメソッドを代入可能にする(訳注: =
で終わるいわゆるセッターメソッドを定義する)と、読み込みの振る舞いをアクション間でもっとうまく共有できることにも気が付きました。たとえば何らかのアクセス制御を行うとしましょう。定義はおそらく次のようになります。
def users
return @users if defined? @users
@users = policy_scope User.all
end
helper_method :users
def user
return @user if defined? @user
@user = users.find params[:id]
end
helper_method :user
ここでは、データ読み込みメソッド(users
)のひとつを用いて、他のメソッド(user
)の実装を支援しています。before_action
コールバックによる方法とは異なり、ソースコードの順序はまったく影響しません。単にメソッドを呼んでいるだけなので、user
を先に定義しても構わないのです。
ここまでは何もかもうまくいってる感がありますね。今度はindex
アクションで検索もできるようにしたいとしたらどうでしょうか?index
アクションの定義は次のようになるでしょう。
def index
@users = users.search params[:q]
end
ここでやっと例の@
ハックに立ち戻りました。もし(インスタンス変数でない)users
データ読み込みメソッドに検索結果を代入すると、show
アクションでも現状のuser
の実装に合わせて自前で検索を行うはめになります。データ読み込みの振る舞いの一部(policy_scope
など)はアクション間で共有したいが、その他の振る舞い(検索)は共有したくない、というのは一般によくある問題です。
この問題も、代入によって解決できます。次のように、あるアクションでデータを絞り込めるよう、別のメソッドを定義してみてはどうでしょう。
private
def users= assigned_users
@users = assigned_users
end
これで次のようにindex
アクションを定義できます。
def index
self.users = users.search params[:q]
end
先ほどメモ化を実装しておいたので、同じインスタンス変数が使われれば(繰り返しますが、このインスタンス変数は同一インスタンス内のメソッド呼び出し間でデータを共有するのに使われます)、index
ビューでusers
を呼び出すとpolicy_scope
や検索が適用されたリストを取得できます。show
アクションでuser
を呼び出せば、policy_scope
のみが適用され、検索は除外されます。
この代入メソッド(users=
)はprivateにしてあります。理由は、このメソッドはそのアクション内(さもなければbefore_action
フック)でしか使われないからです。このメソッドをコンポーネントの外(ビューなど)で使う理由はまったく思い当たりません。
テストを書く
この提案におけるもうひとつの絶大なメリットは、テストの書きやすさです。これらのメソッドはいずれもpublicなので、テストでまったく普通に呼び出せます。たとえばindex
アクションで検索が正しく行われているかどうかをテストしたい場合、従来の方法では、おそらく以下のような感じのテストを書くでしょう(RSpec構文とFactoryBotを利用)。
it 'searches for the given query' do
create :user, last_name: 'Doe'
create :user, last_name: 'Jackson'
get '/users', params: { q: 'Doe' }
expect( response.body ).to have_content 'Doe'
expect( response.body ).not_to have_content 'Jackson'
end
上のテストコードには、出力されたテンプレートにキーワードが含まれているかどうかをチェックすることでコントローラが正しく振る舞っていると見なすという、暗黙の前提があります。しかし、そのページに何かのはずみでJackson
という単語が紛れ込んでしまえば、テストは「正しく機能していない」という理由で失敗するでしょう。しかしこの失敗は本当の失敗ではなく、false positive(偽陽性)です。
それでは、同じテストを先ほどのpublicメソッドで書き直してみましょう。
it 'searches for the given query' do
expected = create :user, last_name: 'Doe'
create :user, last_name: 'Jackson'
get '/users', params: { q: 'Doe' }
expect( controller.users ).to eq [expected]
end
こちらのテストはさらに頑丈になりました。欲しいレコードが見つかることと、それ以外のレコードが検索されないことを確認しています。false positiveをうっかり引き起こすような副作用の起きる余地はありません。
定形コードを減らす
皆さまがここまで辛抱強く読んでくださり、そして私の推す提案を気に入っていただければ幸いです。しかし、まだ「定形コードを何度も書くのがだるい」という問題が残されています。何やかやで、現在のデータ読み込みメソッドは以下のようになっています。
def user
return @user if defined? @user
@user = User.find params[:id]
end
helper_method :user
private
def user= assign_user
@user = assign_user
end
えっへん!Rubyには、この手の共通パターンをシンプルに書くのにうってつけのメタプログラミングという強い味方があるのです。上のコードで提供したいものは、結局次の2つに集約されます。
- データ変数の名前
- そのデータ変数の読み込み方法
開発者が普段から慣れ親しんでいるRSpecとある程度対になるよう、この新しいメソッドにlet
と命名しました。データ読み込みにメタプログラミングを用いると、次のような感じで書けます。
let(:user) { User.find params[:id] }
簡潔でありながら、先ほどの定形コードによる方法のメリットを何ひとつ失っていません。Lettable
モジュールでどんなメタプログラミングが使われているのか、じっくりご覧ください(Gist)。
module Lettable
def let name, &blk
iv = "@#{name}"
define_method name do
return instance_variable_get iv if instance_variable_defined? iv
instance_variable_set iv, instance_eval(&blk)
end
helper_method name
define_method :"#{name}=" do |value|
instance_variable_set iv, value
end
private :"#{name}="
end
end
上のコードをapp/controllers/concern
ディレクトリに置き、ApplicationController
でこのコードをextend
すれば完了です。コードが引き締まり、巨大なライブラリを持ち出す必要もなくなりました。let
という名前がお気に召さないのであれば、好きに変えていただいて構いません。
コード例
上のコードを用いるとコントローラをどんなふうに書けるか見てみましょう(Gist)。
class WidgetsController < ApplicationController
let(:widgets) { Widget.all }
let(:widget) { widgets.find_or_initialize_by id: params[:id] }
def new
render :form
end
def edit
render :form
end
def create
save
end
def update
save
end
def destroy
widget.destroy
redirect_to widgets_url, notice: 'ウィジェットは削除されました'
end
private
def save
if widget.update secure_params
redirect_to widget, notice: 'ウィジェットは保存されました'
else
render :form
end
end
def secure_params
params.require(:widget).permit :name
end
end
私はcreate
とupdate
のどうでもいいような差分を消し去るのが大好きなので、テンプレート名をform
とし、パーシャルレンダリング用のダミーテンプレートは一切用いませんでした。もちろん、このあたりの書き方は好みに応じて変えていただいても一向に構いません。
このlet
による手法を既存のコントローラに導入したとしても、コントローラで他の部分を書き直す必要はありません。皆さんのお好きなようにコーディングしていただければ結構です。let
は、単にアクション間やコントローラ-ビュー間でデータ読み込みを共有し、テストを楽にするためのものに過ぎません。
影響を受けたgem
私の提案は、decent_exposure gemのライブラリから影響を受けていることをここに認めるものであります。このライブラリのおかげで最初の着想に触れることができました(ダジャレを狙ったわけではありません2)。decent_exposureで好きになれなかったのは「暗黙の読み込み」の部分でした。これをやると隠蔽が甚だしくなり、カスタマイズが難しくなるからです。
decent_exposureを用いることも検討しましたが、暗黙の読み込みはどうしても使いたくありませんでした。巨大なライブラリを導入しなくても、ひとかけらのメタプログラミングさえあれば十分やれることに気付いたときに、それをconcernに置くのがよいという決定を下したのです。
ツイートより
状態ハックを使わなくていい👍
仕事のコードに導入しようかな>>>
Rails: 提案「コントローラから`@`ハックを消し去ろう」(翻訳) https://t.co/cApY1GnZM5— wint🛰 (@wint7) June 15, 2018
関連記事
- 訳注: 本記事のコード例はあくまで説明のためのものです。週刊Railsウォッチ20181015『Rails初心者とバレる書き方』もご覧ください。 ↩
- 訳注: 「exposed me to the idea」とdecent_exposureのシャレと思われます。なおdecent exposureは「個人情報の適度な露出」という流行語です。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。
週刊Railsウォッチで絶賛された記事です。Rails Wayから外れるのでRails中級以上向けですが、Rails初心者も知っておいて損はありません。
太字は訳で追加いたしました。