偶然看到 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 不允许存在未初始化的成员变量,所以必须同时初始化 keywrappedValue

@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 的构造函数中无论是否赋值,都只会调用一次 @Ainit。当我们不指定 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 中有 pagefoo 两个成员。

幸好,我们有 Mirror ——利用对象的元数据,能够向我们提供对象成员变量的信息。1

Mirror 尝试一下:

let mirror = Mirror(reflecting: request) // request is `SomeRequest`
for (_, property) in mirror.children {
    // property 是 request 的成员变量
}

我们可以通过这种方法访问 request 对象的所有成员变量。接下来的问题是,我们怎么识别是不是 @Query,以及怎么把 Query 里面的 keywrappedValue 取出来。

一种很朴素,又很错误的想法是这样:

if let property = property as? Query {
    ...
}

这样当然通过不了编译,Query 只是个模版,不是具体的类。我们需要一种与 Query 模版参数无关的方法。

我们喜欢的 protocol 这时可以发挥作用了。我们不需要获取“property 具体的类型”这么强的信息,只需要这个 property 能够向我们提供我们需要的 URL 编码的 valuekey 就行了。马上可以写一个协议:

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 参数了,将所有的 keyvalue 加到请求的 URL 即可。

实际上的处理要稍微复杂一点,因为 Value 可能是一个数组,这时候就不仅仅是一个 key 和 value 对了。

后续

根据相似的思路,我们可以为 Header、body 参数等定义类似的 Property Wrapper:

Property WrapperDescriptionWrapped Value
@HeaderHTTP HeaderString?
@HeaderDictAn dictionary of headers[String : String]?
@QueryQuery parametersEncodable?
@QueryDictAn dictionary of query parameters[String : String]?
@KeyQueryQuery parameters with no value associatedBool
@JSONBody parameters with JSON encodingEncodable?
@FieldForm URL Encoded parametersEncodable?

我们可以在一个类声明中定义所有的请求参数了。最后的实现效果如下:

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 了。