Swift その2 Advent Calendar 2016の8日目の記事です。
はじめに
クライアントサイドで行う簡易的なバリデーションチェックの実装を考えてみます。
ここで指すバリデーションチェックでは、最初に発生したエラーだけではなく入力した内容が間違っている際に全てのエラーを通知するものと定義。
愚直に実装すると
func validation(email: String, Password: Int) -> [String] {
var acc: [String] = []
if name.isEmpty { acc.append("名前が未入力です") }
if age < 18 { acc.append("未成年です") }
return acc
}
if validation(name: "", age: 0).isEmpty {
// 正常処理
} else {
// 異常処理
}
上記のように、一時格納(accumulator)用の配列を定義し、ここにエラーメッセージを格納する実装が考えられます。(validationメソッドの戻り値が空配列の場合に正常とみなす)
問題点
- validationメソッド内に複数のバリデーションルールが集約され肥大化する恐れがある
- 単一責務の原則から外れ、また単体テストが書きづらい
このような問題点を抱えヤモヤしていた際にComposite Validators - Hot Cocoa Touchの記事を読み、大変興味深く勉強になったので紹介させてください(著者から了承得ています)。
ユースケース
アプリケーションに以下2つのフィールドを持つ登録フォームがあるとします。
[Email]
- 空白は許容しない
- メール形式に準拠している
[Password]
- 空白は許容しない
- 最低8文字以上の長さ
- 1つ以上の大文字が含まれている1
準備
Validator Protocol
全てのvalidatorが実装するI/Fを作成します。
(ここでいうvalidatorは、ユーザー入力が有効かどうかを判断するオブジェクトと定義)
まず、validatorが返す結果型を作成します。
Result型を用いての実装も可能ですが、今回正常値は必要ないので専用の型を用意しました。
また、最初に発生したエラーだけではなく、全てのエラーを通知できるようError型を配列で保持するようにします。
enum ValidationResult {
case valid
case invalid(errors: [Error])
}
各validatorは、与えられた入力値が有効かどうかを判定します。
有効でない場合、理由を説明する何かしらのエラーを返します。
入力に対して多態性を持たせるべきですが、本ケースではString
を受け取りValidationResult
を返す入力検証を振る舞いとして持つようにします。
protocol Validator {
func validate(_ value: String) -> ValidationResult
}
事前準備として、個々のValidatorを実装する前に無効な入力に対して指定できるエラーを定義します。
enum EmailValidationError: Error {
case empty
case invalidFormat
}
enum PasswordValidationError: Error {
case empty
case tooShort
case noUppercaseLetter
}
個別のvalidator定義
ここから、詳細なバリデーションルールを定義していきます。
- 空文字チェック用のvalidator
struct EmptyStringValidator: Validator {
private let invalidError: Error
init(invalidError: Error) {
self.invalidError = invalidError
}
func validate(_ value: String) -> ValidationResult {
if value.isEmpty {
return .invalid(errors: [invalidError])
} else {
return .valid
}
}
}
- メール形式に準拠しているかチェック用のvalidator
struct EmailFormatValidator: Validator {
func validate(_ value: String) -> ValidationResult {
let magicEmailRegexStolenFromTheInternet = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
let emailTest = NSPredicate(format:"SELF MATCHES %@", magicEmailRegexStolenFromTheInternet)
if emailTest.evaluate(with: value) {
return .valid
} else {
return .invalid(errors: [EmailValidationError.invalidFormat])
}
}
}
ここではNSPredicateを使用していますが、簡易的なRegexクラス等を作成しマッチングを行っても良いです。
- パスワードの長さチェック用のvalidator
struct PasswordLengthValidator: Validator {
func validate(_ value: String) -> ValidationResult {
if value.characters.count >= 8 {
return .valid
} else {
return .invalid(errors: [PasswordValidationError.tooShort])
}
}
}
- 大文字が含まれているかチェック用のvalidator
struct UppercaseLetterValidator: Validator {
func validate(_ value: String) -> ValidationResult {
let uppercaseLetterRegex = ".*[A-Z]+.*"
let uppercaseLetterTest = NSPredicate(format:"SELF MATCHES %@", uppercaseLetterRegex)
if uppercaseLetterTest.evaluate(with: value) {
return .valid
} else {
return .invalid(errors: [PasswordValidationError.noUppercaseLetter])
}
}
}
validatorの合成
各validatorを組み合わせられるよう、任意数のvalidatorを梱包する複合validatorを定義します。
== 個々のvalidatorから大きなvalidatorの木構造を作成し、この木構造の葉のように使用できるようにする。
struct CompositeValidator: Validator {
private let validators: [Validator]
init(validators: Validator...) {
self.validators = validators
}
func validate(_ value: String) -> ValidationResult {
return validators.reduce(.valid) { (acc, validator) -> ValidationResult in
switch validator.validate(value) {
case .valid:
return acc
case .invalid(let errors):
switch acc {
case .valid:
return .invalid(errors: errors)
case .invalid(let accErrors):
return .invalid(errors: accErrors + errors)
}
}
}
}
}
すべてのvalidatorを繰り返し処理し、有効な場合は以前の結果を返します。
無効な場合は、見つかった新しいエラーを以前のエラーと連結します。
Factoryクラス
本ユースケースに即したvalidatorを生成するためのファクトリークラスを定義します。
struct ValidatorFactory {
static let sharedInstance = ValidatorFactory()
private init() {}
func emailValidator() -> Validator {
return CompositeValidator(validators: emptyEmailStringValidator(), EmailFormatValidator())
}
func passwordValidator() -> Validator {
return CompositeValidator(validators: emptyPasswordStringValidator(), passwordStrengthValidator())
}
private func emptyEmailStringValidator() -> Validator {
return EmptyStringValidator(invalidError: EmailValidationError.empty)
}
private func emptyPasswordStringValidator() -> Validator {
return EmptyStringValidator(invalidError: PasswordValidationError.empty)
}
private func passwordStrengthValidator() -> Validator {
return CompositeValidator(validators: PasswordLengthValidator(), UppercaseLetterValidator())
}
}
木構造のイメージ
使い方
では、実際に文字列を入力して検証してみましょう。
let emailValidator = ValidatorFactory.sharedInstance.emailValidator()
print(emailValidator.validate(""))
print(emailValidator.validate("invalidEmail@"))
print(emailValidator.validate("validEmail@validDomain.com"))
let passwordValidator = ValidatorFactory.sharedInstance.passwordValidator()
print(passwordValidator.validate(""))
print(passwordValidator.validate("psS$"))
print(passwordValidator.validate("paSSw0rd"))
標準出力
invalid([EmailValidationError.empty, EmailValidationError.invalidFormat])
invalid([EmailValidationError.invalidFormat])
valid
invalid([PasswordValidationError.empty, PasswordValidationError.tooShort, PasswordValidationError.noUppercaseLetter])
invalid([PasswordValidationError.tooShort])
valid
複数のエラー情報を統合して返すことが出来ました。
まとめ
- 有用なフィードバックをユーザに提供するためには、複数のエラーを返すことが大事2
- 小さなルール(部品)を組み合わせる3ことで、大きな問題に立ち向かう
- 複合Validatorを使用するこのパターンを実行し、アプリケーションが持つさまざまなニーズに合わせて柔軟にバリデーションチェックができる
その他
今回紹介したやり方以外のvalidation実装を考えてみます。
1. SwiftValidatorを使用する
Validatorライブラリは幾つか存在しますが、比較的スターが多くシンプルなjpotts18/SwiftValidatorを触ってみます。
let validator = Validator()
validator.registerField(
emailTextField,
errorLabel: emailErrorLabel,
rules: [RequiredRule(), EmailRule(message: "Invalid email")]
)
実入力の値(String)を引数に受け取るのではなく、UITextLabelをバインドします(エラーメッセージ表示用のラベルも結びつけることが可能)。
予め、よく使用するであろう入力チェック(メール形式に準拠しているかなど)ルールが用意されているので、これらを柔軟に組み合わせることが可能ですね。
SwiftValidator/Rules
2. ScalazのValidation型を参考にする
独習 Scalaz — Validation
Validation型とは、複数のエラー値を保持できるデータ構造(Result型と同様にエラーと正常値を保持する直和型)。
雑に説明するとResult<Value, [Error]>
です。
Swiftzに定義されていると思いきや無かったので、
scalazのValidationの使い方 値の検証・エラー処理を参考に実装のイメージをしてみました。
enum Validation<E, A> {
case Success(A)
case Failure(E)
}
/// Nel == Non empty list
typealias ValidationNel<E, A> = Validation<NonEmptyList<E>, A>
struct Person {
let name: String
let age: Int
}
/// * これは実装イメージです
/// 値の検証とオブジェクトの生成を行う
func validatePerson(name: String, age: Int) -> ValidationNel<String, Person> {
let validatedName: ValidationNel<String, String> = return name.isEmpty ? "名前が未入力です".failureNel : name.successNel
let validatedAge: ValidationNel<String, Int> = return age < 18 ? "未成年です".failureNel : age.successNel
return (validatedName • validatedAge)(Person.init)
}
switch validatePerson {
case .Success(a):
print(a)
case .Failure(e):
print(e)
}
上記のvalidatePerson
メソッド内で失敗情報をNonEmptyListにaccumulateしていくことで検証を行っていきます。
こちらのイメージを具現化しようと実装に取り掛かったのですが、私の実装力不足で[WIP]状態です。。to4iki/Validation
Swift2.2で書いていたのですが、今一度Swift3で再チャレンジしてみようと思います!!4
See also
-
より実践的であるなら最低1文字以上の大文字、小文字、数字が含まれているのが好ましい ↩
-
ログイン時などセキュリティ上、パスワード失敗理由を教えすぎないようにするなど例外もある ↩
-
Generic Type Aliasesが使えるようになったので、表現しやすくなったはず [Swift 3.0] 新しく追加されたGeneric Type Aliasesについて ↩