建立一个类型安全的 Swift 模型

UserProfile

每个用户都有 namefirstNamelastName 有些用户还有 emailphoneNumber 。模型可能会是这样:

struct UserProfile {
    let name: String
    let lastName: String
    let phoneNumber: String?
    let email: String?
}

可能我们每天都会看到这样的模型,也习惯了这样的模型,但仔细思考一下。email 有没有把信息完整的表示出来呢?还有phoneNumber ? 他俩仅仅只是字符串吗?

如果他有在父类中没有的其他行为,则引入一个新的类型。 --- Martin Fowler

make a type if it will have some special behavior in its operations that the base type doesn’t have. by Martin Fowler

在之后的代码里面,我们可能就会用到这两个属性。比如说打电话,或者是给用户发送邮件。但是从模型里面,我们只能推导出来这两个东西是 String? 当然,我们可以在每次使用的时候都做一次校验,或者在初始化方法里面校验,但是这都不是类型层面上的东。如果有其他人要用这段代码的时候,他们知道这两个东西需要做一次校验吗?加一段注释:// 这个属性需要做一次校验 是最有效的方法吗?又如果项目中还有一个类Contact 也需要用到 phoneNumber 呢?校验的代码又复制过去?

typealias Email = String
typealias PhoneNumber = String

struct UserProfile {
    let name: String
    let lastName: String
    let phoneNumber: PhoneNumber?
    let email: Email?
}

我们至少能用到 typealias 来让他们知道, 这些东西跟 String 还是有一点区别的。

不知道你有没有发现 TimeIntervalCLLocationDegrees 这个两个类型,实际上都是 Double 的类型别名。为什么要这么做呢?答案是: 为了上下文。当我们看到 CLLocationDegrees 的时候,我们就能知道这个值的范围在 [-180,180] 之间。如果这个值是不可能是 1000 的。同样,手机号也不可能是一个类似 "HelloWorld" 的字符串。

当我们使用 typealias 来声明自定义类型的时候,我们可以在今后的版本中轻易的给他添加更多的上下文,而不用去修改很多的代码。比如说 CGFloat ,在早期的 Swift 版本中,这个属性只是 Double 的一个类型别名,而现在他已经是一个完整的数据类型了。

现在我们来试试怎样把刚刚的类型别名改成一个完整的数据类型:

struct Email {
    let address: String
    
    /** 或者还可以将邮箱地址分隔开成 domain 还有 localPart*/
    
    //  let domain: String
    //  let localPart: String
}

struct PhoneNumber {
    let digist: String
    
    /** 跟邮箱一样,手机号码可以分成 国家代码,区域代码,号码三个部分 */
    
    //  let countryCode: String?
    //  let areaCode: String
    //  let destination: String
}

struct UserProfile {
    let name: String
    let lastName: String
    let phoneNumber: PhoneNumber?
    let email: Email?
}

现在的代码就更能表示出 UserFrofile.email 具体是什么东西了,这样的代码甚至能够避免以后会出现的问题。

比如下面的问题。比如说我们并没有重写 UserPrifile ,现在我们需要用户的全名。

struct UserProfile {
    let name: String
    let lastName: String
    let phoneNumber: String
    let email: String?
}

extension UserProfile {
    var fullName: String {
        return name + " " + cellNumber
    }
}

处于一些原因,我们错误的把 cellNumber 拼在了后面(正确的应该是 lastName )。编译器不会发现这其实是个错误,因为对编译器来说他们都是 String。这个问题有可能在运行时才会被发现出来,甚至是测试阶段。但是如果使用 PhoneNumber 这个类型的话,这个问题在编译期就能够被发现了。

User

随着 App 的发展,我们肯能会用到 OTP 作为 App 的登录方法。这时候可能还会引入一个新的类型 User

struct User {
    let id: String
    let isRegistered: Bool
    let phoneNumber: String?
    let profile: UserProfile?
}

struct UserProfile {
    let name: String
    let lastName: String
    let email: String?
}

如果完全按照上文的方法,代码会变成这样子:

typealias UserID = String

struct PhoneNumber {
    let digits: String
}

struct Email {
    let address: String
}

struct User {
    let id: UserID
    let phoneNumber: PhoneNumber?
    let isRegistered: Bool
    let profile: UserProfile?
}

struct UserProfile {
    let name: String
    let lastName: String
    let email: Email?
}

如果我们接着写下去,代码会变得很啰嗦。PhoneNumber 还有 Email 还有一些意义, 而 UserID 还有 UserProfileUser 之外没有任何意义。所以再把代码整理一下:

struct User {
    typealias ID = String
    
    let id: ID
    let phoneNumber: PhoneNumber?
    let isRegistered: Bool
    let profile: Profile?
    
    struct Profile {
        let name: String
        let lastName: String
        let email: Email?
    }
}

如果要使用 UserProfile 的时候,我们可以使用 User.Profile 来替代。同样 UserID 也用 User.ID 替代。这样就更有意义了。

但是这样仍然还有问题。 对 profile 还有 isRegisterted 来说仍然还有可能会有问题。要知道,如果用户已经注册,就一定会有用户资料。反之,如果未注册,就肯定没有用户资料。但是从这个类的声明讲,这并没有体现出来这个逻辑。我们使用枚举和模式匹配来做这件事情:

struct User {
    typealias ID = String
    
    let id: ID
    let phoneNumber: PhoneNumber?
    let status: Status
    
    enum Status {
        case notRegistered
        case registered(profile: Profile)
    }
    struct Profile {
        let name: String
        let lastName: String
        let email: Email?
    }
}

现在我们就能确定只有当用户已注册的时候才会有 Profile 了。

是否使用枚举,完全取决于 App 的上下文。

如果我们有一个新属性 isEmailVerified 。用来表示 email 是否在服务端做过验证。这就不需要使用枚举,因为是否验证跟这个值是否存在没有关系。

JSON & Codable

首先我们需要知道 JSON 并不是类型安全的。他只支持基本数据类型,数组还有字典。

JSON 包含了很多的上下文,因为在 JSON 文件中, email 和 phoneNumber 都是字符串。我们应该知道如何去处理它。

下面是一个 User 的 JSON 示例

// 未注册用户
"user": {
    "id": "a_unique_id",
    "phone_number": "+989354358291",
    "is_registered": false
}
// 注册用户
"user": {
    "id": "a_unique_id",
    "phone_number": "+989354358291",
    "is_registered": true,
    "profile": {
        "name": "Farzad",
        "last_name": "Sharbafian",
        "email": "farzad.shbfn@gmail.com",
        "is_email_verified": true
    }
}

首先我们来处理 PhoneNumberEmail。 Codable 有一个方法 singleValueContainer 用来表示 JOSN 的这部分数据就是它本身,我们并不需要为了它而做更多的事情。用这个方法可以讲 JSON 文件中的字符串直接转换成 Model。

struct PhoneNumber: Codable {
    let digits: String
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
            let rawDigits = try container.decode(String.self)
        digits = rawDigits
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(digits)
    }
}

struct Email: Codable {
    let address: String
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let rawAddress = try container.decode(String.self)
        address = rawAddress
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(address)
    }
}

我们不需要为 Profile 实现 Codable 的方法,因为它所包含的所有类型都是 Codable 的,编译器会自动帮我们生成这部分代码。我们需要做的只是修改 CodingKey 因为 JSON 中的 key 跟我们需要的 key 不一样。

struct User {
    struct Profile: Codable {
        let name: String
        let lastName: String
        let email: Email?
        let isEmailVerified: Bool
        
        private enum CodingKeys: String, CodingKey {
            case name
            case lastName = "last_name"
            case email
            case isEmailVerified = "is_email_verified"
        }
    }
}

但是对于 User,我们还有一些很复杂的事情要做,因为在 JONS 中没有 status 这个东西。在这部分,代码就有点丑了。

struct User: Codable {
    typealias ID = String
    
    let id: ID
    let phoneNumber: PhoneNumber
    let status: Status
    
    enum Status {
        case notRegistered
        case registered(profile: Profile)
    }
    
    private enum CodingKeys: String, CodingKey {
        case id
        case phoneNumber = "phone_number"
        case isRegistered = "is_registered"
        case profile
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(ID.self, forKey: .id)
        phoneNumber = try container.decode(PhoneNumber.self, forKey: .phoneNumber)
        let isRegistered = try container.decode(Bool.self, forKey: .isRegistered)
        status = isRegistered ? try .registered(profile: container.decode(Profile.self, forKey: .profile)) : .notRegistered
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(phoneNumber, forKey: .phoneNumber)
        switch status {
        case .notRegistered:
            try container.encode(false, forKey: .isRegistered)
        case .registered(let profile):
            try container.encode(true, forKey: .isRegistered)
            try container.encode(profile, forKey: .profile)
        }
    }
    
    
    struct Profile: Codable {
        
        let name: String
        let lastName: String
        let email: Email?
        let isEmailVerified: Bool
        
        private enum CodingKeys: String, CodingKey {
            case name
            case lastName = "last_name"
            case email
            case isEmailVerified = "is_email_verified"
        }
    }
}

现在,所有的模型都是类型安全的了,不需要知道什么额外的信息,所有的信息代码都能告诉我们了。

现在仍然能够像之前一样有 isRegisteredprofile 两个属性。这两个信息我们并不需要存起来,只需要看看 User 的实现,我们很容易就能够实现:

// MARK:- User Helper methods
extension User {
    var isRegistered: Bool {
        return status.isRegistered
    }
    
    var profile: Profile? {
        return status.profile
    }
}

// MARK:- User.Status helper methods
extension User.Status {
    fileprivate var isRegistered: Bool {
        switch self {
        case .registered: return true
        case .notRegistered: return false
        }
    }
    
    fileprivate var profile: User.Profile? {
        switch self {
        case .registered(let profile): return profile
        case .notRegistered: return nil
        }
    }
}

用户信息只是建立模型的一个非常基本的例子,这样来做,我们就可以省去将来可能会犯下的错误。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,087评论 18 139
  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,785评论 2 89
  • width: 65%;border: 1px solid #ddd;outline: 1300px solid #...
    邵胜奥阅读 4,202评论 0 1
  • Spring Web MVC Spring Web MVC 是包含在 Spring 框架中的 Web 框架,建立于...
    Hsinwong阅读 21,744评论 1 92
  • 没有圣经的教育,是在把孩子推进地狱。——马丁·路德 没有基督信仰的教育,不过是培养一群聪明的坏蛋。——C.S.路易...
    約之美阅读 355评论 2 4