先日使ってみたいモジュールがあるからとこんな記事を書きました。
まぁ記事と言っても殴り書きの自習ノートみたいなもんですが。
で、使うには十分な理解度に到達したと思うので、使いたかったData.Either.Validation
を使ってみます。
使おうとしたら全然使用例みつからないし日本語の記事も全くと言って良いほど見当たらなかったので、せっかくだしまとめてみます。
どんなもの?
一言で言うと、全部のエラーを溜められるEither
です。
ちなみにインストールは不要です。
これがEither
のApplicative
の実装です。
一度失敗したらそれ以降が何であろうと、その失敗を保持し続けます。
instance Applicative (Either e) where
pure = Right
Left e <*> _ = Left e
Right f <*> r = fmap f r
動かしてみます。
(+) <$> Right 1 <*> Right 2
Right 3
(+) <$> Left "error 1" <*> Right 2
Left "error 1"
(+) <$> Left "error 1" <*> Left "error 2"
Left "error 1"
最後に得られるのは最初の失敗だけです。
それに対してValidation
の実装はこうです。
2つめ以降の失敗の場合に連結しています。
instance Semigroup e => Applicative (Validation e) where
pure = Success
Failure e1 <*> Failure e2 = Failure (e1 <> e2)
Failure e1 <*> Success _ = Failure e1
Success _ <*> Failure e2 = Failure e2
Success f <*> Success a = Success (f a)
こちらも動かしてみます。
import Data.Either.Validation
(+) <$> Success 1 <*> Success 2
Success 3
(+) <$> Failure ["error 1"] <*> Success 2
Failure ["error 1"]
(+) <$> Failure ["error 1"] <*> Failure ["error 2"]
Failure ["error 1","error 2"]
実例イメージ
4つのパラメータを受けて、全てのバリデーションが通ったら購入処理を行ってみます。
import Text.Printf
purchase :: String -> String -> String -> Int -> String
purchase userId mailAddress itemName itemCount = printf "ordered [userId: %s, mailAddress: %s, itemName: %s, itemCount: %d]" userId mailAddress itemName itemCount
手抜きで文字列連結をするだけですが、今回の本体です。
どこにもバリデーションに関することは現れていません。
purchase "user-1" "foo@bar.com" "item-1" 3
"ordered [userId: user-1, mailAddress: foo@bar.com, itemName: item-1, itemCount: 3]"
ただの値を渡すとただの値が返ってくる関数です。
さて、この4つのパラメータのバリデータを書いてみます。
import Data.Either.Validation
validateUserId :: String -> Validation [String] String
validateUserId value = if value /= ""
then Success value
else Failure ["UserId: empty not allowed"]
validateMailAddress :: String -> Validation [String] String
validateMailAddress value = if '@' `elem` value
then Success value
else Failure ["AailAddress: no atmark"]
validateItemName :: String -> Validation [String] String
validateItemName value = if value /= ""
then Success value
else Failure ["ItemName: empty not allowed"]
validateItemCount :: Int -> Validation [String] Int
validateItemCount value = if value /= 0
then Success value
else Failure ["ItemCount: zero not allowed"]
1項目につき1つのルールでチェックします。
validateUserId ""
Failure ["UserId: empty not allowed"]
validateUserId "user-1"
Success "user-1"
validateMailAddress "foo.bar.com"
Failure ["AailAddress: no atmark"]
validateMailAddress "foo@bar.com"
Success "foo@bar.com"
validateItemName ""
Failure ["ItemName: empty not allowed"]
validateItemName "item-1"
Success "item-1"
validateItemCount 0
Failure ["ItemCount: zero not allowed"]
validateItemCount 3
Success 3
本文とバリデータは揃いました。
あとはバリデート結果が全部Success
かチェックして、その時だけSuccess
が保持している値を引っ張りだして本文を呼ぶ...
なんて面倒な事はしませんよ!
Validation
はApplicative
なので、ただの値を受け取るpurchase
をそのまま使います!
purchase <$> validateUserId "user-1" <*> validateMailAddress "foo@bar.com" <*> validateItemName "item-1" <*> validateItemCount 3
Success "ordered [userId: user-1, mailAddress: foo@bar.com, itemName: item-1, itemCount: 3]"
purchase <$> validateUserId "" <*> validateMailAddress "foo@bar.com" <*> validateItemName "item-1" <*> validateItemCount 0
Failure ["UserId: empty not allowed","ItemCount: zero not allowed"]
全てのバリデーションが通ったときはpurchase
の結果が、1つ以上のバリデーションエラーがある場合は出たエラー全てが得られます!
これすごくない!?
purchase
自体の単体テストをする場合なんかはそのままString
とかを渡して、プロダクトコードで使うときはValidation
を渡す(風)に使えちゃいます。
エラーがあったら〜、なかったら〜、という処理はHaskellの標準ライブラリで実現しているのでテスト書かないで良いでしょう。
バリデータと本文の単体テストだけをすれば良いんです!
なんて簡単!
型注釈とif
の改行を考えないで実質行数で考えると5行しか書いてないです!
以上でData.Either.Validation
の紹介を終わります。
ぜひ触ってみてください、感動しますよ!
余談
printf
って引数の数とか型を間違えると実行例外になっちゃうんですね。
Haskellで実行例外出すとものすごーく損した気持ちになるので今後はちゃんと作るときは使わない様にしようと思いました。
おまけ
せっかくValidation
は複数のエラーを全部返せるのだから、1つのバリデータにつき1つのルールじゃあなくて複数ルールにしたい。
必須とか文字長は独立部品として用意してみます。
notNull :: String -> String -> Validation [String] String
notNull name value = if value == "" then Failure [printf "%s: not null" name] else Success value
len :: Int -> String -> String -> Validation [String] String
len size name value = if length value /= size then Failure [printf "%s: length must be %d" name size] else Success value
ルールと呼ぶ事にします、こうやって使います。
バリデータから独立させたので、バリデータ名と値を受けてValidation
を返します。
notNull "UserId" ""
Failure ["UserId: not null"]
len 6 "UserId" "abc"
Failure ["UserId: length must be 6"]
さて、これらの部品を全て呼んで、エラーが混じっていたらエラー全てを、正常なら値を返さなければなりません。
こう書いてみました。
validateUserId :: String -> Validation [String] String
validateUserId value = (\v _ -> v) <$> notNull "UserId" value <*> len 6 "UserId" value
エラーがあったら〜、なかったら〜、はそのままさっきのApplicative
と同じ様に出来ますが、Success
の場合の値はどうしたら良いでしょうか...
全てがSuccess
の場合は、全てのSuccess
に同じ値(value)が入っているので、ラムダ式で適当に1つだけを取り出してみました。
validateUserId "user-1"
Success "user-1"
validateUserId ""
Failure ["UserId: not null","UserId: length must be 6"]
とりあえずは正しそうです。
けどこれだとルールを増やした場合にラムダ式の引数も増やさないといけないので、イケてない感があります。
どうしようか考えていたところで、先日読んだH本のリストのApplicative
を思い出しました。
あれは確か1引数関数を総当たりで適用出来る感じだったはず...
[(+2), (+3)] <*> [5]
[7,8]
お、これ良さそうなのでは?
[notNull "UserId", len 6 "UserId"] <*> [""]
[Failure ["UserId: not null"],Failure ["UserId: length must be 6"]]
[notNull "UserId", len 6 "UserId"] <*> ["user-1"]
[Success "user-1",Success "user-1"]
うん、これで進めてみよう。
けどその前に細かいですが、何回もバリデータ名を書くのは嫌だし見通しも悪いので、map
を使ってまとめて部分適用するようにちょっとだけ改造します。
map (\f -> f "UserId") [notNull, len 6] <*> ["user-1"]
[Success "user-1",Success "user-1"]
あとは...やっぱり[Validation]
を全部のエラーか成功値にまとめないといけないよね...
こんな感じで畳み込んでみようかな...
flat :: Validation [String] String -> Validation [String] String -> Validation [String] String
flat (Success x) (Success y) = Success x
flat (Success x) (Failure y) = Failure y
flat (Failure x) (Success y) = Failure x
flat (Failure x) (Failure y) = Failure $ x ++ y
foldl1 flat $ map (\f -> f "UserId") [notNull, len 6] <*> ["user-1"]
Success "user-1"
foldl1 flat $ map (\f -> f "UserId") [notNull, len 6] <*> [""]
Failure ["UserId: not null","UserId: length must be 6"]
よし、Success
か全てのFailure
に平らに出来てます。
こんな感じで名前をつけてバリデータ名とルールと値を渡すだけで良い様にしておきます。
validate :: String -> [(String -> String -> Validation [String] String)] -> String -> Validation [String] String
validate name rules value = foldl1 flat $ map (\f -> f name) rules <*> [value]
これで各バリデータの実装がとてもすっきりして宣言的になりました!
validateUserId :: String -> Validation [String] String
validateUserId = validate "UserId" [notNull, len 6]
最後に、下のコードとかを見るとString
が多すぎてよくわからなくなってきたのでもう一工夫します。
validate :: String -> [(String -> String -> Validation [String] String)] -> String -> Validation [String] String
型シノニムを使ってみます。
全部String
ではあるのですが、エイリアスを付けて別名で書ける様にします。
type FormName = String
type Value = String
type Error = String
type Validated = Validation [Error] Value
type Rule = FormName -> Value -> Validated
こうするだけで大分読み易くなります。
(最初掲載したコードにInt
の部分がありましたが、WebAPIのパラメータは全部String
なのでまとめてしまいました)
notNull :: Rule
len :: Int -> Rule
flat :: Validated -> Validated -> Validated
validate :: FormName -> [Rule] -> Value -> Validated
validateUserId :: Value -> Validated
これでとても読み易くなりました!
バリデーション部分の最終成果物はこんな感じです。
それではまたノシ
import Text.Printf
import Data.Either.Validation
type FormName = String
type Value = String
type Error = String
type Validated = Validation [Error] Value
type Rule = FormName -> Value -> Validated
notNull :: Rule
notNull name value = if value == "" then Failure [printf "%s: not null" name] else Success value
len :: Int -> Rule
len size name value = if length value /= size then Failure [printf "%s: length must be %d" name size] else Success value
flat :: Validated -> Validated -> Validated
flat (Success x) (Success y) = Success x
flat (Success x) (Failure y) = Failure y
flat (Failure x) (Success y) = Failure x
flat (Failure x) (Failure y) = Failure $ x ++ y
validate :: FormName -> [Rule] -> Value -> Validated
validate name rules value = foldl1 flat $ map (\f -> f name) rules <*> [value]
validateUserId :: Value -> Validated
validateUserId = validate "UserId" [notNull, len 6]