はじめに
携わるプロジェクトでElixir/Phoenix
を開発している中で、リクエストパラメータのハンドリングがなかなかしっくり来ず、paramsというライブラリを導入したらだいぶ幸せになったので、その知見をお裾分けできればという思いで記事に起こしてみます。
と順を追って話していくので、paramsの使い方をすぐに知りたい方は、paramsの紹介からどうぞ!!
※何か認識間違っているところがあれば、バンバンご指摘頂けると嬉しいです!!
Elixir/Phoenixでのリクエストの受け取り方の基本
Elixir/PhoenixでRequestパラメータを受け取る際、
- Pathパラメータ
- Queryパラメータ
- Bodyパラメータ
といったパラメータはすべてControllerの action/2
メソッドの第2引数である params
で受け取ることとなります。(第2引数のparams
の実体はConn.params
なので、もちろん第1引数のconn
からも取得することができます)
その際、サンプル通りに実装すると、以下のようにパラメータを受け取ることが一般的です。
- 必須パラメータはパターンマッチで取得
- 任意のパラメータは
params
というmap
に文字列キーでアクセスして取得
defmodule HogeWeb.HogeController do
...
# 必須パラメータの受け取り
def hoge(conn, %{"hoge" => hoge} = params) do
# 任意パラメータの受け取り
fuga = params["fuga"]
...
end
end
この時点では、何の問題もなさそうです。
ただ、実際にプロジェクトを運用をしていくと、つらいところがチラホラ出てきます。次から、その例を見ていきましょう
既存のリクエストパラメータハンドリングのつらいところ
自分が感じた既存のリクエストパラメータハンドリングのつらいところは以下のようなところです。
- 必須パラメータが増えた時にControllerがごちゃごちゃする
- 任意パラメータの受け取りが曖昧になる
- パラメータの型がバラバラ
【つらみ1】 必須パラメータが増えた時にControllerがごちゃごちゃする
上記のサンプルくらいシンプルならいいのですが、実際のAPIのリクエストパラメータは一定以上増えることがよくあります。
例えば、名前や電話番号、住所などを入力してもらうフォームを考えると、Controllerのコードは以下のようになるでしょう。(コードの中身自体は超適当です。)
def create(conn, %{"family_name" => family_name, "first_name" => first_name, "phone_number" => phone_number, "zip_code" => zip_code, "prefecture" => prefecture, "city" => city, "address" => address} = params) do
middle_name = params["middle_name"]
user_address = UserAddressService.create(family_name, first_name, ...)
conn
|> json(user_address)
end
かなりパラメータ数が多く、一つ減った増えたの認知がものすごくしづらいです。
【つらみ2】 任意パラメータの受け取りが曖昧になる
上記のつらみ1の対応として、paramsのまま、別モジュールに引き渡すという手もできます。
たとえば、上記のコードでUserAddressService.create
の引数をparamsにまとめると、以下のようになります。
def create(conn, params) do
user_address = UserAddressService.create(params)
conn
|> json(user_address)
end
@spec create(map) :: UserAddress.t
def create(params) do
%{
"family_name" => family_name,
"first_name" => first_name,
"phone_number" => phone_number,
"zip_code" => zip_code,
"prefecture" => prefecture,
"city" => city,
"address" => address
} = params
...
middle_name = params["middle_name"]
end
どうでしょうか?
Controllerはスッキリしたし、悪くないように見えますが、
UserAddressService
では「paramsが何のキーを持っているのか?」「このキーは果たして正しいのか?」というのはコードを読んだだけでは読み取れず、修正時にはAPIのインタフェース定義書等を別途見て、確認する必要が出てきます。(ControllerのparamsとAPIのインタフェース定義書を突き合わせるのは、個人的に許せるのですが、階層構造の設計でService層とかにあるものを突き合わせるのは個人的に気持ちわるいです。
また、今回は1つのメソッドで値を抜き出しているだけなので、あまり影響がないですが、これが複数のメソッドなどを跨いで値を取得するようになると、何のキーがあるかはどんどんわからなくなっていってしまいます。
【つらみ3】パラメータの型がバラバラ
Controller
で受け取ったparamsの値を素直に使おうとすると、パラメータの型がStringだったり、Intだったり、バラバラな事に気づきます。
requestパラメータを受け取った時の型としては、以下のように変換されます。
パラメータ種別 | 受け取る時の型 |
---|---|
Bodyパラメータ(JSON) | JSONの型に準ずる |
Bodyパラメータ(Form) | すべて文字列 |
Pathパラメータ | すべて文字列 |
Queryパラメータ | すべて文字列 |
JSON以外はParserとかを通している訳ではないので、そりゃそうなんですが、これがなかなか不便です。
Controller
で受け取ったパラメータを一旦そのままDBに突っ込んでしまうような薄い構成のアプリケーションでは、Ecto.Schema
、及びEcto.Changeset
が自動で変換してくれるためあまり問題にならないのですが、受け取った値でロジックを処理しなければならない場合、問題となります。
例えば、標準で容易されているString.to_integer
は、数字以外の文字列はもちろん、文字列以外のものを入れてもエラーを吐いて終了します。
DBに入れる前に数値比較でバリデーション処理等を行っている場面では、受け取った型を調べ、問題なければ変換する。といった処理が必要になり、非常に面倒です。
これらの辛いところをparamsを用いて解決します。
paramsの紹介
paramsとは
Easy parameters validation/casting with Ecto.Schema, akin to Rails' strong parameters.
「Ecto.Schemaを用いたパラメータの簡単なバリデーションと変換」と書かれていますが、その名の通りのライブラリです。
内部的にはPhoenix
のエコシステムの一環であり、実績のあるEcto
ライブラリが利用されており、paramsはより簡単で簡潔な記法で、Ectoの機能を利用することができます。
そのため動作にも信頼ができますし、ここで得た知見はEctoを用いたDB操作前のバリデーションにも流用することができます。
こちらの記事を書く時点でのバージョンはv2.1.1になります
Ecto
を用いた課題解決
もちろん、paramsは内部的にEcto
を使用しているので、Ecto
を用いても、つらみは解決できます。
過去の記事にもElixirで値のバリデーションをEctoで行う
というエントリーがありました。
実装例に関しては、paramsのAboutを引用します。
※ちゃんと読みたい方は上記を直接お願いします。
例として、シンプルなUserオブジェクトをリクエストパラメータとして扱うなら、以下のように書くことができます。
# Ecto.Schemaを定義
defmodule MyApp.User do
use MyApp.Web, :model
schema "users" do
field :name, :string
field :age, :integer
end
@required [:name]
@optional [:age]
def changeset(changeset_or_model, params) do
cast(changeset_or_model, params, @required ++ @optional)
|> validate_required(@required)
end
end
# Controllerでの利用
def create(conn, params) do
ch = User.changeset(%User{}, params)
if ch.valid? do
...
end
ここまでは何も問題がないです。
ただし、Ectoを純粋に用いた場合、複雑なJsonを受け取るのに苦労をします。
以下のようなJsonを受け取ることを想定すると、
{
"breed": "Russian Blue",
"age_min": 0,
"age_max": 5,
"near_location": {
"latitude": 92.1,
"longitude": -82.1
}
}
以下のようにコードを書く必要があります。
# ネストしたオブジェクトの定義
defmodule MyApi.Params.Location
use Ecto.Schema
import Ecto.Changeset
@required ~w(latitude longitude)
@optional ~w()
schema "location params" do
field :latitude, :float
field :longitude, :float
end
def changeset(ch, params) do
cast(ch, params, @required ++ @optional)
|> validate_required(@required)
end
end
# ルートオブジェクトの定義
defmodule MyAPI.Params.KittenSearch
use Ecto.Schema
import Ecto.Changeset
@required ~w(breed)
@optional ~w(age_min age_max)
schema "params for kitten search" do
field :breed, :string
field :age_min, :integer
field :age_max, :integer
embeds_one :near_location, Location
end
def changeset(ch, params) do
cast(ch, params, @required ++ @optional)
|> validate_required(@required)
|> cast_embed(:near_location, required: true)
end
end
# Controllerでの利用
def search(conn, params) do
alias MyAPI.Params.KittenSearch
changeset = KittenSearch.changeset(%KittenSearch{}, params)
if changeset.valid? do
...
end
求める機能は実現できますが、ちょっと複雑です。
この機能をparamsを用いて、簡単に導入します。
paramsを用いた実装
※paramsのUsageのコードを引用して、簡単に解説したものです。
defparams
上記の実装は以下のように書けます。
めちゃくちゃ簡単です。
defmodule MyAPI.KittenController do
use Params
defparams kitten_search %{
breed!: :string, # 必須パラメータ
age_max: :integer, # 任意パラメータ
age_min: [field: :integer, default: 1], # default値の定義
near_location!: %{ # 入れ子の定義
latitude!: :float, longitude!: :float
},
tags: [:string] # 配列の定義
}
def index(conn, params) do
changeset = kitten_search(params)
if changeset.valid? do
search = Params.data changeset
IO.puts search.near_location.latitude
...
end
end
defparams
はParams.Schamaを生成するマクロです。
なお、defparamasの引数は、mapです。keyとvalueは以下のようになっています。
値 | 説明 |
---|---|
key | field名のatom 末尾に"!"をつけると必須パラメータとして扱われる |
value | 基本はEcto Typeのatom defauld値を設定したい場合は、 [field: :integer, default: 1] のように配列で宣言する |
Moduleでの定義とSchemaの利用
defparams
を用いる他に、モジュールを作成してその中にスキーマやカスタムバリデーション用のメソッドを利用できます
defmodule UserSearch do
use Params.Schema, %{name: :string, age: :integer}
import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]
def child(ch, params) do
cast(ch, params, ~w(name age))
|> validate_inclusion(:age, 1..6)
end
end
defmodule MyApp.UserController do
def index(conn, params) do
changeset = UserSearch.from(params, with: &UserSearch.child/2)
if changeset.valid? do
# age in 1..6
end
end
child/2
メソッドはEcto.Changeset
を第1引数に取り、第2引数のparams
はconn.params
になり、Ecto.Changeset
を返すメソッドです。
Ecto.Changeset
を使っているので、その機能に含まれたバリデーションは全て利用することができます。値の範囲、文字数の最小、最大、文字列の正規表現といった用意されたバリデーションから、カスタムバリデーションや、入力値の加工(空白文字の削除)などもここで実装できます。
ここでその実装方法については触れないので、公式のEcto.Changeset
を参照ください。
Changesetから構造体やマップに変換する方法については、以下のメソッドが提供されています。
上記を用いることで、バリデーション後のchangesetを扱いやすいdata型に変更できます。
defmodule UserUpdateParams do
use Params.Schema, %{
name: :string,
age: :integer,
auditlog: [field: :boolean, default: true]
}
end
changeset = UserUpdateParams.from(%{name: "John"})
Params.data(changeset) # => %UserUpdateParams{name: "John", age: nil, auditlog: true}
Params.to_map(changeset) # => %{name: "John", auditlog: true}
このように、非常に簡潔な実装ながら、最初に紹介した既存のリクエストパラメータハンドリングのつらいところを全て解決しています。
- 必須パラメータの数が増えてもSchema定義されているので見やすい
- 任意パラメータも定義してあるし、
!
の有無で必須パラメータとの違いが見やすい - 型のCastはEctoの作法に則ってよしなにやってくれる
(ついでに)
- 必須パラメータが欠けている時にも
changeset.valid?
でハンドリングできるので、400等の適切なハンドリングがしすい -
Ecto.Changeset
を使っているので、カスタムバリデーションもエスケープ処理も自由自在
と良いことずくめです!
Paramsライブラリを使う際の注意点
最後に、paramsライブラリは非常に便利なのですが、バリデーション条件を追加するカスタムバリデーションを利用する時には、少し注意が必要です。
サンプル通りに実装し、changeset = UserSearch.from(params, with: &UserSearch.child/2)
のようにfrom
メソッドの第2引数に:with
をキーにメソッドを設定した場合、既存のバリデーション機能が一部うまく動きません。
うまく使えない機能は以下の2点です
- "!"をつけて指定していた必須か否かのバリデーションが自動で効かなる
- 入れ子のバリデーションが自動で効かなくなる
なぜだろうと思いParams.from
の内部実装を見てみると、withに関数を指定すると、on_castが完全にその関数に置き換わるためでした。
def from(params, options \\ []) when is_list(options) do
on_cast = Keyword.get(options, :with, &__MODULE__.changeset(&1, &2))
__MODULE__ |> struct |> Ecto.Changeset.change |> on_cast.(params)
end
既存の必須パラメータなどのバリデーションを活かしつつ、カスタムでバリデーションを足したい場合、以下のように、Params.changeset
を使用することで、デフォルトのバリデーションを呼び出すことが可能です。
こうすることで、デフォルトのバリデーションと追加のバリデーションをうまく共存させることができます。
defmodule UserSearch do
use Params.Schema, %{name: :string, age: :integer}
import Ecto.Changeset, only: [cast: 3, validate_inclusion: 3]
def child(ch, params) do
changeset(ch, params) #もしくは、Params.changeset(ch, params)
|> validate_inclusion(:age, 1..6)
end
end
※ 尚、!
等を使ったデフォルトのバリデーションのエラー文言は基本的に英語で出力されますので、日本語のエラー文言を出力したい!という場合は、changeset
ではなく、cast
を利用することをおすすめします!
最後に
自分がparamsライブラリを利用して流れを、理由から一通り書いてみました!
paramsライブラリは非常に簡潔な記法で十分に検証されているEcto
の機能を使うことができる非常に便利なライブラリです。
Elixir/Phoenix
でパラメータハンドリングに悩んでいる方は、是非検討してみてください!