Strong Parameter With Meta Programming

March 15, 2017    Ruby Metaprogramming StrongParameter

目的

今回はRailsのコントローラでは必須のStrong Parameterとメタプログラミングを使ってコードをすっきりさせてみます。

Strong Parameterとは

With this plugin Action Controller parameters are forbidden to be used in Active Model mass assignments until they have been whitelisted. This means you’ll have to make a conscious choice about which attributes to allow for mass updating and thus prevent accidentally exposing that which shouldn’t be exposed. rails/strong_parameters

簡単に言うと、どのパラメータをモデルに渡すかを決めるホワイトリスト方式のパラメータフィルターのことです。 ホワイトリスト方式でフィルタリングすることで、DBのカラムの不正な更新や作成を防ぎます。

環境

  • Rails 4.2.6
  • ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin16]

背景

上記したように、DBのカラムを不正(予期せず)更新しないためにコントローラでフィルタリングしますが、create時やupdate時でどのパラメータを通すか分けて書いたり、モデルの数が増えると同じコードがそこら中のコントローラに散見されるので、メタプログラミング利用してDRYにしようという背景があります。

手法

今回は以下のような方針で実装します。

  1. どのアクションに対してどのパラメータを通すかはモデルが管理する。
  2. どのアクションに対してストロングパラメータを用いるかはコントローラに明示的に記述できるようにする。
  3. StrongParameterDefinerというモジュールを作ってストロングパラメータの処理をBaseControllerにミックスインさせる。

実装

通常のような場合だと以下のようなコードに書かれると思います。今回のケースではupdateではemailとpasswordの変更はpeopleのコントローラを通してはできないという想定で行きたいと思います。

class ::PeopleController < ::BaseController
  before_action :find_person!, only: [:update]
  def create
    @person = Person.new permit_create_params
  end
  def update
    @person.update! permit_update_params
  end

  private
  def find_person!
    Person.find params[:id]
  end
  def permit_create_parasms
    params.require(:person).permit
      [:name, :email, :password, :age, :phone_number]
  end
  def permit_update_parasms
    params.require(:person).permit [:name, :age, :phone_number]
  end
end

この状態だとコントローラがDBにどんなカラムを持っているのか常に知っていないと行けないのでカラムの変更があった時にコントローラも変更し忘れないうにしないといけません。(例えば新たにlocationというカラムが増えたとか…)

これだと、疎結合ではないので手法1の方針で書き換えると以下のようになります。

class ::PeopleController < ::BaseController
  before_action :find_person!, only: [:update]
  def create
    @person = Person.new permit_create_params
  end
  def update
    @person.update! permit_update_params
  end

  private
  def find_person!
    Person.find params[:id]
  end
  def permit_create_parasms
    params.require(:person).permit ::Person::ATTRIBUTES_FOR_CREATE
  end
  def permit_update_parasms
    params.require(:person).permit ::Person::ATTRIBUTES_FOR_UPDATE
  end
end
class ::Person < ActiveRecord::Base
  ATTRIBUTES_FOR_CREATE = [:name, :email, :password, :age, :phone_number]
  ATTRIBUTES_FOR_UPDATE = [:name, :age, :phone_number]
end

まずどのパラメータを通すかはモデルに定数を用意します。こうする事によってコントローラがモデルのカラムを気にしなくていいので疎結合になります。

コントローラから見るとDBのカラムを把握して、createの時は何を通して、updateの時は何を通すということを意識せずにモデルから言われたものだけ通すと言うかたちになりより抽象的になります。DBに変更があったときでもmodelのファイルだけ気にすればいいのがうれしいですね。

ストロングパラメータの処理はpeopleだけじゃなくて他のコントローラでも書かないといけないです。(仮にcompanyのコントローラがあったり) 今回コントローラはBaseControllerを継承しているということを想定して、BaseControllerにメソッドを定義するというのは一つの手です。

class ::BaseController < ActionController::Base
  def permit_create_parasms model
    params.require(model).permit "#{camelized(model)}::ATTRIBUTES_FOR_CREATE)
  end
  def permit_update_parasms model
    params.require(model).permit "#{camelized(model)}::ATTRIBUTES_FOR_UPDATE)
  end
end
class ::PeopleController < ::BaseController
  before_action :find_person!, only: [:update]
  def create
    @person = Person.new permit_create_params :person
  end
  def update
    @person.update! permit_update_params :person
  end

  private
  def find_person!
    Person.find params[:id]
  end
end

これならコントローラからも自分のモデルを引数に渡してもらえればBaseControllerに書かれたストロングパラメータが機能します。

でもpeople_controller.rb単体で見た時にこのメソッドはどこからでてきたんだ?みたいな印象を受けます。

それからupdateとcreateという字面だけ違うだけで同じ処理をしているので、まだ無駄あるように見えます。

メソッド名をpermit_paramsに変えて引数にmodelとactionの2つをとるという方法も考えられますが、別の方法でまとめたいと思います。 手法2で述べたようにどのアクションにストロングパラメータを適用するか明示的に宣言できるようにします。

class ::BaseController < ActionController::Base
  class << self
    def define_permit_params model, actions
      actions.each do |action|
        define_method "permit_#{action}_params" do
          params.require(model).permit(
            "#{camelized(model)}::#{model_attr(action)}".constantize)
        end
      end
    end
  end
  private
  def model_attr action
    "attributes_for_#{action}".upcase
  end
  def camelized model
    model.to_s.camelize
  end
  end
end
class ::PeopleController < ::BaseController
  before_action :find_person!, only: [:update]
  define_permit_params :person, [:create, :update]
  def create
    @person = Person.new permit_create_params
  end
  def update
    @person.update! permit_update_params
  end

  private
  def find_person!
    Person.find params[:id]
  end
end

BaseControllerの仲が一気に複雑になりました。 やっていることはBaseControllerの特異メソッドを作り、そのメソッドが呼ばれたらdefine_methodを利用してpermit_create_paramsやpermit_update_paramsを動的に定義しようということです。そしてPeopleControllerの中でどんなアクションにストロングパラメータを適応するか宣言するようにします。 こうすることでpeople_controller.rbを単体で見た時にpermit_create_paramsのメソッドがコントローラ内で定義されたんだなと知らない人が見ても感じ取れます。

最後に、BaseControllerのdefine_permit_paramsメソッドが複雑すぎて、今度はbase_controller.rbを見た時に何をしているのかわかりづらくなるのでモジュールにして何をしてるのか抽象的にしてしまいましょう。

class ::BaseController < ActionController::Base
  include StrongParameterDefiner
end
module StrongParameterDefiner
  extend ActiveSupport::Concern
  module ClassMethods
    def permit_params model, actions
      actions.each do |action|
        define_method "#{model}_#{action}_params" do
          params.require(model).permit(
            "#{camelized(model)}::#{model_attr(action)}".constantize)
        end
      end
    end
  end
  private
  def model_attr action
    "attributes_for_#{action}".upcase
  end
  def camelized model
    model.to_s.camelize
  end
end

ActiveSupport::ConcernをextendすることでClassMethods内に書かれたメソッドはミックスインしたクラスの特異メソッドとしてextendされます。 BaseControllerの責任も軽くなり、モジュールの名前から何をやっているのかわかりやすくなりました。

結論

最終的には以下ようなコードになりました。 記述量が増えてしまいましたが、コントローラの中がスッキリ保たれて良いのではないかと思っています。 これで、どんなモデルコントローラでもdefine_permit_paramsと定数を用意するだけでストロングパラメータを利用することができるようになりました。

class ::PeopleController < ::BaseController
  before_action :find_person!, only: [:update]
  define_permit_params :person, [:create, :update]
  def create
    @person = Person.new permit_create_params
  end
  def update
    @person.update! permit_update_params
  end
  private

  def find_person!
    Person.find params[:id]
  end
end
class ::Person < ActiveRecord::Base
  ATTRIBUTES_FOR_CREATE = [:name, :email, :password, :age, :phone_number]
  ATTRIBUTES_FOR_UPDATE = [:name, :age, :phone_number]
end
class ::BaseController < ActionController::Base
  include StrongParameterDefiner
end
module StrongParameterDefiner
  extend ActiveSupport::Concern
  module ClassMethods
    def permit_params model, actions
      actions.each do |action|
        define_method "#{model}_#{action}_params" do
          params.require(model).permit(
            "#{camelized(model)}::#{model_attr(action)}".constantize)
        end
      end
    end
  end
  private
  def model_attr action
    "attributes_for_#{action}".upcase
  end
  def camelized model
    model.to_s.camelize
  end
end


comments powered by Disqus