用 Property Wrapper 包装网络请求参数
偶然看到 Java 语言 Retrofit 库的文档,对这种标注 HTTP 请求参数的方式十分感兴趣(其实我也没学过 Java):
@FormUrlEncoded
@POST("user/edit")
Call<User> updateUser(@Field("first_name") String first, @Field("last_name") String last);
联想一下以前用 Swift 写的网络请求(参考 协议 + 泛型:威力巨大的组合),都需要手动赋值参数,实在是不太优雅:
let headers: HTTPHeaders = [
"TOKEN": self.configuration.token,
"Accept": "application/json"
]
let parameters: [String : Encodable] = [
"pid" : self.configuration.postId,
"switch": self.configuration.switchToAttention ? 1 : 0,
]
Java 的这种语法与 Swift 的 Property Wrapper
很像(实践证明 Swift 这个语法局限性还是挺大的),而且逼格也非常高 ,恰好也一直想自己写个 Property Wrapper 玩一玩研究一下,于是就动手写一个包装网络请求参数的库。
理想效果⌗
理想中,对参数的包装应该达到的效果有:
- 用 Property Wrapper 包装实际的参数值和对应的 key
struct SomeRequest: Request { @Header("TOKEN") var accessToken @Field("page") var page: Int @Field("foo") var bar: String ... }
- 能够自动找到所有的参数,并将其放入 HTTP 请求中
目标有了,就开始写吧!
设计与实现⌗
包装参数⌗
我们的 Property Wrapper 中需要 key 和 value。尝试写一下 @Query
:
@propertyWrapper
struct Query<Value: Encodable> {
var key: String
var wrappedValue: Value
init(wrappedValue: Value, _ key: String) {
self.key = key
self.wrappedValue = wrappedValue
}
}
@Query
接受 String
作为 key,和一个遵循 Encodable
协议的值作为 value。
可是,当我们尝试使用这个 Property Wrapper 的时候,却出现了问题:
@Query("foo") var bar: Int
这样写会出现编译错误:
Missing argument for parameter 'wrappedValue' in call
原因在于,@Query("foo") var bar: Int
的声明其实是在调用 Query<Int>
的 init(wrappedValue: Value, _ key: String)
,而 Swift 不允许存在未初始化的成员变量,所以必须同时初始化 key
和 wrappedValue
:
@Query("foo") var bar: Int = 1
但是,总不能要求调用者每种类型都给出个默认值……这样实在是太丑陋了,也不符合语义—— @Query("foo")
声明 key,value 应该是之后赋值的。而且也不现实,如果 wrappedValue
是一个自定义类型的话,提供默认值是一件匪夷所思的事情。
没办法,Property Wrapper 括号中的参数并不是严格意义上静态的 attribute,而是一个函数的参数,是一个变量,这本来就是和我们的需求相背的。
那么,退而求其次,既然不能要求提供默认值,那我们可以用一种不会产生歧义的、带有默认初始化方法的变量—— Optional
:
@propertyWrapper
struct Query<Value: Encodable> {
var key: String
var wrappedValue: Value?
init(wrappedValue: Value?, _ key: String) {
self.key = key
self.wrappedValue = wrappedValue
}
}
这样一来,参数的声明就可以稍微合理一点了:
@Query("foo") var bar: Int? = nil
虽然我们还是要提供一个初始值,但是 nil
在这里刚好可以表达“未初始化”的意思,在以后解析所有参数的时候,如果这个值没有被设定,我们直接跳过这个请求参数就好了。
到这里,自然会想到在 init
中给 wrappedValue
设定 nil
的默认值,使得我们不需要手动写 = nil
:
init(wrappedValue: Value? = nil, _ key: String)
这似乎解决了问题,我们可以这样写了:
@Query("foo") var bar: Int?
但是又多了一个问题:我们不能直接用 wrappedValue
初始化含有 @Query
变量的类型了,如:
struct Foo {
@Query("foo") var bar: Int?
}
// let foo = Foo(bar: 1)
// Cannot convert value of type 'Int' to expected argument type 'Query<Int>'
用一个例子测试一下 Property Wrapper 初始化的原理:
struct A<Value: Encodable> { var key: String private var value: Value? public var wrappedValue: Value? { get { value } set { value = newValue; print(newValue) } } public init(wrappedValue: Value?, _ key: String) { self.key = key self.value = wrappedValue print("Initializing with wrappedValue \(wrappedValue), key \(key)") } } struct Test { @A("hello") var a: Int? = nil } let foo = Test() let bar = Test(a: 1)
输出为:
Initializing with wrappedValue nil, key hello Initializing with wrappedValue Optional(1), key hello
原来
@A
变量在Test
的构造函数中无论是否赋值,都只会调用一次@A
的init
。当我们不指定a
的值的时候,使用我们提供的默认值nil
进行初始化;当我们指定a
的值的时候,使用我们指定的值。所以,
@Query("foo") var bar: Int? = nil
中= nil
其实就相当于init
的一个默认参数。这也解释了为何我们直接在init
中设参数默认值使得无法用wrappedValue
初始化。
所以,还是手动把 = nil
加上为妙。
获取信息⌗
包装好了,怎么获取这些变量是另一个大问题。要知道,我们根本不知道传进来的对象是什么。例如,我们的 SomeRequest
中有这些参数:
struct SomeRequest {
@Query("page") var page
@Query("bar") var foo: Int? = nil
}
我们当然不知道 SomeRequest
中有 page
和 foo
两个成员。
幸好,我们有 Mirror
——利用对象的元数据,能够向我们提供对象成员变量的信息。1
用 Mirror
尝试一下:
let mirror = Mirror(reflecting: request) // request is `SomeRequest`
for (_, property) in mirror.children {
// property 是 request 的成员变量
}
我们可以通过这种方法访问 request
对象的所有成员变量。接下来的问题是,我们怎么识别是不是 @Query
,以及怎么把 Query
里面的 key
和 wrappedValue
取出来。
一种很朴素,又很错误的想法是这样:
if let property = property as? Query {
...
}
这样当然通过不了编译,Query
只是个模版,不是具体的类。我们需要一种与 Query
模版参数无关的方法。
我们喜欢的 protocol
这时可以发挥作用了。我们不需要获取“property
具体的类型”这么强的信息,只需要这个 property
能够向我们提供我们需要的 URL 编码的 value
和 key
就行了。马上可以写一个协议:
protocol QueryProtocol {
func urlEncodedKey() -> String
func urlEncodedValue() -> String
}
我们给 Query
加上 QueryProtocol
的协议:
extension Query: QueryProtocol {
func urlEncodedKey() -> String {
return SomeURLStringEncoder().encode(key)
}
func urlEncodedValue() -> String {
return SomeURLStringEncoder().encode(wrappedValue)
}
}
这样,我们就可以用 Mirror
访问 key 和 value 了:
let mirror = Mirror(reflecting: request) // request is `SomeRequest`
for (_, property) in mirror.children {
if let query = property as? QueryProtocol {
let key = query.urlEncodedKey()
let value = query.urlEncodedValue()
// TODO: Add key, value to request URL.
}
}
这样,最后一个障碍——读取参数信息也扫清了。我们可以获取到一个类里面所有的 @Query
参数了,将所有的 key
和 value
加到请求的 URL 即可。
实际上的处理要稍微复杂一点,因为
Value
可能是一个数组,这时候就不仅仅是一个 key 和 value 对了。
后续⌗
根据相似的思路,我们可以为 Header、body 参数等定义类似的 Property Wrapper:
Property Wrapper | Description | Wrapped Value |
---|---|---|
@Header | HTTP Header | String? |
@HeaderDict | An dictionary of headers | [String : String]? |
@Query | Query parameters | Encodable? |
@QueryDict | An dictionary of query parameters | [String : String]? |
@KeyQuery | Query parameters with no value associated | Bool |
@JSON | Body parameters with JSON encoding | Encodable? |
@Field | Form URL Encoded parameters | Encodable? |
我们可以在一个类声明中定义所有的请求参数了。最后的实现效果如下:
struct SetPushRequest: JSONDecodableRequest, RequestConfiguration {
@Header("TOKEN") var accessToken = nil
@Field("push_system_msg") var pushSystemMessage: Bool? = nil
@Field("push_reply_me") var pushReplyMe: Bool? = nil
@Field("push_favorited") var pushFavourite: Bool? = nil
struct Response: Codable {
var code: Int
var msg: String?
}
let base: URL = URL(string: "https://dev-api.thuhole.com")!
let path: String = "v3/config/set_push"
let method: HTTPMethod = .post
var configuration: Self { return self }
}
let request = SetPushRequest(accessToken: token, pushSystemMessage: true, pushReplyMe: true, pushFavourite: false)
request.perform { result in
switch result {
case .success(let response):
print(response)
case .failure(let error):
print(error)
}
}
SetPushRequest
的定义不计空行只用了 14 行。在 原来树洞的代码 中,完成上述功能写了 31 行(不包括上面代码不涉及的功能)。
项目源代码参见 liang2kl/APIKit(
虽然还没写完)。
利用 Property Wrapper 包装参数,加上一些基本信息,就可以定义一个 HTTP 请求了。最重要的是,我们再也不需要手动设置 parameters 了。