AdventCalendar
iOS
Validation
Swift
swift3
SwiftDay 22

Swift 3でバリデーションライブラリ作った

More than 1 year has passed since last update.

Swift Advent Calendar 2016 22日目のかっくんです。

今の会社に入社して1年1ヶ月が経過しました。
入社したての頃にObjective-Cでバリデーションライブラリを作成しました。
当時はSwiftはリリースされていましたが、自社のアプリにはまだ適応しておらず、個々人で自主的に勉強を進めていた様な状況でした。

あれからたった1年で、世間的にはSwift 3がリリースされOSSになり、自社のアプリも50%ぐらいがSwiftになりました。
時間の流れとは恐ろしいですね...

というわけで当時は全然Swiftを理解してなかった人が1年経って同じ目的のライブラリを作るとこうなったという記事を書こうかなと思います。

Valy

今回Swift 3でValyというライブラリを作成しました。
決してバリィさんをパクったわけでは...(ゴホッゴホッ
(名前に関してはバリデーションを行いそうな事を想像出来る事、短くてタイプ数が少なくて済むというのを重要視していて、たまたま似たような名前のゆるキャラがいたので便乗した形に近いです...)

基本思想はTMValidatorとほぼ同じで、バリデーターを作成し、ルールを追加して、文字列をチェックメソッドに渡してやればチェックをしてくれるといった感じです。
じゃあ何故敢えてSwiftで書き直すのかというと、やはり過去に作ったものは時間が経つと粗が見え、当時はイケていたと思っていた設計も改めて見るとすっきりしていない箇所が目につく様になったからです。

これを成長と呼ぶのか好みが変わったのかは自分では分かりませんがポジティブに成長だと捉えて、新しく作り直してみたいなと思ったのがきっかけです。

TMValidatorのサンプルコード

UITextField *name = [[UITextField alloc] init];
name.text = @"名前";

TMValidatorField *fieldName = [TMValidatorField fieldWithValue:name.text andLabel:@"name" andElement:name];
[[fieldName addRule:[TMValidatorRuleRequired rule]] addRule:[TMValidatorRuleMaxLength ruleWithLength:@10]];

TMValidator *validator = [TMValidator validator];
[[validator addField:fieldName];

[validator runWithSuccesses:^(NSArray *successes) {
    NSLog(@"success! %@", successes);
} andFailure:^(NSArray *errors) {
    NSLog(@"failure! %@", errors);
}];

Valyのサンプルコード

let result = Valy.factory()
    .add(rule: ValyRule.required)
    .add(rule: ValyRule.maxLength(10))
    .run(with: value)
switch result {
case .success:
    print("success!")
case .failure(let rule):
    print("failed rule \(rule)")
}

比べてみると一目瞭然ですがまずSwiftになっただけでかなりすっきりした様な気がします。(文字量が減っただけというツッコミは受け付けてません)
内部の実装も大幅に違います。

ルール

TMValidatorではルールはTMValidatorRuleという基底クラスがあり、これを継承してルールを作るという事をやっていました。
また、エラーメッセージとエラーコード等をそれぞれのルール側で定義する必要がありかなり面倒でした。
ValyではルールはAnyValidatorRuleを実装していれば何でも良いです。
これにより独自のバリデータールールを作成する事が容易になりました。

enum CustomValidatorRule: AnyValidatorRule {
    case email
    func run(with value: String?) -> Bool {
        switch self {
        case .email:
            return doYourEmailValidation(value)
        }
    }
}

let result = Valy.factory().add(rule: CustomValidatorRule.email).run(with: value)

こんな感じで好き放題にルールを作る事が出来る様になりました。
そのお陰でValy側では最低限のルールをデフォルトで用意するだけにしてあります。

用意したルールはこんな感じです。

public enum ValyRule: AnyValidatorRule {
    case required //必須
    case match(String) //完全一致
    case pattern(String) //正規表現
    case alphabet //半角英字
    case digit //半角数字
    case alnum //半角英数
    case number //数値
    case minLength(Int) //最低文字数
    case maxLength(Int) //最大文字数
    case exactLength(Int) //文字数一致
    case numericMin(Double) //最低数値
    case numericMax(Double) //最大数値
    case numericBetween(min: Double, max: Double) //数値範囲
}

宗教論争が起こりそうなメールアドレスやURLのバリデーションは結局独自カスタムしたくなったり、サービスによってバリデーションが違ったりするのでデフォルトで用意するのはやめました。

エラーメッセージ

TMValidatorではルール側にエラーメッセージを用意して、エラーが発生した際にそのエラーを返す様な仕組みにしていました。

@interface TMValidatorRule : NSObject <TMValidatorRuleProtocol>

+ (instancetype _Nonnull)rule;

@property (nonatomic, readonly) TMValidatorErrorCode errorCode;
@property (nonatomic) NSString * _Nullable errorMessage;
@property (nonatomic) NSDictionary * _Nullable userInfo;

@end

ただ、社内で利用していると、後からエラーメッセージをカスタムしたいという要望があり、その為のバージョンアップを行ったりしました。
これを踏まえてValyではバリデーションに失敗した時は、最初に失敗したルールのみを返す様にしました。
エラーメッセージやエラーの表示方法は使う側に任せる様にしています。

var errorMessages: [String] = []
let nameValidation = Valy.factory(rules: [ValyRule.required, ValyRule.maxLength(10)])
switch nameValidation.run(with: name) {
case .failure(let rule):
    switch rule {
        case ValyRule.required:
        errorMessages.append("名前は必須入力です")
        case ValyRule.maxLength(let length):
        errorMessages.append("名前は\(length)文字以内で入力して下さい")
        default:
        break
    }
default:
break
}

if 0 != errorMessages.count {
    //display error!
    return
}

少しネストが深くなりましたが、エラーメッセージを作成する所を別の関数等で用意すれば、すっきり書く事は出来るかなと思っています。

Swiftだから出来たこと

enumでルール

TMValidatorはルールも全てクラスでしたが、Valyenumで作る事が出来る様になりました。
Associated Valueを使う事で柔軟にルールを作る事が出来る様になりました。

enum CustomValidatorRule: AnyValidatorRule {
    case allowedCharacterSet(CharacterSet):
    func run(with value: String?) -> Bool {
        guard let value = value else {
            return true
        }

        switch self {
        case .allowdCharacterSet(let characterSet):
            return 0 == value.components(separatedBy: characterSet).joined().characters.count
        }

    }
}

型消去

最後に、今回個人的に初めて型消去の仕組みを参考にして取り入れてみました。

protocol AnyValidator {
    var rules: [AnyValidatorRule] { get }
    static func factory() -> AnyValidator
    static func factory(rules: [AnyValidatorRule]) -> AnyValidatable
    func add(rule: AnyValidatorRule) -> AnyValidatable
    func add(rules: [AnyValidatorRule]) -> AnyValidatable
}
protocol AnyValidatable: AnyValidator {
    func run(with value: String?) -> ValidatorResult
}

上記の様なprotocolを用意してみました。
factory()単体で呼び出した場合、ルールが何もない事が明白なので、run(with:)を呼び出させたく無かったのです。
その為、run(with:)が定義されていないAnyValidatorprotocolを返す様にしています。
一方、factory(rules:)や、add(rule:)の場合は既にAnyValidatorRuleが渡されている事が期待出来るのでrun(with:)が定義されているAnyValidatableを返す様にしました。

Valy自体は class Valy: AnyValidatable {}の様にAnyValidatableを実装しているんですが、Valy.factory().run(with: value)としようとすると、 Value of type 'AnyValidator' has no member 'run'というエラーが出ます。
ちゃんとValy.factory().add(rule: ValyRule.required).run(with: value)の様にルールを追加してやる必要があります。

自分としてもまだ勉強中な為、これで正しいか少し不安な所はありますが、プロトコルを上手く活用してライブラリを使う側にも正しく制約を加える事が出来ました🎉

まとめ

1年前と同じ目的のライブラリを再度作ってみましたが、時代も変われば自分も変わって良い感じに変化が出せたんじゃないかと思います。
また、勉強会等で得た知識を新たに生かせたのが嬉しかったです。
いいなと思ったらスター⭐、いいね👍下さい!