写树洞客户端时,和一个 PKU 的同学一起写,他负责写调用树洞 HTTP API 获取数据的部分。既然涉及到协作,就得先规定下接口了。

对于 Swift 来说,规定接口的最好的办法自然是 protocol ——规定并强制实现所有的接口。于是,我着手写这个协议的声明。

源代码参见 liang2kl/HollowCore

协议的设计

目标

协议的设计有两个目标:

  • 所有的请求均提供统一的接口,方便调用
  • 尽可能方便不同的请求类复用代码

v1.0:最初尝试

接口无非是 输入 + 输出,对于网络请求,输入即是请求的参数,输出是得到的 response。很容易想到这样一个声明:

protocol Request {
    // 用网络请求参数初始化请求
    init(parameters: [String : String])
    // 开始请求
    func performRequest(completion: @escaping (String?, Error?) -> Void)
}

其中,init 接受一个字典作为输入,performRequest 接受一个回调函数,异步处理数据。

乍看之下,似乎没什么问题——调用者只需要将参数的字典传进去,然后接收 JSON 格式的 String 字符串就行了。不过,这个设计真的合理吗?

仔细分析,这个版本的协议存在这些问题:

  1. 调用者的输入 parameters: [String : String] 没有受到任何限制,导致:
    • 调用者在每次调用时都需要查看文档以确定需要传哪些参数
    • 被调用者需要繁琐地一一检查字典的 key 和 value 是否符合要求
  2. 直接返回原始的 JSON 字符串,每次调用后均需要解析

问题的根本在于,完成网络请求的类并不知道任何关于请求本身的具体信息。我们需要向类提供这些信息,使得类只接受符合规定的输入,并提供解析好的、符合规定的输出。而且,完成这个目标最好是依赖编译期进行静态的检查,避免可能出现的失误。朝着这个目标,我们来写 2.0 版本。

v2.0:添加请求信息

我们需要向 Request 协议内添加参数、输出的信息。

首先考虑传递参数的问题。一个朴素的想法是在 init 中分别列出参数,如:

init(token: String, page: Int, ...)

但是这就违背了我们**「提供一个统一的接口」**的宗旨。理想情况下,我们应该提供这样一个接口:

init(configuration: Configuration)

其中,Configuration 包装了所有需要的参数,这通常使用 struct 完成。

但是,不同请求的 Configuration 是不一样的,该怎么办?

幸运的是,protocol 支持“泛型”,我们只需要针对不同的请求定义不同的 Configuration 类就可以了。同理,每个请求应该有对应的 Response 类型。另外,为了方便处理错误,添加一个 Error 类型,其遵循 Swift 中 Error 协议,用来指定具体的错误。新的设计如下:

protocol Request {
    associatedtype Configuration
    associatedtype Response
    associatedtype Error: Swift.Error

    // 储存参数信息
    var configuration: Configuration { get set }
    
    init(configuration: Configuration)
    func performRequest(completion: @escaping (Response?, Error?) -> Void)
}

我们可以开始实现具体的请求了。比如,对于编辑收藏的请求:

struct EditAttentionRequest: Request {
    struct Configuration {
        var token: String
        var postId: Int
        var switchToAttention: Bool
    }

    struct Response {
        // 暂时省略
        ...
    }

    enum Error: Swift.Error {
        case tokenExpired,
        case ...
    }
    
    var configuration: Configuration
    
    init(configuration: Configuration) {
        self.configuration = configuration
    }
    
    func performRequest(completion: @escaping (Response?, Error?) -> Void) {
        let urlPath = "v3/edit/attention"
        ...
    }
}

这样,对于调用者来说就轻松很多了,不再需要翻文档来传参数,也不需要自己来解析 JSON 字符串,只需要根据 Configuration 的构造函数传递参数,并利用得到的 Response 即可:

let configuration = EditAttentionRequest.Configuration(...)
let request = EditAttentionRequest(configuration: configuration)

request.performRequest { response, error in
    ...
}

这个版本的 Request 基本上实现了我们设计一个统一接口的目标。

v2.1:Response 的界定

注意到上面的 EditAttentionRequest 中省略了 Response 的定义。我们需要明确 Response 是什么。

从调用者的角度,Response 应该是可以直接利用,而不需要再进行处理的数据;而从请求本身的角度,Response 指的是服务器返回的 JSON 字符串解析出来的对象。但是,这两个概念并不总是一样的:服务器返回的结果往往需要进一步处理,比如指示请求状态的 code,错误信息 msg 等。

于是,Response 的概念需要分成两部分:服务器返回的结果 Result,和处理后最终返回的可以直接使用的数据 ResultData

protocol Request {
    associatedtype Configuration
    associatedtype Result
    associatedtype ResultData
    associatedtype Error: Swift.Error

    init(configuration: Configuration)
    func performRequest(completion: @escaping (ResultData?, Error?) -> Void)
}

相对应地,EditAttentionRequestResultResultData 分别定义为:

struct Result: DefaultRequestResult {
    var code: Int
    var msg: String?
    var data: Post?
}

typealias ResultData = Post

在编辑完关注状态后,服务器会返回完整的树洞信息 Post,以及 codemsg。我们不需要 codemsg(错误已经在请求内部处理),于是 Post 作为 ResultData 就可以了。

定义 Result 类型是很有必要的:我们一般直接使用 JSONDecoder 将 JSON 反序列化为对象,这需要我们提供解析的模版,即 Result

至此,我们的协议声明已经基本上完成了。所有遵循 Request 协议的类型都对外提供了统一的接口,并且利用泛型实现了对参数输入、服务器返回数据、最终输出和错误的严格规定。

代码复用与接口扩展

上面我们花了大力气来规定 Request 的各种类型,但除了统一了接口、约束输入输出外并没有什么特别的作用。实际上,协议 + 泛型的巨大威力体现在代码复用接口扩展上。

代码复用

上面的 Request 只是规定了接口,我们还要逐一实现每个请求的 performRequest。如果分别独立写 HTTP 请求代码,将十分繁琐,而且会大量重复相同代码。为此,我们需要进行一些默认实现。

大多数请求满足两个共性:

  • 从 JSON 反序列化为对象的要求很简单:Result 类型遵循 Decodable 即可。对于这些请求,由获得的 JSON 生成 Result 的方法是固定的
  • 返回包括 codemsg,用来传递查询结果和错误信息。因此,Result 生成 Error 的方法是固定的。而大多数的 Error 类型也是相同的。

利用这两点,我们可以在 Request 的基础上,针对这些请求进行 performRequest 的默认实现。对这些请求,我们定义一个新的协议 DefaultRequest,遵循 Request

protocol DefaultRequest: Request 
    where Error == DefaultRequestError, Result: DefaultRequestResult { }

ErrorDefaultRequestError,是一个涵盖了所有错误类型的 enum

enum DefaultRequestError: Swift.Error, LocalizedError {
    case decodeFailed
    case tokenExpiredError
    case fileTooLarge
    case unknown
    case noSuchPost
    case other(description: String)

    init(errorCode: Int, description: String?) {...}
}

Result 遵循 DefaultRequestResult,其定义如下:

protocol DefaultRequestResult: Codable {
    var code: Int { get }
    var msg: String? { get }
}

分别对应上述两个共同点。

于是,我们就可以进行 performRequest 的默认实现了。在 DefaultRequestextension1 中:

extension DefaultRequest {
    func performRequest(
        urlRoot: String,
        urlPath: String,
        parameters: [String : Any]? = nil,
        headers: HTTPHeaders? = nil,
        method: HTTPMethod,
        transformer: @escaping (Result) -> ResultData?,
        completion: @escaping (ResultData?, Error?) -> Void
    ) {...}
}

在函数内,我们需要实现:

  • 将网络请求得到的 JSON 反序列化为对象,由 DefaultRequestResult 遵循 Codable 满足
    let jsonDecoder = JSONDecoder()
    let result = try jsonDecoder.decode(Result.self, from: data)
    
  • Result 中包含的错误信息转化为 Error 返回,由 Error 类型已知以及 DefaultRequestResult 中要求 codemsg 满足
    let error = DefaultRequestError(errorCode: result.code, description: result.msg)
    
  • Result 转化为 ResultData,由传入的参数 transformer 提供
    if let data = transformer(result) {
        completion(data, nil)
    }
    

我们在对 protocol 的泛型作了一定的约束后,获得了更多的信息,使得我们可以在不知道请求的具体类型的情况下进行默认实现

完整代码如下:

func performRequest(
    urlRoot: String,
    urlPath: String,
    parameters: [String : Any]? = nil,
    headers: HTTPHeaders? = nil,
    method: HTTPMethod,
    transformer: @escaping (Result) -> ResultData?,
    completion: @escaping (ResultData?, Error?) -> Void
) {
    AF.request(
        urlRoot + urlPath,
        method: method,
        parameters: parameters,
        encoding: URLEncoding.default,
        headers: headers
    )
    .validate()
    .responseJSON { response in
        switch response.result {
        case .success:
            let jsonDecoder = JSONDecoder()
            jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
            do {
                guard let data = response.data else {
                    completion(nil, .unknown)
                    return
                }
                let result = try jsonDecoder.decode(Result.self, from: data)
                if result.code >= 0 {
                    // result code >= 0 valid!
                    if let data = transformer(result) {
                        completion(data, nil)
                    } else {
                        completion(nil, .unknown)
                    }
                } else {
                    let error = DefaultRequestError(errorCode: result.code, description: result.msg)
                    completion(nil, error)
                    return
                }
                
            } catch {
                completion(nil, .decodeFailed)
                return
            }
            
        case let .failure(error):
            if let errorCode = error.responseCode, errorCode == 413 {
                completion(nil, .fileTooLarge)
                return
            } else {
                completion(nil, .other(description: error.localizedDescription))
                return
            }
        }
    }
}

现在,我们可以轻松实现具体类型的 performRequest 了,如 EditAttentionRequest

func performRequest(completion: @escaping (ResultData?, Error?) -> Void) {
    let urlPath = "v3/edit/attention" + Constants.urlSuffix
    
    let headers: HTTPHeaders = [
        "TOKEN": self.configuration.token,
        "Accept": "application/json"
    ]
    
    let parameters: [String : Encodable] = [
        "pid" : self.configuration.postId,
        "switch": self.configuration.switchToAttention ? 1 : 0,
    ]

    performRequest(
        urlRoot: self.configuration.apiRoot,
        urlPath: urlPath,
        parameters: parameters,
        headers: headers,
        method: .post,
        transformer: { $0.data },
        completion: completion
    )
}

我们只需要将 Configuration 转换为字典就可以了。在实际项目中,几乎所有的请求都可以遵循 DefaultRequest,只需要写一次网络请求的代码,就可以实现所有的网络请求。这就是 protocol + 泛型 + 限制条件的魅力。

接口扩展

利用 protocol 的泛型,还有个好处在于扩展接口十分方便。例如,针对 Swift 5.5async/await,我们很容易为 Request 添加异步接口:

extension Request {
    func result() async -> (ResultData?, Error?) {
        return await withCheckedContinuation { continuation in
            performRequest { result in
                continuation.resume(returning: result)
            }
        }
    }
}

几乎不需要写任何代码。

总结

Swift 的 protocol 相当于 C++ 的抽象类,不过相比 C++ 更加严谨、灵活。protocol 本身有规定接口的功能,再加上泛型,能够很好地实现多态,实现高效的代码复用,同时保持良好的可维护性和可扩展性。