使用RawRepresentable和NSDataDetector验证电子邮件地址的方法

82 阅读5分钟

如今,应用程序必须以某种方式处理电子邮件地址是非常普遍的,当这样做时,我们通常希望对用户输入的任何地址执行某种客户端验证。

从概念上讲,执行这种验证应该很简单,因为电子邮件地址需要符合某种格式才能有效,但在实践中,要弄清楚如何在Swift中实现这种逻辑可能相当困难。

一个非常常见的方法是使用正则表达式,当它与NSPredicate 结合时,我们可以像这样验证一个给定的电子邮件地址String (为简洁起见,实际的正则表达式已被省略)。

class SignUpViewController: UIViewController {
    private lazy var emailAddressField = UITextField()

    @objc private func handleSignUpButtonPress() {
        let emailPredicate = NSPredicate( 
            format: "SELF MATCHES %@", "<regular expression>"
        )

        guard let email = emailAddressField.text,
              emailPredicate.evaluate(with: email) else {
            return showErrorView(for: .invalidEmailAddress)
        }
        
        ...
    }
}

然而,虽然上述技术确实有效,但它确实有一些缺点。首先,我们必须仔细制作我们希望使用的正则表达式,或者弄清楚在网上可以找到的许多变体中,哪一个才是真正可以使用的。但也许更重要的是,当存储和传递电子邮件地址作为原始的String ,没有办法建立一个保证,即一个给定的值实际上已经被验证。

例如,只看下面这个User 类型,我们就无法判断被存储在其中的emailAddress 是否真的经过了任何形式的验证,因为这个逻辑目前完全与该值分离。

struct User: Codable {
    var name: String
    var emailAddress: String
    ...
}

然而,事实证明,Swift实际上有一个内置的模式,可以让我们相当优雅地解决上述一系列问题。

如果我们仔细想想,我们经常以枚举的形式处理其他类型的验证原始值。例如,下面的UserGroup 枚举可以用一个原始的String 值来初始化,但实际上只有当这个原始值与我们的枚举的情况之一相匹配时才会返回一个实例。

enum UserGroup: String {
    case admins
    case moderators
    case regular
}

起初,它可能看起来像原始值支持的枚举得到的init(rawValue:) 初始化器是某种硬编码的编译器逻辑的结果,这是专门针对枚举的。虽然这部分是正确的,但那个初始化器实际上是一个通用协议的一部分,叫做RawRepresentable ,有原始值的枚举自动符合这个协议。

这意味着我们也可以定义我们自己的RawRepresentable 类型,这让我们可以用一种非常巧妙的方式来封装我们的电子邮件验证逻辑--就像这样:

struct EmailAddress: RawRepresentable, Codable {
    let rawValue: String

    init?(rawValue: String) {
        // Validate the passed value and either assign it to our
        // rawValue property, or return nil.
        ...
    }
}

请注意,我们新的EmailAddress 类型也符合Codable ,我们无需编写任何额外的代码就可以得到支持。值将被自动编码和解码到我们的底层rawValue 属性,就像枚举在这种情况下的工作方式。

现在,虽然我们可以简单地将之前基于正则表达式的验证逻辑复制/粘贴到我们新的EmailAddress 类型中,但让我们也利用这个机会来探索一种不同的(如果你问我,更好的)执行验证的方式--通过使用Foundation的NSDataDetector API。

在引擎盖下,NSDataDetector 实际上也使用正则表达式,但将这些细节隐藏在一系列专门的API后面,让我们识别链接、电话号码和电子邮件地址等标记。下面是我们如何使用link 检查类型,为我们正在验证的电子邮件地址提取一个mailto 链接,然后我们可以像这样对它进行一些额外的检查:

struct EmailAddress: RawRepresentable, Codable {
    let rawValue: String

    init?(rawValue: String) {
        let detector = try? NSDataDetector(
            types: NSTextCheckingResult.CheckingType.link.rawValue
        )

        let range = NSRange(
            rawValue.startIndex..<rawValue.endIndex,
            in: rawValue
        )

        let matches = detector?.matches(
            in: rawValue,
            options: [],
            range: range
        )
    
        // We only want our string to contain a single email
        // address, so if multiple matches were found, then
        // we fail our validation process and return nil:
        guard let match = matches?.first, matches?.count == 1 else {
            return nil
        }

        // Verify that the found link points to an email address,
        // and that its range covers the whole input string:
        guard match.url?.scheme == "mailto", match.range == range else {
            return nil
        }

        self.rawValue = rawValue
    }
}

有了上面的方法,我们现在可以简单地在任何存储电子邮件地址的地方使用我们新的EmailAddress 类型,并且我们将得到一个编译时间的保证,当这些地址从原始的String 值转换时,它们总是被验证的:

struct User: Codable {
    var name: String
    var emailAddress: EmailAddress
    ...
}

然后要实际地将原始的电子邮件地址字符串转换成我们新类型的实例,我们可以简单地使用相同的init(rawValue:) 初始化器,用来将原始值转换成枚举,或者在我们之前的SignUpViewController 的情况下,我们可以在UITextField 给我们的可选String 上使用flatMap ,然后将我们新类型的初始化器作为一个第一类函数传递--像这样:

class SignUpViewController: UIViewController {
    private lazy var emailAddressField = UITextField()

    @objc private func handleSignUpButtonPress() {
        // As an added bonus, we also trim all whitespaces from
        // the string that the user entered before validating it:
        let rawEmail = emailAddressField.text?.trimmingCharacters(
            in: .whitespaces
        )

        guard let email = rawEmail.flatMap(EmailAddress.init) else {
            return showErrorView(for: .invalidEmailAddress)
        }
        
        ...
    }
}

当然,如果我们想的话,我们也可以在我们的EmailAddress 实现中继续使用基于正则表达式的方法,但我个人认为,专门的RawRepresentable 类型和NSDataDetector 的组合会产生一个更简单的解决方案。