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色強めになる予定です。
ありがとうございました。