Railsプロジェクトにおけるパラメータ処理

2022/10/19に公開約11,400字

はじめに

今回の記事で書かせていただくのはパラメータ処理の方法についてです。一般的且つ基礎的なパラメータの処理とそれに付随されて考えうる問題点を洗い出した上で、弊社で実際に使用しているパラメータ処理について紹介していきたいと思います。

自己紹介

初めまして。株式会社ヴァージニアのエンジニアリング本部の津留と申します。前職のSESから2022年4月に現在のチームへ参画をし、ある医療美容クリニック様向けのCRMの改修を行っています。

2019年からエンジニアとして働き始め、SES企業を中心に勤めておりました。Java(SpringBoot)やTypescript(Nuxt.js)を中心に開発経験を積んでまいりましたがヴァージニアではRuby on Railsをメインに扱っているので、学習を重ねながらギリギリ日々のタスクについていっています。

故にRuby on Rails経験が3ヵ月ほどの初学者がこれから技術ブログを書くわけですが温かい目でご覧いただけますと幸いです。よろしくお願いします。

1, 一般的なパラメータ処理の方法

GET通信でクエリ文字パラメータとして送信されたものも、POST通信で送信されたパラメータもparamsという変数に格納されるのでAPI側ではparamsに存在するキー名を指定して送られた値を取り出しています。基本、コントローラクラスに各アクションメソッドで処理される事例が一般的だと見受けられます。

例1-1:基本的なパラメータの受け取り

def index
  name = params[:name]
  @person = People.find_by(name: name)
end

上記の形がRailsで行われるパラメータ処理の基本で、さらにストロングパラメータを使用することで指定したパラメータ以外受け取れないように制御することが可能です。

例1-2:ストロングパラメータを使用したパラメータの受け取り

def index
  name = index_params
  @people = People.where(name: index_params[:name]).where("age > ?", index_params[:age])
end

private
def index_params
  params.require(:form).permit(:name, :age)
end

ちなみにこの時に当たり前のように使用されているparamsというのはActionController::Parametersという型に詰められたデータです。

このクラスで用意されているrequireメソッドとpermitメソッドをストロングパラメータ作成時に使用すると意図しないパラメータをコントローラクラスで使用することを防ぐことができます。

例1-3:ActionController::Parametersを使用してパラメータを制御する

params = ActionController::Parameters.new({
  form: {
    name: 'Tanaka',
    age:  22,
    born: 'tokyo'
  },
  user: 'admin'
})

permitted = params.require(:form).permit(:name, :age)
permitted  # => #<ActionController::Parameters {"name"=>"Tanaka", "age"=>22} permitted: true>
permitted.permitted? # => true

Person.first.update!(permitted)
# => #<Person id: 1, name: "Tanaka", age: 22, born: "saitama">

例えば、上のコードのようにフロントエンドからformオブジェクトのnameage以外にデータが送信されてもparams.require(:form).permit(:name, :age)と記述することでpermittedにはnameageパラメータの値しか格納されません。なのでPerson.first.update!(permitted)のようにモデルのデータの属性に対する値を一括更新してもnameage(permitメソッドで許可した値)しか更新されなく、安心です。
一番最後の更新結果を見ていただくとわかりますがPersonクラスのborn属性は送られた値(tokyo)で更新されていません。saitamaのままです。

2, 一般的なバリデーション処理の方法

paramsに格納されたパラメータを取り出したり、必要なものを絞ったりすることは出来ました。しかし、フロントエンドから送られたパラメータは常に期待されるものが送られてくるとは限らないのでバリデーションを通して検証する必要があります。

そんな時、モデルクラスにバリデーションを記述する方法がRailsガイド等でも紹介されています。(参照:https://railsguides.jp/active_record_validations.html)

モデルクラスに属性に対するバリデーションを記述することで、モデルクラスがデータをcreatesaveupdateする際に不正なデータがデータベースに保存されることを事前に防ぐことができます。
テーブルに対して1体1の関係であるモデルクラスにバリデーションを書けば良いだけなので簡潔に実装できます。

例2-1:モデルクラスにバリデーションを実装

class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true
  validates :password, presence: true, length: { minimum: 10 }
end

しかしメリットもある一方でデメリットもあります。

上のUserモデルでは名前(name)、メールアドレス(email)、パスワード(password)に対してそれぞれバリデーションを行うように記述されています。
先ほど述べたように簡潔にバリデーションを実現できていますが、実現したいユースケースによってはバリデーションの制約内容が変わる可能性がある為、バリデーションがトリガする条件を追加しないといけなくなります。

実装することは可能ですが下記コード例(例2-2:肥大化したモデルクラス)のように可読性も落ちそうですし、改修を加えたら他のユースケースに影響を与えてしまった、なんてこともありえます。

例2-2:一つのモデルクラスを複数ユースケースで使い回す懸念

例2-1:モデルクラスにバリデーションを実装で使用したコードに新たに他のユースケースを考慮した実装を追加しました。
下記コードを見ていただくとわかる通り、Userを①登録する際にのみ発火するバリデーションと②パスワード変更の両方で発火するバリデーションが存在しています。

2つのユースケースで行うバリデーションの差異を上げますと
①の場合は「passwordが空欄でも可、空欄でないなら10文字以上」となっていますが、
②の場合は「passwordは必ず10文字以上」でないといけない
となっています。

1つのモデルに複数のユースケースを考慮したバリデーションが実装する必要があるので単一責任の法則からの観点で考えてあまり好ましくはなさそうですね。

class User < ApplicationRecord
  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true
  validates :password, length: { minimum: 10 }, allow_blank: true,  on: :create # ①登録する際にのみ発火するバリデーション
  validates :password, length: { minimum: 10 }, on: :change_pass # ②パスワード変更のみ発火するバリデーション
end

ただ、モデルにバリデーションを実装するのはバリデーションが一つのクラスに集約されているので、改修スピードは上がると思われますし、単純で多くのユースケースに使用されていないモデルクラスであればモデルクラスに実装してしまうのが良いと思われます。

ケースバイケースで対応を変えていくのが良いと個人的には思っています。

3.ヴァージニアでのパラメータ処理の方法

さて、これまでは一般的なパラメーター、バリデーションの処理を書いてきましたが、ここからはヴァージニアの実装方針について書いていきます。

ヴァージニアではフロントエンドからバックエンドのAPIを呼び出しており、paramsに格納されたパラメータはバックエンド側でバリデーションを実施しています。
また、API呼び出しする前にユーザーにエラー情報を掲示する目的で、フロントエンド側でも実施が可能なものについてはバリデーションを行っています。

ヴァージニアではパラメータクラスを導入し、コントローラ層で受け取ったパラメータをパラメータクラスに受け渡して初期化しています。パラメータクラスの中身は下記のようになっています。

例3-1:パラメータクラス

例2-2:一つのモデルクラスを複数ユースケースで使い回す懸念で使用した肥大化したモデルクラスをそれぞれユースケース単位に分離し、パラメータクラスとしました。
下記のRegisterUserParameterクラスはUser情報を登録・更新するユースケースでのみ使用するパラメータを格納する為のクラスです。
ChangePasswordParameterクラスはUser情報のパスワードを更新するユースケースで使用するパラメータクラスです。

class RegisterUserParameter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :password, :string
  
  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true
  validates :password, length: { minimum: 10 }, allow_blank: true

  def initialize(params)
    super(params.permit(self.class.attribute_names))
  end
end
class ChangePasswordParameter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :password, :string

  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true
  validates :password, length: { minimum: 10 }

  def initialize(params)
    super(params.permit(self.class.attribute_names))
  end
end

一見、ファイルの中身が同じように見えますがvalidates passwordの部分をよく見てみるとRegisterUserParameterにのみallow_blank: trueが記述されています。

ファイルの数が多くなってしまったのでモデルクラスにまとめて書いていた頃よりも対象のパラメータクラスを見つけることが難しくなったかもしれませんが
ファイルをそれぞれ見てみると、ユースケース毎に使用したいパラメータが見やすくなったと思います。(attributeメソッドに渡しているシンボルを見てください。)
またバリデーションもユースケースをまたがって記述されていないのでどんな検証が必要なのか分かり易くなりました。

実際の使用例を見てみましょう。
下記はコントローラクラスのアクションメソッド内でparams処理を行っている箇所です。

例3-2:パラメータクラスをコントローラクラスで使用する

ユースケースに合わせて、それぞれのアクションメソッド内でRegisterUserParameterChangePasswordParameterを使い分けています。

paramsを基にそれぞれのクラスで初期化し、初期化した後に検証を行なっています。(parameter.validate!

class  UserController
  def register_user
    regist_user_parameter = RegisterUserParameter.new(params)
    regist_user_parameter.validate!

    # ...中略
  end

  def change_password
    change_pass_parameter = ChangePasswordParameter.new(params)
    change_pass_parameter.validate!

    # ...中略
  end
end

先ほども述べたように必要なパラメータがユースケース毎に分離しているのでregist_user_parameterchange_pass_parameterにはどんな値が入っているのか分かり易いですね。
RSpecもパラメータクラス毎に書けば良いのでシンプルに楽になります。

4, コードの解説

それではこれらのパラメータクラスがどのような挙動をしているのか大まかに実装を覗いてみます。もう一度パラメータクラスのコードを記載しておきます。

例4-1:User情報を登録・更新する際に使用するパラメータクラス

class RegisterUserParameter
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :password, :string
  
  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true
  validates :password, length: { minimum: 10 }, allow_blank: true

  def initialize(params)
    super(params.permit(self.class.attribute_names))
  end
end

上から見ていくと、まずActiveModel::AttributesActiveModel::Modelをインクルードしていますが、これらのモジュールをインクルードすることでattributeメソッドやvalidatesメソッド等が使用できるようになります。

先にざっとこれら2つのモジュールの役割を話すと
ActiveModel::Attributesは属性に対してattr_accessorと同様の記法で、値の代入・参照をするために必要なモジュールです。また値に対して型を指定し、キャストすることができます。これはattr_accessorにはないActiveModel::Attributes独自の機能です。

ActiveModel::Modelは代入された値に対してvalidationと代入したい値をハッシュ形式で渡すことができます。

以上、2つのモジュールによってパラメータから受け取った値をパラーメタクラスに保持し、validationを実施したり、保持した値を読みこむことができるようになります。

それではActiveModel::Attributesをインクルードすることで使用できるようになるattributeメソッドとinitializeメソッドがどのような実装になっているのか見ていきたいと思います。
validatesメソッドに関してはActiveRecordで使用されているものとほぼ、変わらないのでここでは割愛します。

validatesメソッドはActiveModel::Modelによってvalidationが可能となっていますがモジュール内部でActiveModel::APIを、さらに内部でActiveModel::ValidationsをインクルードしているのでActiveRecordvalidatesメソッドと同じ機能を実現できるようになっています。

attributeメソッド

  attribute :name, :string
  attribute :email, :string
  attribute :password, :string

こちらのクラスの属性に当たるnameemailpasswordをそれぞれシンボルでattributeメソッドに渡しています。
このメソッドはActiveModel::Attributesモジュールにあるattributeメソッドを使用しています。下記コードが該当部分になります。
https://github.com/rails/rails/blob/c5929d5eb55b749bc124b3ccc2d79323d015701f/activemodel/lib/active_model/attributes.rb#L19-L27

このメソッド内の25行目のdefine_default_attributeメソッドで各属性の情報を格納するインスタンス(ActiveModel::AttributeSet)を作成しています。
今回の例で言うとnameemailpasswordに対応したインスタンスが作られていきます。

26行目のdefine_attribute_methodメソッドは値を読み込むgetterメソッドと代入するsetterメソッドを作り出しています。(属性nameに対してnameメソッドとname=メソッドを作っています。emailpasswordについても同様です。)
setterメソッドによって25行目のdefine_default_attributeメソッドで作成したインスタンス(ActiveModel::AttributeSet)に値を代入し、getterメソッドによって代入した値を読み込みます。

これらのメソッドによりRegisterUserParameterクラスを初期化した後、各属性に対して値の代入、読み込みが実現できるようになります。

initializeメソッド

  def initialize(params)
    super(params.permit(self.class.attribute_names))
  end

次にinitializeメソッドを見ていきます。

引数のparams.permit(self.class.attribute_names)部分で、パラメータを制限しています。
params.permitについてはこのブログの冒頭でも触れたので省略します。self.class.attribute_namesメソッドを見てください。
このメソッドはActiveModel::Attributesクラスのものです。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activemodel/lib/active_model/attributes.rb#L104-L118

104行~115行にも書いてありますがattributeメソッドに渡している:name:ageをそれぞれ文字列にして配列にして返却しています。

RegisterUserParameterクラスに当てはめるとnameemailpasswordがそれぞれ文字列で配列になって返却しているわけです。
よってparams.permit(self.class.attribute_names)は下記のような形になってsuperActiveModel::Attributesクラスのinitialize)メソッドに渡されます。

super(params.permit(["name","email","password"]))super({"name"=>nameパラメータの値, "email"=>emailパラメータの値, "password"=>passwordパラメータの値})

superメソッドの参照先は下記になります。
https://github.com/rails/rails/blob/c5929d5eb55b749bc124b3ccc2d79323d015701f/activemodel/lib/active_model/attributes.rb#L77-L80
@attributesには先ほど言及しましたActiveModel::Attributesattributeメソッドで作成したインスタンス(ActiveModel::AttributeSet)が格納されています。
次の行でさらにsuperメソッドが呼び出されていますがこちらはActiveModel::Modelinitializeを呼んでいます。superメソッドに引数が明記されていませんが暗黙的にinitialize(*)メソッドの引数(*)が渡されています。
https://github.com/rails/rails/blob/c5929d5eb55b749bc124b3ccc2d79323d015701f/activemodel/lib/active_model/model.rb#L70-L84
70行目から79行目に使用例が記載されています。
内部のassign_attributesメソッドで以前に生成したsetterメソッド(=name=email=passwprd)を呼び出し値を代入しています。

冒頭にも申し上げた通り、これでActiveModel::Attributesattributeメソッドによって属性を格納する為のインスタンス(ActiveModel::AttributeSet)とアクセスするためのgetter、setterメソッドを定義し、ActiveModel::Modelinitializeによって
setterメソッドを通して値を代入し、初期化を行っています。

5, パラメータクラスを使用することによるメリット・デメリット

今まで、パラメータクラスを作成することによるメリットを中心に書いてきましたが、使用する上で認識しておいたほうが良いデメリットと共に最後に纏めたいと思います。

メリット

  • ユースケース毎にファイルが分かれているのでspecを書くのが簡単になる。
  • 複数のユースケースで一つのクラスを使い回すことがないのでコードの可読性が上がる。それぞれの値に対してどんなバリデーション制約を行っているのか分かり易い。
  • paramsに格納されたパラメータから何の値を取り出して使用しているのか一目瞭然。

デメリット

  • パラメータクラスを使用せず、モデルクラスのみ使用する処理を行う場合、対応するパラメータクラスの制約内容を見ないと整合が取れない。(例えばジョブの実行など)
  • チェックしたいパラメータや制約内容がユースケース毎に違う場合に都度、クラスファイルを作成する為ファイル数が増えやすい。

まとめ

今回は一般的に行われているパラメータ処理、バリデーション制約とヴァージニアで行っているそれらを比較し、互いのメリットデメリットについて言及してきました。

モデルクラスにて受け取ったパラメータを用いて初期化、バリデーションを行うことは開発の速度を上げるメリットがある一方、複数のユースケースの渡る処理を行う際はクラスが肥大化しやすい問題がありました。

一方、パラメータクラスを作成する場合、各ユースケースで使用したいパラメータが何なのか、各値に対してどんな制約が課されているのか一眼でわかりやすくなりました。しかしモデルクラスにはバリデーション等を書かないため、パラメータクラスを使用しない処理を実行する場合、別途制約を実装する必要があります。またユースケース毎に使用したいパラメータの値、バリデーションの制約が異なればその分、クラスファイルが増えるのでファイルの数が膨大になります。

ヴァージニアではパラメータクラスを用いることのデメリットを許容していますが、個人的にもモデルクラスが肥大化しやすいことに比べれば上記で挙げたデメリットは比較的軽いのかなと思っています。

Discussion

ログインするとコメントできます