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について ↩ 
