DDD初心者が拙いHaskellを使って色々考える試みです。
はじめに
先日DDDをHaskellで考える 失敗を表現するという記事を投稿しました。
今回はその中で「バリデーションはまたいつか」としておいた部分について考えます。
例えばAPIを作っていて、飛んできたパラメータの桁やフォーマットが正しく無い場合、それは例外でしょうか?
曖昧な表現ですが、受け取ってチェックする以上想定しているとも思えるし、外の世界との窓口はシステマチックな話とも思えます。
フォーム部品についてもいずれ試し書きをしてみたいと思っていますが、今は以下の様に
Eitherを使った関数にしたいと思っています。
理由はまた改めて述べる機会を作りたいと思います。
Haskellについて
手前味噌ですが僕が今回の試みにあたり軸に考えている部分を最初の投稿に載せています。
例外にしない理由
一言で言うと例外よりEitherの方が扱いやすいからです。
また、例外にしてしまうと最初に不備を見つけた時点で残りの検査が終わってしまうと考えました。
それだと複数の不備をまとめて指摘できないですし、出る例外がコードの順番に依存すると考えました。
Data.Either.Validation
Eitherで試し書きをしていたときに、Data.Either.Validationというモジュールがあることを教えてもらいました。
Validationは一言で言うと、全部のエラーを溜められるEitherです。
Eitherでは最初に出てきた失敗を保持し続けるのみですが、
(+) <$> Right 5 <*> Right 3
Right 8
(+) <$> Left ["error 1"] <*> Left ["error 2"]
Left ["error 1"]
Validationでは全ての失敗を連結してくれます。
(+) <$> Success 5 <*> Success 3
Success 8
(+) <$> Failure ["error 1"] <*> Failure ["error 2"]
Failure ["error 1","error 2"]
Validationの使い方や今回のコードの元ネタはHaskellのData.Either.Validationを使うにまとめてありますので、良ければご覧ください。
実例
いつもは素の.hsファイルですが、今回はstack newをしてみました。
テストの依存ライブラリにhspecを追加して、テストも書きます。
テストを書きながら小さな関数群を組み合わせていく様を表現できたら良いと思います。
例題
例によってお題を設けます。
今回は個人情報登録フォームから「氏・名」、「生年月日」、「メールアドレス」を受けて、「個人情報」を組み上げるのをお題とします。
バリデートの共通部品
最初に共通で必要になりそうな関数を作ります。
型シノニム
まず、型シノニムを使ってただの文字列にエイリアスを用意し、可読性を上げます。
type FormName = String
type Value = String
type Message = String
type Error = String
type Validated a = Validation [Error] a
type Rule = FormName -> Value -> Validated Value
先の記事とは少し違い、Validatedは型引数を取る様にしてみました。
Validated ValueやValidated FirstNameの様に使います。
ルール
次にチェック関数を沢山用意します。
今後これをルールと呼びます。
notEmpty :: Rule
notEmpty name value
| value /= "" = Success value
| otherwise = Failure $ mkErrors name value "empty string is not allowed"
lenIn :: Int -> Int -> Rule
lenIn x y name value
| length value `elem` [x..y] = Success value
| otherwise = Failure $ mkErrors name value $ "allowed length is " ++ show x ++ " to " ++ show y
バリデートとValueObject生成
最後に、バリデーションの実行とValueObjectの生成を行う部品を用意します。
parse :: FormName -> [Rule] -> (Value -> a) -> Value -> Validated a
parse name rules constructor value = constructor <$> validate name rules value
where
validate :: FormName -> [Rule] -> Value -> Validated Value
validate name rules value = head <$> sequenceA (map (\f -> f name) rules <*> [value])
少し長いですが、型シノニムのおかげで読めそうです。
適当なValueObjectを用意してそれで試してみましょう。
data FooId = FooId String deriving Show
Form.Validator.parse "FooId" [notEmpty, lenIn 3 5] FooId "foo-1"
Success (FooId "foo-1")
Form.Validator.parse "FooId" [notEmpty, lenIn 3 5] FooId "foo-123"
Failure ["FooId[foo-123]: allowed length is 3 to 5"]
Form.Validator.parse "FooId" [notEmpty, lenIn 3 5] FooId ""
Failure ["FooId[]: empty string is not allowed","FooId[]: allowed length is 3 to 5"]
とある1つのValueObjectに対して複数のルールでバリデーションしています。
失敗時は複数のエラーが、成功時はValidated FooIdが返されています。
(head <$> sequenceA ...の部分は[Validation [Error] Value]をValidation [Error] Valueに平らにしている部分です。
先の記事ではflatという関数で畳み込みましたが、その後もらった指摘を反映してsequenceAを用いています。)
stack ghciで動作確認をしていますが、REPLは本当に良いですね。
こういう単体の関数をたくさん作って動作確認する様な開発とREPLは本当に相性が良いです。
Test
ルールの部分は是非テストを書いておきましょう。
関数単体での動作を保証しておけばnotEmptyが正しく動くか確かめるために試しにAPIを呼んでみるなんて必要はありません。
spec :: Spec
spec = do
describe "notEmpty" $ do
it "success" $
notEmpty "FooForm" "foo" `shouldBe` Success "foo"
it "failure" $
notEmpty "FooForm" "" `shouldBe` Failure ["FooForm[]: empty string is not allowed"]
describe "lenIn" $ do
it "success" $
lenIn 2 3 "FooForm" "foo" `shouldBe` Success "foo"
it "failure" $
lenIn 2 3 "FooForm" "fooo" `shouldBe` Failure ["FooForm[fooo]: allowed length is 2 to 3"]
単一要素のValueObjectとForm
さて、ルールとそれを適用する関数が出来たので、今度はValueObjectとそれのFormを作りましょう。
ここでのValueObjectは単一の要素からなるとし、便宜上単一要素のValueObjectと言います。
またFormはValueObjectと基本的には1:1で存在するとします。
(異なるURLで同一のValueObjectに違うルールを適用する場合は違うFormを作ることになると思いますが)
ValueObject
これだけです。
module Domain.User.FirstName where
data FirstName = FirstName String deriving (Show, Eq)
Form
こちらもこれだけです、実質1行ですね。
module Form.User.FirstNameForm where
import Form.Validator as V
import Domain.User.FirstName
parse :: Value -> Validated FirstName
parse = V.parse "FirstName" [notEmpty, lenIn 3 8] FirstName
これだけで複数ルールのチェックと、全てのエラーの保持もしくはバリデーション済みValueObjectが手に入ります。
型をValue -> Validated FirstNameと表現できたところがちょっとポイントです。
Test
バリデート済みのFirstName、もしくは複数のエラーが得られることをテストします。
spec :: Spec
spec =
describe "parse" $ do
it "success" $
parse "John" `shouldBe` Success (FirstName "John")
it "failure empty value" $
parse "" `shouldBe` Failure [
"FirstName[]: empty string is not allowed"
, "FirstName[]: allowed length is 3 to 8"
]
複数要素のValueObjectとForms
単一要素のValueObjectは正しくバリデーション出来ました。
次は複数要素のValueObjectをバリデートしてみます。(こちらも便宜上そう呼んでいますが、不適切でしたら訂正します)
複数要素のValueObjectとは、例えば「氏と名から成るフルネーム」の様なValueObjectを指すとします。
ValueObject
これだけです。
LastNameは先述のFirstNameとほぼ同様の実装です。
module Domain.User.FullName where
import Domain.User.FirstName
import Domain.User.LastName
data FullName = FullName FirstName LastName deriving (Show, Eq)
FullNameは2引数関数で、FirstNameとLastNameを受けます。
FullName (FirstName "John") (LastName "Doe")
Forms
バリデート済みの複数要素のValueObjectを得るのは簡単です。
module Form.User.FullNameForms where
import Form.Validator
import Domain.User.FullName
import Form.User.FirstNameForm as F
import Form.User.LastNameForm as L
parse :: Value -> Value -> Validated FullName
parse first last = FullName <$> F.parse first <*> L.parse last
先にFullNameは2引数関数だと述べましたが、その関数にバリデート済みの氏と名を適用するだけです。
?.parseは例えばSuccess ValueObjectを返すので、読み替えるとこんな感じです。
FullName <$> Success (FirstName "John") <*> Success (LastName "Doe")
Success (FullName (FirstName "John") (LastName "Doe"))
これは2引数関数にValidationを適用しているので、冒頭の(+)の例と全く同じです。
(+) <$> Success 5 <*> Success 3
Success 8
また、細かいですが複数のFormを扱う場合はFormsと名付けています。
Test
FullNameFormsにはロジックは実質入っていない様なものなのでちょっと過剰な気もしますが、折角なので書いてみます。
spec :: Spec
spec =
describe "parse" $ do
it "success" $
parse "John" "Doe" `shouldBe` Success (FullName (FirstName "John") (LastName "Doe"))
it "failure empty last name" $
parse "John" "" `shouldBe` Failure [
"LastName[]: empty string is not allowed"
, "LastName[]: allowed length is 3 to 8"
]
it "failure empty both name" $
parse "kz" "" `shouldBe` Failure [
"FirstName[kz]: allowed length is 3 to 8"
, "LastName[]: empty string is not allowed"
, "LastName[]: allowed length is 3 to 8"
]
ちゃんとバリデート済みのフルネーム、もしくは出た全てのエラーが得られています。
行数は長いですが、大したことはしていません。
個人情報を組みあげる
複数要素のValueObjectは複数のValidated aからなるValidated aでした。
ですので、複数要素のValueObjectのネストも可能です。
ここまで来ればもうあとは消化試合です。
ValueObject
複数の色々なValueObjectから成ります。
module Domain.Registration.PersonalData where
import Domain.User.FullName
import Domain.User.BirthDate
import Domain.Mail.MailAddress
data PersonalData = PersonalData FullName BirthDate MailAddress deriving (Show, Eq)
(この記事には載せていませんが、FullNameはFirstNameとLastNameから成る複数要素のValueObject、MailAddressとBirthDateは単一要素のValueObjectです。
MailAddressは共通ルール以外の独自のルール定義を、BirthDateはString以外の要素をそれぞれ試したかったので書きました。
最終的な全容は最後にGitHubのリンクを掲載します。)
Forms
複数要素を扱うのでこれもFormsと呼びます。
module Form.Registration.PersonalDataForms where
import Form.Validator
import Domain.Registration.PersonalData
import Form.User.FullNameForms as F
import Form.User.BirthDateForm as B
import Form.Mail.MailAddressForm as M
parse :: Value -> Value -> Value -> Value -> Validated PersonalData
parse first last birth mail = PersonalData <$> F.parse first last <*> B.parse birth <*> M.parse mail
単一にしろ複数にしろ、?.parseはValidated aを返すので、いくらでもネストが可能です。
簡単かつシンプルですね。
Test
冗長すぎる気がしますが、一応書きます。
spec :: Spec
spec =
describe "parse" $ do
it "success" $ do
let full = FullName (FirstName "John") (LastName "Doe")
let birth = B.fromString "1990-01/23"
let mail = MailAddress "foo.bar@gmail.com"
parse "John" "Doe" "1990-01/23" "foo.bar@gmail.com" `shouldBe` Success (PersonalData full birth mail)
it "failure with one error" $
parse "John" "Doe" "1990-12/34" "foo.bar@gmail.com" `shouldBe` Failure [
"BirthDate[1990-12/34]: allowed format is %Y-%m/%d, and it must be exist date"
]
it "failure with more than two errors" $
parse "" "" "" "" `shouldBe` Failure [
"FirstName[]: empty string is not allowed"
, "FirstName[]: allowed length is 3 to 8"
, "LastName[]: empty string is not allowed"
, "LastName[]: allowed length is 3 to 8"
, "BirthDate[]: allowed format is %Y-%m/%d, and it must be exist date"
, "MailAddress[]: empty string is not allowed"
, "MailAddress[]: atmark must be there"
]
それぞれのForm、もしくはFormsのエラー全てが得られています。
締め
今回の「フォームとバリデーション」についてはここまでとします。
Haskell力アップ、Validationについてと経由してきたので、個人的には結構満足な結果が得られました。
Testについて
今回は愚直に全てのテストを実装しましたが、どの部品のテストをどの様に書くかは規模や状況次第だと思います。
ルールの単体テストを書いているので、FormおよびForms層のテストは冗長な感じがします。
例えばFirstNameFormSpecが担保しているのは「設定した複数のルールが何か」で、FullNameFormsSpecが担保しているのは「複数エラー全てが手に入ること」です。
前者は目視のレビューでも十分抜けますし、後者ははHaskellの標準ライブラリと言語仕様に基づいた実装なので、テストしないという選択もあると思います。
規模やプロダクトの寿命、メンバーの入れ替わり頻度や改修頻度と相談して、例えば「単一要素のFormだけテストする」とか「PersonalDataFormsだけテストする」とかにするのもありだと思います。
最終成果物
今回書いたコードはGitHubにあります。
続きについて
続きは「API層と認証・認可」について考える予定ですが、IOとValidationが絡んできて今のHaskell力ではうまく書けそうにありません。
ので、少しHaskell力を鍛えてから続けたいと思います。モナド変換子とかを理解しないといけないのでしょうか?
今回はDDDよりHaskell色の強い記事となってしまいましたが、次はこの記事でやったことを絡めつつDDD色強めになる予定です。
ありがとうございました。