通过 Sourcery 来写 API Client
我不太喜欢现在公司项目中 API Client 的实现方式,虽然并没有什么问题,但我还是忍不住要重构一下。原本的实现是这样的:
1 | @implementation SomeModel |
这里有几个地方是我不喜欢的:1. 用 Objective C 写的;2. API 不太现代;3. endpoint 分散在了不同的 model 定义里面;4. 重复的东西太多。
构思
这次重构我不想有太大的改动,所以 [APIClient.sharedClient...]
那部分我是会保留的,同时我也需要支持 Objective C,于是用 enum 来表示 endpoint 这种事情肯定是做不到的了。为了能好好利用自动补全,我还是会采用方法来实现每一个 endpoint,只是这次我们会把它们都放在一个类里,就叫作 API
吧。
事实上这些方法的实现都大同小异,这种情况就特别适合使用元编程了,我希望我能够以 declarative 的方式定义一个 endpoint,然后再通过 Sourcery 来自动生成代码。我希望这个定义是这个样子的:
1 | class API: NSObject { |
这就和我们的 API 文档一模一样,APIEndPoint
是一个 protocol,一方面可以指导我定义一个 endpoint,另一方面,可以告诉 Sourcery 我们都有哪一些 endpoint 需要生成代码。实现 ResponseBodyType
的类型需要同时实现 Codable
,这样我们就能将 JSON 转化为 Model 了。这同时也能够告诉 Sourcery,有哪一些类型我们需要提供一个 Objective C 可以理解的版本,毕竟无论是嵌套类型还是 struct,Objective C 都是不认的。
我决定用一个 class 把它包住。
1 | class API_ProductSearch_ResponseBody: NSObject { |
实现部分,我希望和原本差不多,但 completion handler 的参数是一个 enum Result<Body>
:
1 | static func productSearch( |
当然还需要一个 Objective C 的版本,自然也就不能用 Result
了:
1 | static func post_productSearch( |
既然大致的方向决定了,接下来就是写模板了。
写模板
❖ 因为 Hexo 会尝试去解析 stencil 模板,所以文章中用 {٪ 来防止解析发生。
适配 Objective C
写这个部分的模板还是比较烦人的,因为 Objective C 并不支持 Swift 里面的所有类型。
首先是提供 Objective C 可以理解的 response body。就像之前所说的那样,我们只需要让一个 class 把每一个实现 ResponseBodyType
的类型包起来就行了:
1 | {٪for responseBody in types.all where responseBody.implements.ResponseBodyType%} |
Stencil 提供了一些内置的 filter,所以我们能够很简单地将一些嵌套类型的名称 A.B.C
转变成 A_B_C
。Variable 部分需要比较注意的是,像 Int?
这种东西 Objective C 也是看不懂的,需要额外处理成 NSNumber
,当 variable 是 ResponseBodyType
或 [ResponseBodyType]
或 ResponseBodyType?
之类的也需要额外处理,挺啰嗦的,这里就不一一写明了。Sourcery 中 Variable
有两个参数 type
和 typeName
,说实话很诡异,必须得好好看文档才知道什么时候该用什么。
对 ResponseBodyType?
这种情况目前还没有很好的解决方法, typeName.unwrappedTypeName
返回的是 String
而不是 TypeName
,正巧我把这些类型都嵌套在 API
里,可以判断其 actualTypeName
的前缀来捕获这种情况。
1 | {٪if variable.typeName.isOptional and variable.typeName.actualTypeName|hasPrefix:"API." %} |
请求方法
提供 Objective C 版本的请求方法很简单,就是内部调用 Swift 版本的方法。这部分需要注意的只有一些 optional 的参数类型可能要用别的类型替换,就一笔带过了。
有了上一个小节的经验,我们可以发现这些模板的写法都大同小异。对方法签名的参数生成和请求参数的生成,无非都是遍历 RequestBody
的 variables
而已。唯一值得一提的是,我们可以用 {٪if var.variables.count == 0%}
来判断一个数组是否为空,还可以通过 {٪if forloop.last%}
来判断是否是当前循环的最后一次。很多用法其实都没有写在 Sourcery 的文档里面,不妨也去看看 Stencil 的文档。
Coding Keys
有时候我们希望不用去刻意地让属性的名称和 JSON 里的 key 完全对应上,因为有的 key 的命名真的是难以理解,这时我们也可以利用 Sourcery 帮助我们自动生成相关的代码。
和我之前的文章一样,我们用 AutoCodable
来指定这些需要生成代码的类型,但幸运的是,我们没有必要和 Enum with Associated Value 做斗争了。
仔细观察了一下,我大概有这么几个需求:
- 类型为数组时,默认值为
[]
- 可以指定默认值
defaultValue = 0
- 可以指定 key
jsonKey = "abc"
- 某一些情况将
Int
转为Bool
对于数组默认值,我们只需要把对应情况的 decode
改成 decodeIfPresent ?? []
就好了。
后三者则用到了 Sourcery 中的 annotation 功能,通过注释给 Sourcery 传递代码以外的信息。
1 | // sourcery: jsonKey = "id" |
模板中可以通过 annotated
这个 filter 来判断目标是否有相应的注释。也可以通过 something.annotation.jsonKey
这种方式来获取注释的值。
扩展
元编程的一个好处就是没有复杂的设计,这使得扩展变得很容易。比如说,我们可以给 endpoint 增加一个获取 mock response 的功能。我们通过判断 endpoint 是否实现某一个 protocol 来决定是否插入代码。
对于这个扩展,endpoint 需要实现 APIEndPointHasFakeResponse
这个 protocol:
1 | protocol APIEndPointHasFakeResponse { |
在这个方法中,我们既可以直接返回一段 JSON,也可以将请求转发给 mock server 来获取一段 mock response。当然我们也可以只对特定的请求做处理,并在这种情况下返回 false
。
我们完全不用担心这些多余的扩展会污染我们的代码,毕竟这些代码只有在实现了协议的情况下才会插入到请求方法当中;我们也可以在 extension 中实现这个协议,以保持 endpoint 定义的纯洁性,当我们不需要 mock response 了,只需要随手删掉相关代码就可以了。
我们也可以只移除 protocol 而保留方法的实现,以方便下一次使用。
我们可以把这一部分写成一个 macro:
1 | {٪macro extensionFakeResponse type requestBody%} |
这样就能尽可能使请求方法模板看起来更干净一些。尽管生成的代码是挺丑的,不过谁要去管它们呢。
很遗憾 Sourcery 好像还并不支持 indent
这个 filter,所以当缩进有点深的时候,模板会变得很难看(尽管官方好像说是支持的)。