Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 提案「コントローラから`@`ハックを消し去ろう」(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

週刊Railsウォッチで絶賛された記事です。Rails Wayから外れるのでRails中級以上向けですが、Rails初心者も知っておいて損はありません。

太字は訳で追加いたしました。

  • 2018/06/14: 初版公開
  • 2021/10/21: 更新

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

他のupdatedestroyなどのメンバーアクションも同様です。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

私はcreateupdateのどうでもいいような差分を消し去るのが大好きなので、テンプレート名をformとし、パーシャルレンダリング用のダミーテンプレートは一切用いませんでした。もちろん、このあたりの書き方は好みに応じて変えていただいても一向に構いません。

このletによる手法を既存のコントローラに導入したとしても、コントローラで他の部分を書き直す必要はありません。皆さんのお好きなようにコーディングしていただければ結構です。letは、単にアクション間やコントローラ-ビュー間でデータ読み込みを共有し、テストを楽にするためのものに過ぎません。

影響を受けたgem

私の提案は、decent_exposure gemのライブラリから影響を受けていることをここに認めるものであります。このライブラリのおかげで最初の着想に触れることができました(ダジャレを狙ったわけではありません2)。decent_exposureで好きになれなかったのは「暗黙の読み込み」の部分でした。これをやると隠蔽が甚だしくなり、カスタマイズが難しくなるからです。

decent_exposureを用いることも検討しましたが、暗黙の読み込みはどうしても使いたくありませんでした。巨大なライブラリを導入しなくても、ひとかけらのメタプログラミングさえあれば十分やれることに気付いたときに、それをconcernに置くのがよいという決定を下したのです。

ツイートより

関連記事

3年以上かけて培ったRails開発のコツ集大成(翻訳)

Railsコードを改善する7つの素敵なGem(翻訳)


  1. 訳注: 本記事のコード例はあくまで説明のためのものです。週刊Railsウォッチ20181015『Rails初心者とバレる書き方』もご覧ください。 
  2. 訳注: 「exposed me to the idea」とdecent_exposureのシャレと思われます。なおdecent exposureは「個人情報の適度な露出」という流行語です。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。