面向协议编程与 Cocoa 的邂逅

引子

面向协议编程 (Protocol Oriented Programming,以下简称 POP) 是 Apple 在 2015 年 WWDC 上提出的 Swift 的一种编程范式。相比与传统的面向对象编程 (OOP),POP 显得更加灵活。结合 Swift 的值语义特性和 Swift 标准库的实现,这一年来大家发现了很多 POP 的应用场景。本次演讲希望能在介绍 POP 思想的基础上,引入一些日常开发中可以使用 POP 的场景,让与会来宾能够开始在日常工作中尝试 POP,并改善代码设计。

起・初识 - 什么是 Swift 协议

Protocol

Swift 标准库中有 50 多个复杂不一的协议,几乎所有的实际类型都是满足若干协议的。protocol 是 Swift 语言的底座,语言的其他部分正是在这个底座上组织和建立起来的。这和我们熟知的面向对象的构建方式很不一样。

一个最简单但是有实际用处的 Swift 协议定义如下:

protocol Greetable {
    var name: String { get }
    func greet()
}

这几行代码定义了一个名为 Greetable 的协议,其中有一个 name 属性的定义,以及一个 greet 方法的定义。

所谓协议,就是一组属性和/或方法的定义,而如果某个具体类型想要遵守一个协议,那它需要实现这个协议所定义的所有这些内容。协议实际上做的事情不过是“关于实现的约定”。

面向对象

在深入 Swift 协议的概念之前,我想先重新让大家回顾一下面向对象。相信我们不论在教科书或者是博客等各种地方对这个名词都十分熟悉了。那么有一个很有意思,但是其实并不是每个程序员都想过的问题,面向对象的核心思想究竟是什么?

我们先来看一段面向对象的代码:

class Animal {
    var leg: Int { return 2 }
    func eat() {
        print("eat food.")
    }
    func run() {
        print("run with \(leg) legs")
    }
}

class Tiger: Animal {
    override var leg: Int { return 4 }
    override func eat() {
        print("eat meat.")
    }
}

let tiger = Tiger()
tiger.eat() // "eat meat"
tiger.run() // "run with 4 legs"

父类 Animal 定义了动物的 leg (这里应该使用虚类,但是 Swift 中没有这个概念,所以先请无视这里的 return 2),以及动物的 eat 和 run 方法,并为它们提供了实现。子类的 Tiger 根据自身情况重写了 leg (4 条腿)和 eat (吃肉),而对于 run,父类的实现已经满足需求,因此不必重写。

我们看到 Tiger 和 Animal 共享了一部分代码,这部分代码被封装到了父类中,而除了 Tiger 的其他的子类也能够使用 Animal 的这些代码。这其实就是 OOP 的核心思想 - 使用封装和继承,将一系列相关的内容放到一起。我们的前辈们为了能够对真实世界的对象进行建模,发展出了面向对象编程的概念,但是这套理念有一些缺陷。虽然我们努力用这套抽象和继承的方法进行建模,但是实际的事物往往是一系列特质的组合,而不单单是以一脉相承并逐渐扩展的方式构建的。所以最近大家越来越发现面向对象很多时候其实不能很好地对事物进行抽象,我们可能需要寻找另一种更好的方式。

面向对象编程的困境

横切关注点

我们再来看一个例子。这次让我们远离动物世界,回到 Cocoa,假设我们有一个 ViewController,它继承自 UIViewController,我们向其中添加一个 myMethod

class ViewCotroller: UIViewController
{
    // 继承
    // view, isFirstResponder()...

    // 新加
    func myMethod() {

    }
}

如果这时候我们又有一个继承自 UITableViewController 的 AnotherViewController,我们也想向其中添加同样的 myMethod

class AnotherViewController: UITableViewController
{
    // 继承
    // tableView, isFirstResponder()...

    // 新加
    func myMethod() {

    }
}

这时,我们迎来了 OOP 的第一个大困境,那就是我们很难在不同继承关系的类里共用代码。这里的问题用“行话”来说叫做“横切关注点” (Cross-Cutting Concerns)。我们的关注点 myMethod 位于两条继承链 (UIViewController -> ViewCotroller 和 UIViewController -> UITableViewController -> AnotherViewController) 的横切面上。面向对象是一种不错的抽象方式,但是肯定不是最好的方式。它无法描述两个不同事物具有某个相同特性这一点。在这里,特性的组合要比继承更贴切事物的本质。

想要解决这个问题,我们有几个方案:

  • Copy & Paste

    这是一个比较糟糕的解决方案,但是演讲现场还是有不少朋友选择了这个方案,特别是在工期很紧,无暇优化的情况下。这诚然可以理解,但是这也是坏代码的开头。我们应该尽量避免这种做法。

  • 引入 BaseViewController

在一个继承自 UIViewController 的 BaseViewController 上添加需要共享的代码,或者干脆在 UIViewController 上添加 extension。看起来这是一个稍微靠谱的做法,但是如果不断这么做,会让所谓的 Base 很快变成垃圾堆。职责不明确,任何东西都能扔进 Base,你完全不知道哪些类走了 Base,而这个“超级类”对代码的影响也会不可预估。

  • 依赖注入

通过外界传入一个带有 myMethod 的对象,用新的类型来提供这个功能。这是一个稍好的方式,但是引入额外的依赖关系,可能也是我们不太愿意看到的。

  • 多继承

当然,Swift 是不支持多继承的。不过如果有多继承的话,我们确实可以从多个父类进行继承,并将 myMethod 添加到合适的地方。有一些语言选择了支持多继承 (比如 C++),但是它会带来 OOP 中另一个著名的问题:菱形缺陷。

菱形缺陷

上面的例子中,如果我们有多继承,那么 ViewController 和 AnotherViewController 的关系可能会是这样的:

在上面这种拓扑结构中,我们只需要在 ViewController 中实现 myMethod,在 AnotherViewController 中也就可以继承并使用它了。看起来很完美,我们避免了重复。但是多继承有一个无法回避的问题,就是两个父类都实现了同样的方法时,子类该怎么办?我们很难确定应该继承哪一个父类的方法。因为多继承的拓扑结构是一个菱形,所以这个问题又被叫做菱形缺陷 (Diamond Problem)。像是 C++ 这样的语言选择粗暴地将菱形缺陷的问题交给程序员处理,这无疑非常复杂,并且增加了人为错误的可能性。而绝大多数现代语言对多继承这个特性选择避而远之。

动态派发安全性

Objective-C 恰如其名,是一门典型的 OOP 语言,同时它继承了 Small Talk 的消息发送机制。这套机制十分灵活,是 OC 的基础思想,但是有时候相对危险。考虑下面的代码:

ViewController *v1 = ...
[v1 myMethod];

AnotherViewController *v2 = ...
[v2 myMethod];

NSArray *array = @[v1, v2];
for (id obj in array) {
    [obj myMethod];
}

我们如果在 ViewController 和 AnotherViewController 中都实现了 myMethod 的话,这段代码是没有问题的。myMethod 将会被动态发送给 array 中的 v1 和 v2。但是,要是我们有一个没有实现 myMethod 的类型,会如何呢?

NSObject *v3 = [NSObject new]
// v3 没有实现 `myMethod`

NSArray *array = @[v1, v2, v3];
for (id obj in array) {
    [obj myMethod];
}

// Runtime error:
// unrecognized selector sent to instance blabla

编译依然可以通过,但是显然,程序将在运行时崩溃。Objective-C 是不安全的,编译器默认你知道某个方法确实有实现,这是消息发送的灵活性所必须付出的代价。而在 app 开发看来,用可能的崩溃来换取灵活性,显然这个代价太大了。虽然这不是 OOP 范式的问题,但它确实在 Objective-C 时代给我们带来了切肤之痛。

三大困境

我们可以总结一下 OOP 面临的这几个问题。

  • 动态派发安全性
  • 横切关注点
  • 菱形缺陷

首先,在 OC 中动态派发让我们承担了在运行时才发现错误的风险,这很有可能是发生在上线产品中的错误。其次,横切关注点让我们难以对对象进行完美的建模,代码的重用也会更加糟糕。

承・相知 - 协议扩展和面向协议编程

使用协议解决 OOP 困境

协议并不是什么新东西,也不是 Swift 的发明。在 Java 和 C# 里,它叫做 Interface。而 Swift 中的 protocol 将这个概念继承了下来,并发扬光大。让我们回到一开始定义的那个简单协议,并尝试着实现这个协议:

protocol Greetable {
    var name: String { get }
    func greet()
}
struct Person: Greetable {
    let name: String
    func greet() {
        print("你好 \(name)")
    }
}
Person(name: "Wei Wang").greet()

实现很简单,Person 结构体通过实现 name 和 greet 来满足 Greetable。在调用时,我们就可以使用 Greetable 中定义的方法了。

动态派发安全性

除了 Person,其他类型也可以实现 Greetable,比如 Cat

struct Cat: Greetable {
    let name: String
    func greet() {
        print("meow~ \(name)")
    }
}

现在,我们就可以将协议作为标准类型,来对方法调用进行动态派发了:

let array: [Greetable] = [
        Person(name: "Wei Wang"), 
        Cat(name: "onevcat")]
for obj in array {
    obj.greet()
}
// 你好 Wei Wang
// meow~ onevcat

对于没有实现 Greetbale 的类型,编译器将返回错误,因此不存在消息误发送的情况:

struct Bug: Greetable {
    let name: String
}

// Compiler Error: 
// 'Bug' does not conform to protocol 'Greetable'
// protocol requires function 'greet()'

这样一来,动态派发安全性的问题迎刃而解。如果你保持在 Swift 的世界里,那这个你的所有代码都是安全的。

  • ✅ 动态派发安全性
  • 横切关注点
  • 菱形缺陷
横切关注点

使用协议和协议扩展,我们可以很好地共享代码。回到上一节的 myMethod 方法,我们来看看如何使用协议来搞定它。首先,我们可以定义一个含有 myMethod 的协议:

protocol P {
    func myMethod()
}

注意这个协议没有提供任何的实现。我们依然需要在实际类型遵守这个协议的时候为它提供具体的实现:

// class ViewController: UIViewController
extension ViewController: P {
    func myMethod() {
        doWork()
    }
}

// class AnotherViewController: UITableViewController
extension AnotherViewController: P {
    func myMethod() {
        doWork()
    }
}

你可能不禁要问,这和 Copy & Paste 的解决方式有何不同?没错,答案就是 – 没有不同。不过稍安勿躁,我们还有其他科技可以解决这个问题,那就是协议扩展。协议本身并不是很强大,只是静态类型语言的编译器保证,在很多静态语言中也有类似的概念。那到底是什么让 Swift 成为了一门协议优先的语言?真正使协议发生质变,并让大家如此关注的原因,其实是在 WWDC 2015 和 Swift 2 发布时,Apple 为协议引入了一个新特性,协议扩展,它为 Swift 语言带来了一次革命性的变化。

所谓协议扩展,就是我们可以为一个协议提供默认的实现。对于 P,可以在 extension P 中为 myMethod 添加一个实现:

protocol P {
    func myMethod()
}

extension P {
    func myMethod() {
        doWork()
    }
}

有了这个协议扩展后,我们只需要简单地声明 ViewController 和 AnotherViewController 遵守 P,就可以直接使用 myMethod 的实现了:

extension ViewController: P { }
extension AnotherViewController: P { }

viewController.myMethod()
anotherViewController.myMethod()

不仅如此,除了已经定义过的方法,我们甚至可以在扩展中添加协议里没有定义过的方法。在这些额外的方法中,我们可以依赖协议定义过的方法进行操作。我们之后会看到更多的例子。总结下来:

  • 协议定义 
    提供实现的入口 
    遵循协议的类型需要对其进行实现

  • 协议扩展 
    为入口提供默认实现 
    根据入口提供额外实现 
    这样一来,横切点关注的问题也简单安全地得到了解决。

  • ✅ 动态派发安全性

  • ✅ 横切关注点
  • 菱形缺陷
菱形缺陷

最后我们看看多继承。多继承中存在的一个重要问题是菱形缺陷,也就是子类无法确定使用哪个父类的方法。在协议的对应方面,这个问题虽然依然存在,但却是可以唯一安全地确定的。我们来看一个多个协议中出现同名元素的例子:

protocol Nameable {
    var name: String { get }
}

protocol Identifiable {
    var name: String { get }
    var id: Int { get }
}

如果有一个类型,需要同时实现两个协议的话,它必须提供一个 name 属性,来同时满足两个协议的要求:

struct Person: Nameable, Identifiable {
    let name: String 
    let id: Int
}

// `name` 属性同时满足 Nameable 和 Identifiable 的 name

这里比较有意思,又有点让人困惑的是,如果我们为其中的某个协议进行了扩展,在其中提供了默认的 name 实现,会如何。考虑下面的代码:

extension Nameable {
    var name: String { return "default name" }
}

struct Person: Nameable, Identifiable {
    // let name: String 
    let id: Int
}

// Identifiable 也将使用 Nameable extension 中的 name

这样的编译是可以通过的,虽然 Person 中没有定义 name,但是通过 Nameable 的 name (因为它是静态派发的),Person依然可以遵守 Identifiable。不过,当 Nameable 和 Identifiable 都有 name 的协议扩展的话,就无法编译了:

extension Nameable {
    var name: String { return "default name" }
}

extension Identifiable {
    var name: String { return "another default name" }
}

struct Person: Nameable, Identifiable {
    // let name: String 
    let id: Int
}

// 无法编译,name 属性冲突

这种情况下,Person 无法确定要使用哪个协议扩展中 name 的定义。在同时实现两个含有同名元素的协议,并且它们都提供了默认扩展时,我们需要在具体的类型中明确地提供实现。这里我们将 Person 中的 name 进行实现就可以了:

extension Nameable {
    var name: String { return "default name" }
}

extension Identifiable {
    var name: String { return "another default name" }
}

struct Person: Nameable, Identifiable {
    let name: String 
    let id: Int
}

Person(name: "onevcat", id: 123).name // onevcat

这里的行为看起来和菱形问题很像,但是有一些本质不同。首先,这个问题出现的前提条件是同名元素以及同时提供了实现,而协议扩展对于协议本身来说并不是必须的。其次,我们在具体类型中提供的实现一定是安全和确定的。当然,菱形缺陷没有被完全解决,Swift 还不能很好地处理多个协议的冲突,这是 Swift 现在的不足。

  • ✅ 动态派发安全性
  • ✅ 横切关注点
  • ❓菱形缺陷

本文的下半部分将展示一些笔者日常使用面向协议思想和 Cocoa 开发结合的示例代码,并对其进行了一些解说。

我们在这个部分会举一个实际的例子,来看看 POP 是如何帮助我们写出更好的代码的。

基于 Protocol 的网络请求

网络请求层是实践 POP 的一个理想场所。我们在接下的例子中将从零开始,用最简单的面向协议的方式先构建一个不那么完美的网络请求和模型层,它可能包含一些不合理的设计和耦合,但是却是初步最容易得到的结果。然后我们将逐步捋清各部分的所属,并用分离职责的方式来进行重构。最后我们会为这个网络请求层进行测试。通过这个例子,我希望能够设计出包括类型安全,解耦合,易于测试和良好的扩展性等诸多优秀特性在内的 POP 代码。

Talk is cheap, show me the code.

初步实现

首先,我们想要做的事情是从一个 API 请求一个 JSON,然后将它转换为 Swift 中可用的实例。作为例子的 API 非常简单,你可以直接访问 https://api.onevcat.com/users/onevcat 来查看返回:

{"name":"onevcat","message":"Welcome to MDCC 16!"}

我们可以新建一个项目,并添加 User.swift 来作为模型:

// User.swift
import Foundation

struct User {
    let name: String
    let message: String

    init?(data: Data) {
        guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
            return nil
        }
        guard let name = obj?["name"] as? String else {
            return nil
        }
        guard let message = obj?["message"] as? String else {
            return nil
        }

        self.name = name
        self.message = message
    }
}

User.init(data:) 将输入的数据 (从网络请求 API 获取) 解析为 JSON 对象,然后从中取出 name 和 message,并构建代表 API 返回的 User 实例,非常简单。

现在让我们来看看有趣的部分,也就是如何使用 POP 的方式从 URL 请求数据,并生成对应的 User。首先,我们可以创建一个 protocol 来代表请求。对于一个请求,我们需要知道它的请求路径,HTTP 方法,所需要的参数等信息。一开始这个协议可能是这样的:

enum HTTPMethod: String {
    case GET
    case POST
}

protocol Request {
    var host: String { get }
    var path: String { get }

    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }
}

将 host 和 path 拼接起来可以得到我们需要请求的 API 地址。为了简化,HTTPMethod 现在只包含了 GET 和 POST 两种请求方式,而在我们的例子中,我们只会使用到 GET 请求。

现在,可以新建一个 UserRequest 来实现 Request 协议:

struct UserRequest: Request {
    let name: String

    let host: = "https://api.onevcat.com"
    var path: String {
        return "/users/\(name)"
    }
    let method: HTTPMethod = .GET
    let parameter: [String: Any] = [:]
}

UserRequest 中有一个未定义初始值的 name 属性,其他的属性都是为了满足协议所定义的。因为请求的参数用户名 name会通过 URL 进行传递,所以 parameter 是一个空字典就足够了。有了协议定义和一个满足定义的具体请求,现在我们需要发送请求。为了任意请求都可以通过同样的方法发送,我们将发送的方法定义在 Request 协议扩展上:

extension Request {
    func send(handler: @escaping (User?) -> Void) {
        // ... send 的实现
    }
}

在 send(handler:) 的参数中,我们定义了可逃逸的 (User?) -> Void,在请求完成后,我们调用这个 handler 方法来通知调用者请求是否完成,如果一切正常,则将一个 User 实例传回,否则传回 nil

我们想要这个 send 方法对于所有的 Request 都通用,所以显然回调的参数类型不能是 User。通过在 Request 协议中添加一个关联类型,我们可以将回调参数进行抽象。在 Request 最后添加:

protocol Request {
    ...
    associatedtype Response
}

然后在 UserRequest 中,我们也相应地添加类型定义,以满足协议:

struct UserRequest: Request {
    ...
    typealias Response = User
}

现在,我们来重新实现 send 方法,现在,我们可以用 Response 代替具体的 User,让 send 一般化。我们这里使用 URLSession 来发送请求:

extension Request {
    func send(handler: @escaping (Response?) -> Void) {
        let url = URL(string: host.appending(path))!
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue

        // 在示例中我们不需要 `httpBody`,实践中可能需要将 parameter 转为 data
        // request.httpBody = ...

        let task = URLSession.shared.dataTask(with: request) {
            data, res, error in
            // 处理结果
            print(data)
        }
        task.resume()
    }
}

通过拼接 host 和 path,可以得到 API 的 entry point。根据这个 URL 创建请求,进行配置,生成 data task 并将请求发送。剩下的工作就是将回调中的 data 转换为合适的对象类型,并调用 handler 通知外部调用者了。对于 User 我们知道可以使用 User.init(data:),但是对于一般的 Response,我们还不知道要如何将数据转为模型。我们可以在 Request 里再定义一个 parse(data:) 方法,来要求满足该协议的具体类型提供合适的实现。这样一来,提供转换方法的任务就被“下放”到了 UserRequest

protocol Request {
    ...
    associatedtype Response
    func parse(data: Data) -> Response?
}

struct UserRequest: Request {
    ...
    typealias Response = User
    func parse(data: Data) -> User? {
        return User(data: data)
    }
}

有了将 data 转换为 Response 的方法后,我们就可以对请求的结果进行处理了:

extension Request {
    func send(handler: @escaping (Response?) -> Void) {
        let url = URL(string: host.appending(path))!
        var request = URLRequest(url: url)
        request.httpMethod = method.rawValue

        // 在示例中我们不需要 `httpBody`,实践中可能需要将 parameter 转为 data
        // request.httpBody = ...

        let task = URLSession.shared.dataTask(with: request) {
            data, _, error in
            if let data = data, let res = parse(data: data) {
                DispatchQueue.main.async { handler(res) }
            } else {
                DispatchQueue.main.async { handler(nil) }
            }
        }
        task.resume()
    }
}

现在,我们来试试看请求一下这个 API:

let request = UserRequest(name: "onevcat")
request.send { user in
    if let user = user {
        print("\(user.message) from \(user.name)")
    }
}

// Welcome to MDCC 16! from onevcat
重构,关注点分离

虽然能够实现需求,但是上面的实现可以说非常糟糕。让我们看看现在 Request 的定义和扩展:

protocol Request {
    var host: String { get }
    var path: String { get }

    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }

    associatedtype Response
    func parse(data: Data) -> Response?
}

extension Request {
    func send(handler: @escaping (Response?) -> Void) {
        ...
    }
}

这里最大的问题在于,Request 管理了太多的东西。一个 Request 应该做的事情应该仅仅是定义请求入口和期望的响应类型,而现在 Request 不光定义了 host 的值,还对如何解析数据了如指掌。最后 send 方法被绑死在了 URLSession 的实现上,而且是作为 Request 的一部分存在。这是很不合理的,因为这意味着我们无法在不更改请求的情况下更新发送请求的方式,它们被耦合在了一起。这样的结构让测试变得异常困难,我们可能需要通过 stub 和 mock 的方式对请求拦截,然后返回构造的数据,这会用到 NSURLProtocol 的内容,或者是引入一些第三方的测试框架,大大增加了项目的复杂度。在 Objective-C 时期这可能是一个可选项,但是在 Swift 的新时代,我们有好得多的方法来处理这件事情。

让我们开始着手重构刚才的代码,并为它们加上测试吧。首先我们将 send(handler:) 从 Request 分离出来。我们需要一个单独的类型来负责发送请求。这里基于 POP 的开发方式,我们从定义一个可以发送请求的协议开始:

protocol Client {
    func send(_ r: Request, handler: @escaping (Request.Response?) -> Void)
}

// 编译错误

从上面的声明从语义上来说是挺明确的,但是因为 Request 是含有关联类型的协议,所以它并不能作为独立的类型来使用,我们只能够将它作为类型约束,来限制输入参数 request。正确的声明方式应当是:

protocol Client {
    func send<T: Request>(_ r: T, handler: @escaping (T.Response?) -> Void)

    var host: String { get }
}

除了使用 <T: Request> 这个泛型方式以外,我们还将 host 从 Request 移动到了 Client 里,这是更适合它的地方。现在,我们可以把含有 send 的 Request 协议扩展删除,重新创建一个类型来满足 Client 了。和之前一样,它将使用 URLSession 来发送请求:

struct URLSessionClient: Client {
    let host = "https://api.onevcat.com"

    func send<T: Request>(_ r: T, handler: @escaping (T.Response?) -> Void) {
        let url = URL(string: host.appending(r.path))!
        var request = URLRequest(url: url)
        request.httpMethod = r.method.rawValue

        let task = URLSession.shared.dataTask(with: request) {
            data, _, error in
            if let data = data, let res = parse(data: data) {
                DispatchQueue.main.async { handler(res) }
            } else {
                DispatchQueue.main.async { handler(nil) }
            }
        }
        task.resume()
    }
}

现在发送请求的部分和请求本身分离开了,而且我们使用协议的方式定义了 Client。除了 URLSessionClient 以外,我们还可以使用任意的类型来满足这个协议,并发送请求。这样网络层的具体实现和请求本身就不再相关了,我们之后在测试的时候会进一步看到这么做所带来的好处。

现在这个的实现里还有一个问题,那就是 Request 的 parse 方法。请求不应该也不需要知道如何解析得到的数据,这项工作应该交给 Response 来做。而现在我们没有对 Response 进行任何限定。接下来我们将新增一个协议,满足这个协议的类型将知道如何将一个 data 转换为实际的类型:

protocol Decodable {
    static func parse(data: Data) -> Self?
}

Decodable 定义了一个静态的 parse 方法,现在我们需要在 Request 的 Response 关联类型中为它加上这个限制,这样我们可以保证所有的 Response 都可以对数据进行解析,原来 Request 中的 parse 声明也就可以移除了:

// 最终的 Request 协议
protocol Request {
    var path: String { get }
    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }

    // associatedtype Response
    // func parse(data: Data) -> Response?
    associatedtype Response: Decodable
}

最后要做的就是让 User 满足 Decodable,并且修改上面 URLSessionClient 的解析部分的代码,让它使用 Response 中的 parse 方法:

extension User: Decodable {
    static func parse(data: Data) -> User? {
        return User(data: data)
    }
}

struct URLSessionClient: Client {
    func send<T: Request>(_ r: T, handler: @escaping (T.Response?) -> Void) {
        ...
     // if let data = data, let res = parse(data: data) {
        if let data = data, let res = T.Response.parse(data: data) {
            ...
        }
    }
}

最后,将 UserRequest 中不再需要的 host 和 parse 等清理一下,一个类型安全,解耦合的面向协议的网络层就呈现在我们眼前了。想要调用 UserRequest 时,我们可以这样写:

URLSessionClient().send(UserRequest(name: "onevcat")) { user in
    if let user = user {
        print("\(user.message) from \(user.name)")
    }
}

当然,你也可以为 URLSessionClient 添加一个单例来减少请求时的创建开销,或者为请求添加 Promise 的调用方式等等。在 POP 的组织下,这些改动都很自然,也不会牵扯到请求的其他部分。你可以用和 UserRequest 类型相似的方式,为网络层添加其他的 API 请求,只需要定义请求所必要的内容,而不用担心会触及网络方面的具体实现。

网络层测试

将 Client 声明为协议给我们带来了额外的好处,那就是我们不在局限于使用某种特定的技术 (比如这里的 URLSession) 来实现网络请求。利用 POP,你只是定义了一个发送请求的协议,你可以很容易地使用像是 AFNetworking 或者 Alamofire 这样的成熟的第三方框架来构建具体的数据并处理请求的底层实现。我们甚至可以提供一组“虚假”的对请求的响应,用来进行测试。这和传统的 stub & mock 的方式在概念上是接近的,但是实现起来要简单得多,也明确得多。我们现在来看一看具体应该怎么做。

我们先准备一个文本文件,将它添加到项目的测试 target 中,作为网络请求返回的内容:

// 文件名:users:onevcat
{"name":"Wei Wang", "message": "hello"}

接下来,可以创建一个新的类型,让它满足 Client 协议。但是与 URLSessionClient 不同,这个新类型的 send 方法并不会实际去创建请求,并发送给服务器。我们在测试时需要验证的是一个请求发出后如果服务器按照文档正确响应,那么我们应该也可以得到正确的模型实例。所以这个新的 Client 需要做的事情就是从本地文件中加载定义好的结果,然后验证模型实例是否正确:

struct LocalFileClient: Client {
    func send<T : Request>(_ r: T, handler: @escaping (T.Response?) -> Void) {
        switch r.path {
        case "/users/onevcat":
            guard let fileURL = Bundle(for: ProtocolNetworkTests.self).url(forResource: "users:onevcat", withExtension: "") else {
                fatalError()
            }
            guard let data = try? Data(contentsOf: fileURL) else {
                fatalError()
            }
            handler(T.Response.parse(data: data))
        default:
            fatalError("Unknown path")
        }
    }

    // 为了满足 `Client` 的要求,实际我们不会发送请求
    let host = ""
}

LocalFileClient 做的事情很简单,它先检查输入请求的 path 属性,如果是 /users/onevcat (也就是我们需要测试的请求),那么就从测试的 bundle 中读取预先定义的文件,将其作为返回结果进行 parse,然后调用 handler。如果我们需要增加其他请求的测试,可以添加新的 case 项。另外,加载本地文件资源的部分应该使用更通用的写法,不过因为我们这里只是示例,就不过多纠结了。

在 LocalFileClient 的帮助下,现在可以很容易地对 UserRequest 进行测试了:

func testUserRequest() {
    let client = LocalFileClient()
    client.send(UserRequest(name: "onevcat")) {
        user in
        XCTAssertNotNil(user)
        XCTAssertEqual(user!.name, "Wei Wang")
    }
}

通过这种方法,我们没有依赖任何第三方测试库,也没有使用 URL 代理或者运行时消息转发等等这些复杂的技术,就可以进行请求测试了。保持简单的代码和逻辑,对于项目维护和发展是至关重要的。

可扩展性

因为高度解耦,这种基于 POP 的实现为代码的扩展提供了相对宽松的可能性。我们刚才已经说过,你不必自行去实现一个完整的 Client,而可以依赖于现有的网络请求框架,实现请求发送的方法即可。也就是说,你也可以很容易地将某个正在使用的请求方式替换为另外的方式,而不会影响到请求的定义和使用。类似地,在 Response 的处理上,现在我们定义了 Decodable,用自己手写的方式在解析模型。我们完全也可以使用任意的第三方 JSON 解析库,来帮助我们迅速构建模型类型,这仅仅只需要实现一个将 Data 转换为对应模型类型的方法即可。

如果你对 POP 方式的网络请求和模型解析感兴趣的话,不妨可以看看 APIKit 这个框架,我们在示例中所展示的方法,正是这个框架的核心思想。

合・陪伴 - 使用协议帮助改善代码设计

通过面向协议的编程,我们可以从传统的继承上解放出来,用一种更灵活的方式,搭积木一样对程序进行组装。每个协议专注于自己的功能,特别得益于协议扩展,我们可以减少类和继承带来的共享状态的风险,让代码更加清晰。

高度的协议化有助于解耦、测试以及扩展,而结合泛型来使用协议,更可以让我们免于动态调用和类型转换的苦恼,保证了代码的安全性。

提问环节

主题演讲后有几位朋友提了一些很有意义的问题,在这里我也稍作整理。有可能问题和回答与当时的情形会有小的出入,仅供参考。

我刚才在看 demo 的时候发现,你都是直接先写 protocol,而不是 struct 或者 class。是不是我们在实践 POP 的时候都应该直接先定义协议?

我直接写 protocol 是因为我已经对我要做什么有充分的了解,并且希望演讲不要超时。但是实际开发的时候你可能会无法一开始就写出合适的协议定义。建议可以像我在 demo 中做的那样,先“粗略”地进行定义,然后通过不断重构来得到一个最终的版本。当然,你也可以先用纸笔勾勒一个轮廓,然后再去定义和实现协议。当然了,也没人规定一定需要先定义协议,你完全也可以从普通类型开始写起,然后等发现共通点或者遇到我们之前提到的困境时,再回头看看是不是面向协议更加合适,这需要一定的 POP 经验。

既然 POP 有这么多好处,那我们是不是不再需要面向对象,可以全面转向面向协议了?

答案可能让你失望。在我们的日常项目中,每天打交道的 Cocoa 其实还是一个带有浓厚 OOP 色彩的框架。也就是说,可能一段时期内我们不可能抛弃 OOP。不过 POP 其实可以和 OOP “和谐共处”,我们也已经看到了不少使用 POP 改善代码设计的例子。另外需要补充的是,POP 其实也并不是银弹,它有不好的一面。最大的问题是协议会增加代码的抽象层级 (这点上和类继承是一样的),特别是当你的协议又继承了其他协议的时候,这个问题尤为严重。在经过若干层的继承后,满足末端的协议会变得困难,你也难以确定某个方法究竟满足的是哪个协议的要求。这会让代码迅速变得复杂。如果一个协议并没有能描述很多共通点,或者说能让人很快理解的话,可能使用基本的类型还会更简单一些。

谢谢你的演讲,想问一下你们在项目中使用 POP 的情况。

我们在项目里用了很多 POP 的概念。上面 demo 里的网络请求的例子就是从实际项目中抽出来的,我们觉得这样的请求写起来非常轻松,因为代码很简单,新人进来交接也十分惬意。除了模型层之外,我们在 view 和 view controller 层也用了一些 POP 的代码,比如从 nib 创建 view 的 NibCreatable,支持分页请求 tableview controller 的 NextPageLoadable,空列表时显示页面的 EmptyPage 等等。因为时间有限,不可能展开一一说明,所以这里我只挑选了一个具有代表性,又不是很复杂的网络的例子。其实每个协议都让我们的代码,特别是 View Controller 变短,而且使测试变为可能。可以说,我们的项目从 POP 受益良多,而且我们应该会继续使用下去。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值