协议 + 泛型:威力巨大的组合
写树洞客户端时,和一个 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
字符串就行了。不过,这个设计真的合理吗?
仔细分析,这个版本的协议存在这些问题:
- 调用者的输入
parameters: [String : String]
没有受到任何限制,导致:- 调用者在每次调用时都需要查看文档以确定需要传哪些参数
- 被调用者需要繁琐地一一检查字典的 key 和 value 是否符合要求
- 直接返回原始的 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)
}
相对应地,EditAttentionRequest
中 Result
和 ResultData
分别定义为:
struct Result: DefaultRequestResult {
var code: Int
var msg: String?
var data: Post?
}
typealias ResultData = Post
在编辑完关注状态后,服务器会返回完整的树洞信息 Post
,以及 code
和 msg
。我们不需要 code
和 msg
(错误已经在请求内部处理),于是 Post
作为 ResultData
就可以了。
定义
Result
类型是很有必要的:我们一般直接使用JSONDecoder
将 JSON 反序列化为对象,这需要我们提供解析的模版,即Result
。
至此,我们的协议声明已经基本上完成了。所有遵循 Request
协议的类型都对外提供了统一的接口,并且利用泛型实现了对参数输入、服务器返回数据、最终输出和错误的严格规定。
代码复用与接口扩展⌗
上面我们花了大力气来规定 Request
的各种类型,但除了统一了接口、约束输入输出外并没有什么特别的作用。实际上,协议 + 泛型的巨大威力体现在代码复用和接口扩展上。
代码复用⌗
上面的 Request
只是规定了接口,我们还要逐一实现每个请求的 performRequest
。如果分别独立写 HTTP 请求代码,将十分繁琐,而且会大量重复相同代码。为此,我们需要进行一些默认实现。
大多数请求满足两个共性:
- 从 JSON 反序列化为对象的要求很简单:
Result
类型遵循Decodable
即可。对于这些请求,由获得的 JSON 生成Result
的方法是固定的。 - 返回包括
code
和msg
,用来传递查询结果和错误信息。因此,从Result
生成Error
的方法是固定的。而大多数的Error
类型也是相同的。
利用这两点,我们可以在 Request
的基础上,针对这些请求进行 performRequest
的默认实现。对这些请求,我们定义一个新的协议 DefaultRequest
,遵循 Request
:
protocol DefaultRequest: Request
where Error == DefaultRequestError, Result: DefaultRequestResult { }
Error
为 DefaultRequestError
,是一个涵盖了所有错误类型的 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
的默认实现了。在 DefaultRequest
的 extension
1 中:
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
中要求code
和msg
满足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.5
的 async/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
本身有规定接口的功能,再加上泛型,能够很好地实现多态,实现高效的代码复用,同时保持良好的可维护性和可扩展性。