我不太喜欢现在公司项目中 API Client 的实现方式,虽然并没有什么问题,但我还是忍不住要重构一下。原本的实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@implementation SomeModel

+ (void)doSomething:(NSString *)aParam completion:(void(^)(BOOL isSuccess, NSString *errorMessage, SomeModel * someModel))completion {
[APIClient.sharedClient
POST:someEndpoint
parameters: @{ @"p": aParam }
success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
PPApiResponseObject *responseItem = [PPApiResponseObject createFrom:responseObject];
if (responseItem.isSuccess) {
// do something
} else {
// do something
}
failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
// do something
}];
}

@end

这里有几个地方是我不喜欢的:1. 用 Objective C 写的;2. API 不太现代;3. endpoint 分散在了不同的 model 定义里面;4. 重复的东西太多。

构思

这次重构我不想有太大的改动,所以 [APIClient.sharedClient...] 那部分我是会保留的,同时我也需要支持 Objective C,于是用 enum 来表示 endpoint 这种事情肯定是做不到的了。为了能好好利用自动补全,我还是会采用方法来实现每一个 endpoint,只是这次我们会把它们都放在一个类里,就叫作 API 吧。

事实上这些方法的实现都大同小异,这种情况就特别适合使用元编程了,我希望我能够以 declarative 的方式定义一个 endpoint,然后再通过 Sourcery 来自动生成代码。我希望这个定义是这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class API: NSObject {
enum ProductSearch: APIEndPoint {
static let endpoint = "/app/search"

struct RequestBody {
let keyword: String
let page: Int
}

struct ResponseBody: ResponseBodyType {
let totalCount: Int
let products: [Product]; struct Product: ResponseBodyType {
let productId: Int
let productName: String
}
}
}
}

这就和我们的 API 文档一模一样,APIEndPoint 是一个 protocol,一方面可以指导我定义一个 endpoint,另一方面,可以告诉 Sourcery 我们都有哪一些 endpoint 需要生成代码。实现 ResponseBodyType 的类型需要同时实现 Codable,这样我们就能将 JSON 转化为 Model 了。这同时也能够告诉 Sourcery,有哪一些类型我们需要提供一个 Objective C 可以理解的版本,毕竟无论是嵌套类型还是 struct,Objective C 都是不认的。

我决定用一个 class 把它包住。

1
2
3
4
5
6
7
8
9
10
11
class API_ProductSearch_ResponseBody: NSObject {
let body: API.ProductSearch.ResponseBody
init(body: API.ProductSearch.ResponseBody) { self.body = body }

@objc var totalCount: Int {
return body.totalCount
}
@objc var product: [API_ProductSearch_ResponseBody_Product] {
return body.product.map(API_ProductSearch_ResponseBody_Product.init(body:))
}
}

实现部分,我希望和原本差不多,但 completion handler 的参数是一个 enum Result<Body>

1
2
3
4
5
6
7
8
9
10
11
12
13
static func productSearch(
keyword: String,
page: Int,
_ completion: @escaping (Result<API.ProductSearch.ResponseBody>)->Void = {_ in}) -> URLSessionDataTask? {
// prepare parameter dictionary
return APIClient.sharedClient().post(API.ProductSearch.endpoint,
parameters: param,
success:{ task, responseObject in
// do something
}, failure:{ task, error in
// do something
})
}

当然还需要一个 Objective C 的版本,自然也就不能用 Result 了:

1
2
3
4
@objc static func post_productSearch(
keyword: String,
page: Int,
_ completion: @escaping (API_ProductSearch_ResponseBody?, NSError?)->Void = {_, _ in}) -> URLSessionDataTask?

既然大致的方向决定了,接下来就是写模板了。

写模板

❖ 因为 Hexo 会尝试去解析 stencil 模板,所以文章中用 {٪ 来防止解析发生。

适配 Objective C

写这个部分的模板还是比较烦人的,因为 Objective C 并不支持 Swift 里面的所有类型。

首先是提供 Objective C 可以理解的 response body。就像之前所说的那样,我们只需要让一个 class 把每一个实现 ResponseBodyType 的类型包起来就行了:

1
2
3
4
5
6
7
8
for responseBody in types.all where responseBody.implements.ResponseBodyType%}
class {{responseBody.name|replace:".","_"}}: NSObject {
let body: {{responseBody.name}}
init(body: {{responseBody.name}}) { self.body = body }
for variable in responseBody.variables%}
// variables
{٪endfor%}
{٪endfor%}

Stencil 提供了一些内置的 filter,所以我们能够很简单地将一些嵌套类型的名称 A.B.C 转变成 A_B_C。Variable 部分需要比较注意的是,像 Int? 这种东西 Objective C 也是看不懂的,需要额外处理成 NSNumber,当 variable 是 ResponseBodyType[ResponseBodyType]ResponseBodyType? 之类的也需要额外处理,挺啰嗦的,这里就不一一写明了。Sourcery 中 Variable 有两个参数 typetypeName,说实话很诡异,必须得好好看文档才知道什么时候该用什么。

ResponseBodyType? 这种情况目前还没有很好的解决方法, typeName.unwrappedTypeName 返回的是 String 而不是 TypeName,正巧我把这些类型都嵌套在 API 里,可以判断其 actualTypeName 的前缀来捕获这种情况。

1
2
3
4
5
6
7
8
if variable.typeName.isOptional and variable.typeName.actualTypeName|hasPrefix:"API." %}
@objc var {{variable.name}}: {{variable.typeName.actualTypeName.name|replace:".","_"}} {
if let v = body.{{variable.name}} {
return {{variable.type.name|replace:".","_"}}(body: v)
}
return nil
}
{٪endif%}

请求方法

提供 Objective C 版本的请求方法很简单,就是内部调用 Swift 版本的方法。这部分需要注意的只有一些 optional 的参数类型可能要用别的类型替换,就一笔带过了。

有了上一个小节的经验,我们可以发现这些模板的写法都大同小异。对方法签名的参数生成和请求参数的生成,无非都是遍历 RequestBodyvariables 而已。唯一值得一提的是,我们可以用 {٪if var.variables.count == 0%} 来判断一个数组是否为空,还可以通过 {٪if forloop.last%} 来判断是否是当前循环的最后一次。很多用法其实都没有写在 Sourcery 的文档里面,不妨也去看看 Stencil 的文档。

Coding Keys

有时候我们希望不用去刻意地让属性的名称和 JSON 里的 key 完全对应上,因为有的 key 的命名真的是难以理解,这时我们也可以利用 Sourcery 帮助我们自动生成相关的代码。

和我之前的文章一样,我们用 AutoCodable 来指定这些需要生成代码的类型,但幸运的是,我们没有必要和 Enum with Associated Value 做斗争了。

仔细观察了一下,我大概有这么几个需求:

  1. 类型为数组时,默认值为 []
  2. 可以指定默认值 defaultValue = 0
  3. 可以指定 key jsonKey = "abc"
  4. 某一些情况将 Int 转为 Bool

对于数组默认值,我们只需要把对应情况的 decode 改成 decodeIfPresent ?? [] 就好了。

后三者则用到了 Sourcery 中的 annotation 功能,通过注释给 Sourcery 传递代码以外的信息。

1
2
3
4
5
// sourcery: jsonKey = "id"
// sourcery: defaultValue = 0
let productId: Int
// sourcery: boolFromInt
let worthBuying: Bool

模板中可以通过 annotated 这个 filter 来判断目标是否有相应的注释。也可以通过 something.annotation.jsonKey 这种方式来获取注释的值。

扩展

元编程的一个好处就是没有复杂的设计,这使得扩展变得很容易。比如说,我们可以给 endpoint 增加一个获取 mock response 的功能。我们通过判断 endpoint 是否实现某一个 protocol 来决定是否插入代码。

对于这个扩展,endpoint 需要实现 APIEndPointHasFakeResponse 这个 protocol:

1
2
3
protocol APIEndPointHasFakeResponse {
static func handleRequestWithMockResponse(_ param: [String: Any]?, completionHandler: (Result<Data>)->Void) -> Bool
}

在这个方法中,我们既可以直接返回一段 JSON,也可以将请求转发给 mock server 来获取一段 mock response。当然我们也可以只对特定的请求做处理,并在这种情况下返回 false

我们完全不用担心这些多余的扩展会污染我们的代码,毕竟这些代码只有在实现了协议的情况下才会插入到请求方法当中;我们也可以在 extension 中实现这个协议,以保持 endpoint 定义的纯洁性,当我们不需要 mock response 了,只需要随手删掉相关代码就可以了。

我们也可以只移除 protocol 而保留方法的实现,以方便下一次使用。

我们可以把这一部分写成一个 macro:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{٪macro extensionFakeResponse type requestBody%}
if type.implements.APIEndPointHasFakeResponse%}
#if DEBUG
if {{type.name}}.handleRequestWithMockResponse({٪call paramOrNil requestBody-%}, completionHandler: {
switch $0 {
case let .success(json):
let result = try! JSONDecoder().decode(APIResponse<{{type.name}}.ResponseBody>.self, from: json!)
completion(.success(result.body))
case let .failure(error): completion(.failure(error))
}
}) { return nil }
#endif
{٪endif%}
{٪endmacro%}

这样就能尽可能使请求方法模板看起来更干净一些。尽管生成的代码是挺丑的,不过谁要去管它们呢。

很遗憾 Sourcery 好像还并不支持 indent 这个 filter,所以当缩进有点深的时候,模板会变得很难看(尽管官方好像说是支持的)。