Help us understand the problem. What is going on with this article?

複合Validationの実装を考えてみる

More than 3 years have passed since last update.

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型を配列で保持するようにします。

ValidationResult
enum ValidationResult {
    case valid
    case invalid(errors: [Error])
}

各validatorは、与えられた入力値が有効かどうかを判定します。
有効でない場合、理由を説明する何かしらのエラーを返します。

入力に対して多態性を持たせるべきですが、本ケースではStringを受け取りValidationResultを返す入力検証を振る舞いとして持つようにします。

Validator
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の木構造を作成し、この木構造の葉のように使用できるようにする。

CompositeValidator
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を生成するためのファクトリークラスを定義します。

ValidatorFactory.swift
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())
    }
}

木構造のイメージ

tree.png

使い方

では、実際に文字列を入力して検証してみましょう。

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. より実践的であるなら最低1文字以上の大文字、小文字、数字が含まれているのが好ましい 

  2. ログイン時などセキュリティ上、パスワード失敗理由を教えすぎないようにするなど例外もある 

  3. UNIX philosophy: Make each program do one thing well 

  4. Generic Type Aliasesが使えるようになったので、表現しやすくなったはず [Swift 3.0] 新しく追加されたGeneric Type Aliasesについて 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした