从Python到Swift有多远?借鉴思路,编写更Swifty的正则匹配工具类|项目复盘

1,339 阅读10分钟

项目简介

一个字符串正则校验工具类,使用Swift中的enum实现并使用。

本文的思路亮点在于从Python使用的过程中,跳跃到Swift中的脑洞折腾方式,难点在于如何写出更Swifty的编码。

Python和Swift,API上的远方亲戚嘛。

项目背景

做前端和App端一般都会涉及表单输入,校验手机号、邮箱等等一些输入字符串的校验,大多数时候,我们需要配合正则表达式加上系统层的API去做这些。

而我要做的,就是封装一个使用简单,一目了然的正则校验工具类。

实践过程

从一个Python爬虫说起

一切源于我在学习Python写爬虫,想要获取一些有用的信息。

嗯,你问我为啥明明写Swift的,怎么就变成从Python开始说起,因为我就是这么脑袋一抽风,编程脑洞这么顺手牵羊过来的。

嗯,你问我写爬虫爬什么东东?学Python就没干过什么正经事,你瞧隔壁这位:偷偷告诉你中国小姐姐的真实Size!!

而使用Python写最简单的爬虫,是通过正则表达式来获取关键信息的。我们举个例子:

# 系统层级的判断正则表达式的类
import re
# 大名鼎鼎的网络请求库
import requests

def getPages() -> int:
    # ¸
    url = "有效网址"
    # 请求回来的响应
    response = requests.get(url)
    # 响应的html字符串
    html = response.text
    # 获取网页字符串中匹配的信息
    result = re.findall(r'>第 1 页,共 ([1-9]\d+) 页</span>', html)
    # 返回结果的第一次出现的值,也就是总页数
    return int(result[0])

这段代码里面除去网络请求,最有价值的代码下面这段:

result = re.findall(r'>第 1 页,共 ([1-9]\d+) 页</span>', html)

通过re的findall函数,输入一个正则表达式与需要校验的字符串,去匹配结果集并以list的形式返回。

分析Python中的re中的源码

来,我们先看看findall这个函数的源码,另外我觉得findall这个函数名一点也不Pythonic,你给我们整个find_all或者findAll都比这个好呀。

def findall(pattern, string, flags=0):
    """Return a list of all non-overlapping matches in the string.

    If one or more capturing groups are present in the pattern, return
    a list of groups; this will be a list of tuples if the pattern
    has more than one group.

    Empty matches are included in the result."""
    return _compile(pattern, flags).findall(string)

由于Python编写函数的时候,是可以不带参数类型的,所以可能看起来有点懵圈,我简单分析一下:


findall有三个参数:

pattern是str类型,为正则表达式,你会好奇为啥我写的入参字符串是这样:r'>第 1 页,共 ([1-9]\d+) 页</span>',这个r‘’表示的字符串原意表达式,我们都知道,html中有很多标签,会有\这样的字符串,如果不使用r''包裹起来,对于\这类字符串其实是需要转译的,\\才表示\ 不了解的可以度娘一把:转义字符

string是str类型,为需要验证的字符串

flags本质上一个枚举入参,默认给的0。

class RegexFlag(enum.IntFlag):
    ASCII = sre_compile.SRE_FLAG_ASCII # assume ascii "locale"
    IGNORECASE = sre_compile.SRE_FLAG_IGNORECASE # ignore case
    LOCALE = sre_compile.SRE_FLAG_LOCALE # assume current 8-bit locale
    UNICODE = sre_compile.SRE_FLAG_UNICODE # assume unicode "locale"
    MULTILINE = sre_compile.SRE_FLAG_MULTILINE # make anchors look for newline
    DOTALL = sre_compile.SRE_FLAG_DOTALL # make dot match newline
    VERBOSE = sre_compile.SRE_FLAG_VERBOSE # ignore whitespace and comments
    A = ASCII
    I = IGNORECASE
    L = LOCALE
    U = UNICODE
    M = MULTILINE
    S = DOTALL
    X = VERBOSE
    # sre extensions (experimental, don't rely on these)
    TEMPLATE = sre_compile.SRE_FLAG_TEMPLATE # disable backtracking
    T = TEMPLATE
    DEBUG = sre_compile.SRE_FLAG_DEBUG # dump pattern after compilation

我们可以用re.I作为最后一个入参,表示在正则查找中不在意大小写。

_compile(pattern, flags).findall(string),最后的这个方法已经涉及到其内部方法了,大致意思就说通过pattern与flag生成一个专门的正则匹配类,然后再去处理需要验证的字符串,通过注释,我们知道返回结果是一个list。


分析Swift中的NSRegularExpression类

你看我一上来,不务正业的巴拉巴拉说了一通Python,甚至连Python的源码都分析了一遍,为何?

在我学习编程语言的时候,我经常会发出这样的感慨,这个函数真好用,哎,为啥Swift中就没有这个轮子呢?如果没有,可不可以自己尝试写呢?

我最初的一个想法是Python中有re模块处理正则,那么Swift也会不会有类型的类呢?仔细一查,是有的————NSRegularExpression。

为了类比Python中的re,我贴一下NSRegularExpression的一些重要函数和参数枚举。

@available(iOS 4.0, *)
open class NSRegularExpression : NSObject, NSCopying, NSSecureCoding {

    public init(pattern: String, options: NSRegularExpression.Options = []) throws
}

extension NSRegularExpression {    

    open func matches(in string: String, options: NSRegularExpression.MatchingOptions = [], range: NSRange) -> [NSTextCheckingResult]
    
}

extension NSRegularExpression {

    
    public struct Options : OptionSet {

        public init(rawValue: UInt)

        
        public static var caseInsensitive: NSRegularExpression.Options { get }

        public static var allowCommentsAndWhitespace: NSRegularExpression.Options { get }

        public static var ignoreMetacharacters: NSRegularExpression.Options { get }

        public static var dotMatchesLineSeparators: NSRegularExpression.Options { get }

        public static var anchorsMatchLines: NSRegularExpression.Options { get }

        public static var useUnixLineSeparators: NSRegularExpression.Options { get }

        public static var useUnicodeWordBoundaries: NSRegularExpression.Options { get }
    }
    
    public struct MatchingOptions : OptionSet {

        public init(rawValue: UInt)

        public static var reportProgress: NSRegularExpression.MatchingOptions { get } 

        public static var reportCompletion: NSRegularExpression.MatchingOptions { get } 

        public static var anchored: NSRegularExpression.MatchingOptions { get } 

        public static var withTransparentBounds: NSRegularExpression.MatchingOptions { get } 

        public static var withoutAnchoringBounds: NSRegularExpression.MatchingOptions { get } 
    }
}

使用init(pattern: String, options: NSRegularExpression.Options = [])函数构造一个NSRegularExpression对象,传入的参数有两个pattern: Stringoptions: NSRegularExpression.Options = [],分别是正则表达式和选项。

然后使用matches(in string: String, options: NSRegularExpression.MatchingOptions = [], range: NSRange)方法去获取匹配的结果。

从Python到Swift,实现同一个函数有多远?

不远,也就换些API,从Pythonic到Swifty一点。

在iOS中,如果我们想要像Python一样爬一个网页,我们应该如何处理呢?

func getPages() -> Int? {
    let urlString = "有效网址"
    
    /// 网址字符串转URL
    guard let url = URL(string: urlString) else {
        return nil
    }
    
    /// 获取网址的html
    guard let html = try? String(contentsOf: url, encoding: .utf8) else {
        return nil
    }
    
    /// Swift5特性,在##之间的字符串,可以不用\对特殊符号进行转义了
    let pattern = #">第 1 页,共 ([1-9]\d+) 页</span>"#
    
    /// 构造NSRegularExpression对象
    guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
        return nil
    }
    
    /// 获取结果集
    let result = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))
    
    /// 获取第一个结果
    guard let firstResutl = result.first else {
        return nil
    }
    
    /// 通过一个结果的range去截取html上的有效字符串
    let num = (html as NSString).substring(with: firstResutl.range)
    
    /// 字符串转Int
    return Int(num)
}

对比Python的爬虫写法,也许你会觉得Swift感觉很复杂,那是因为代码的严谨性,在创建URL的构造函数、NSRegularExpression的构造函数返回的都是可选类型,而且获取html字符串也是返回的可选类型,结果集result的第一个元素也有可能是为nil,所以使用了大量的guard去做守护操作。

基于上面的代码,你需要了解到:

  • Swift内置的方法也是可以写爬虫的。String(contentsOf: url, encoding: .utf8)

  • Swift中对于原意字符串也有支持的,字符串两端用##括起来即可。

  • Swift虽然写起来麻烦,不过考虑对于安全性和健壮性,是可以接受的。

如果你对Swift的解包有疑惑,可以转移到我此前写的这篇文章:Swift:解包的正确姿势

在Swift中,我们正则匹配字符串的核心代码在这里:

/// 构造NSRegularExpression对象
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
    return nil
}

/// 获取结果集
let result = regex.matches(in: html, options: [], range: NSRange(location: 0, length: html.count))

是不是和Python的re.findall函数内部实现函数_compile(pattern, flags).findall(string)非常类似呢?

到此,涉及Swift中通过正则表达式获取有效信息的类和方法就基本确定。下面就让我们进入正题,写一个Swifty的Regular工具类。

编写更Swifty的Regular工具类

想到工具类,我们总是想着以Utils,虚基类,没有构造方法,静态方法等等,以下省略500字:

class RegularUtils {
    /// 是否匹配电话号码
    ///
    /// - Parameters:
    ///   - pattern: 正则表达式字符串
    ///   - string: 待验证的字符串
    /// - Returns: Bool
    static func isContainPhoneNum(pattern:String, string: String) -> Bool {}
    .
    .
    .
    static func isContainEmail(pattern:String, string: String) -> Bool {}
    .
    .
    .
    private init() {}
}

上面的代码当然没有问题,我们甚至可以继续扩展类方法,字符串中是否包含邮箱,包含车牌号等等。

既然使用Swift编码,难道就不能写的更Swifty一点吗?


就如文章一开始所说,我们可能需要校验的类型很多,手机号、邮箱、车牌号等等,每一个类型对于的正则也不同,有没有想过用Swift的枚举来构造这个工具类?

使用枚举优势如下:

  1. 区分不同的校验类型

  2. 通过Swift枚举带参的优势,直接将需要校验的字符串传入,

  3. Swift的枚举本质上就是一个struct,所以可以添加方法、属性


那么,就上一点enum写这个工具的思路吧:

首先突破对枚举只能表示状态的思维,带参数,带多个参数都是可以的。

枚举中有一个custom情况,相当于自定义正则类型,并与其待校验的字符串做匹配。

/// 正则表达式的判断
/// 这些枚举中的String都是待验证的字符串
/// - email: 邮箱
/// - phoneNum: 手机号
/// - carNum: 车牌号
/// - username: 用户名
/// - password: 密码
/// - nickname: 昵称
/// - checkCode: 验证码
/// - URL: url
/// - IP: ip地址
/// - idCard: 身份证
/// - custom: 自定义,注意custom枚举 其中有两个字符串, 第一个是待验证的字符串,第二个是正则表达式
public enum Regular {
    case email(String)
    case phoneNum(String)
    case carNum(String)
    case username(String)
    case password(String)
    case nickname(String)
    case checkCode(String)
    case URL(String)
    case IP(String)
    case idCard(String)
    case custom(_ string: String, _ predicateString: String)
}

对于一些固定正则式,我们可以在这个枚举中先声明:

extension Regular {
    /// 样式
    private struct Pattern {
        static let email = "^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
        static let phoneNum = "^1[0-9]{10}$"
        static let carNum = "^[A-Za-z]{1}[A-Za-z_0-9]{5}$"
        static let username = "^[A-Za-z0-9_-]{6,20}+$"
        static let password = "^[a-zA-Z0-9]{8,16}+$"
        static let nickname = "^[\\u4e00-\\u9fa5]{4,8}$"
        static let url =  "^(https?:\\/\\/)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([\\/\\w \\.-]*)*\\/?$"
        static let ip = "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
        static let checkCode = "^[0-9]{4}$"
        static let idCard = "^[1-9]\\d{5}[1-9]\\d{3}((0\\d)|(1[0-2]))(([0|1|2]\\d)|3[0-1])\\d{3}([0-9Xx])$"
    }
}

再来,我们将涉及NSRegularExpression与返回NSTextCheckingResult结果的方法做简单的封装:

extension Regular {

    /// 通过正则表达式获取一个NSRegularExpression
    ///
    /// - Parameter pattern: 正则表达式
    /// - Returns: NSRegularExpression
    private func regex(pattern: String, options: NSRegularExpression.Options = []) -> NSRegularExpression? {
        return try? NSRegularExpression(pattern: pattern, options: options)
    }
    
    /// 是否有匹配结果
    ///
    /// - Parameters:
    ///   - regex: NSRegularExpression
    ///   - string: 字符串
    /// - Returns: Bool
    private func isMatch(regex: NSRegularExpression?, string: String?) -> Bool {
        guard let regex = regex, let string = string else {
            return false
        }
        
        return regex.matches(in: string, options: [], range: NSRange(location: 0, length: string.count)).count > 0
    }
}

最后,我们使用一个isMatch只读计算属性来完成这个正则表达类中的一个功能,用了返回字符串中是否有匹配内容:

extension Regular {
    public var isMatch: Bool {
        let re: NSRegularExpression?
        let string: String
        switch self {
        case let .email(str):
            re = regex(pattern: Pattern.email)
            string = str
        case let .phoneNum(str):
            re = regex(pattern: Pattern.phoneNum)
            string = str
        case let .carNum(str):
            re = regex(pattern: Pattern.carNum)
            string = str
        case let .username(str):
            re = regex(pattern: Pattern.username)
            string = str
        case let .password(str):
            re = regex(pattern: Pattern.password)
            string = str
        case let .nickname(str):
            re = regex(pattern: Pattern.nickname)
            string = str
        case let .URL(str):
            re = regex(pattern: Pattern.url)
            string = str
        case let .IP(str):
            re = regex(pattern: Pattern.ip)
            string = str
        case let .checkCode(str):
            re = regex(pattern: Pattern.checkCode)
            string = str
        case let .idCard(str):
            re = regex(pattern: Pattern.idCard)
            string = str
        case .custom(let str, let pattern):
            re = regex(pattern: pattern)
            string = str
        }
        
        return isMatch(regex: re, string: string)
    }

使用的时候,我们这样写代码就可以啦:

let isEmail = Regular.email("season@qq.com").isMatch // true

let isContain = Regular.custom("html字符串", #">第 1 页,共 ([1-9]\d+) 页</span>"#).isMatch // false

思考总结

  • 从Python到Swift,编程的思维即使是跨语言,很多时候也不会跨思路,在掌握一门编程语言的同时,有空去看看其他编程世界的精彩,在启发和拓展上往往会有意想不到的的收获。

  • 代码想要写的更Swifty,并不是一朝一夕就从脑袋里崩出来的,从Utils工具类到enum工具类,是大量codeReview和学习的结果,多思考,多总结,才能将代码写的更Swifty。

  • 合理的使用系统提供的轮子和方法,加以封装使用,会让编码愉快。有的时候,折腾很有必要。

  • Swift中枚举,比其他语言的枚举强大很多,善于使用枚举特性,带参特性,有的时候会有出人意料的效果。

  • Swift可以写爬虫的,虽然没有Python里的bs4,没有lxml,但是只有正则表达式使用的溜,也就是语言差异而已。

  • 编程语言没有特定的墙,你所谓的墙都是自己给自己界定的。

参考文章

Swift:解包的正确姿势

Swift:Extension的使用小技巧 | 附Dart的Extension一点使用心得

Flutter:枚举的缺点

本文正在参与「掘金 2021 春招闯关活动」, 点击查看活动详情