/ swiftui

超谈 Swift:利用 Macros 实现 Swift 版本的 Retrofit

2023 年 Swift 语言和 SwiftUI 都迎来了不少的更新。

如果说 SwiftUI 上的更新你大概率可能因为往下兼容性的问题用不上,那么 Swift 5.9 上迎来的 Macros 功能应该能吸引到你的注意:它允许你在编译时根据上下文来生成代码,帮助你减少写重复的相似度过高的代码。Swift Macros 仅仅是帮你生成代码,是一个 ABI 兼容的特性,配合 Xcode 15 即可(当然目前仅是 Beta 版本)并在老项目中使用。

如果你不了解 Swift Macros,那么还请先看一遍官方文档以及 WWDC23 的视频,虽然下文也会对一些内容做详细的介绍和解释(更多的是心得性质的输出,并非手把手的教程),但是还是先建议看一遍官方的内容,这样会对理解本文会有帮助。

Documentation

Expand on Swift macros - WWDC23 - Videos - Apple Developer

Write Swift macros - WWDC23 - Videos - Apple Developer

本文接下来会聊到两方面的内容:

  • 介绍 Swift Macros 的类型和 Role s(角色),通过 Roles 了解 Swift Macros 能帮助你做什么事情
  • 写 Swift Macros 的一些辅助工具
  • 介绍一个模仿 Retrofit 网络请求库实现

Swift Macros 能做什么

正如开头提到的,Swift Macros 能帮助你在编译时生成代码——至于生成怎么样的代码,就完全看你的个人意愿。但毕竟通过 Swift Macros 生成代码的门槛,要比写普通的代码高不少,因此从投资/收益的效果看,Swift Macros 适合用来生成相似、重复度高、需要知道源代码信息的、无法用泛型来解决的代码。

Swift Macros 的定义有两个类型:FreestandingAttached

  • Freestanding **可以 “凭空” 输出一段代码,它不依赖除了自身定义以外的其他代码;它的调用以 # 为开头。
  • Attached 必须附加到现有的代码定义里,因此在写 Swift Macros 的时候,能跟编译器通讯,拿到附加的代码定义范围内的源代码信息(AST 抽象语法树),从而帮助你生成你的代码;它的调用以 @ 为开头。

Freestanding

通过 Xcode 15 新建 Swift Macros 都会附带这么一个官方 Freestanding 的示例代码:

let x = 10
let y = 20
let result = #stringify(x + y)

它的 Macro 定义是:

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacroMacros", type: "StringifyMacro")

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\\(argument), \\(literal: argument.description))"
    }
}

在编译时,会对源代码的所有 Macros 进行 Expand(展开)操作,也就是会通过 Swift Macros Plugin 来执行对应 Macro 的生成,生成 Expansion 的代码后再对代码进行真正的编译操作。

上述代码在 Expand 后会是如下结果:

let result = (10 + 20, "x + y")

那么你可能会问,我自己手写 x + y,结果不也是一样吗?

如果你只需要 x + y 的结果,那么确实是没有必要用到 Macros。但注意下,返回的第二个结果,是 “x+y” 的字符串——里面的 x 和 y 是根据源代码里对应字符串变量的名字来生成的,如果你修改了 x 和 y 的变量名字,那么生成的字符串也会跟着改变。

Attached

正如名字所说的,Attached 类型的 Swift Macros 需要附加到一个地方。而这个地方可以是:Class、Protocol、方法、属性等声明。

根据生成的代码位置(或者叫作用域),Attached 类型的 Swift Macros 有以下几个 Roles:

  • Peer macros:它可以生成跟附加声明的相同作用域的代码;如果你把这个 Macro 附加到一个文件内的 Class 或者 Protocol 定义上,那么它就可以在同样的文件内生成代码;如果你把这个 Macro 附加到一个类里面的方法定义上,那么就可以生成类内的其他方法或者属性
  • Member macros:它可以生成附加声明内的成员方法或者属性;如果你把这个 Macro 附加到一个类生命,那么你可以给这个类生成它的属性和方法
  • Member attribute:它可以给附加的声明添加新的 attributes
  • Accessor macros:它可以给附加的属性生成它的 get set 访问器
  • Conformance macros:它可以给附加的 Class、Protocol 或者 Struct 添加 Conformance;如果你有一堆固定需要继承的类或者协议,那么可以通过写一个 Conformance macro,来让你新定义的类或者协议自动 Conform

不同 Role 的 Attached Macros 在写法上会有些区别,同时能访问的 AST 根据它本身附加的位置决定。

做了一个图来总结:

上图所示为:附加的 Macros 和它生成代码的位置,以同一个颜色来标注。

💡不过需要注意的是,即便是文件内的 Peer Macro,是不支持生成 Extension 的。此外,一个 Swift Macros 可以对应多个 Role,不同的 Role 在实现上对应不同的 Protocol,实现的时候实现需要的协议即可。

以上为 Swift Macros 两种类别和不同 Roles 的介绍。具体怎么写一个 Swift Macro,这里不详细说,请参考官方文档和视频。

写 Swift Macros 的一些辅助工具

如果你像我一样基本在做应用层的工作,那么一开始对 Swift Macros 写起来可能会比较费劲——对 Swift 的 AST 不够了解的话,写起来确实会比较沮丧。

Swift Macros 生成代码,就是把一个 AST 转换为另一个 AST。输出 AST 其实不太困难,SwiftSyntaxBuilder 库有很多方便我们使用的方法来输出(比如通过字符串和拼接的方式来构造 DeclSyntax)。但是如果要访问 Macros 本身和附加的源代码信息,那么还是需要对源代码的 AST 有了解才比较好进行。

有几个方法可以帮助我们查看源代码的 AST。

首先是,通过运行时 Debug 的断点,来输出当前的 AST。需要注意的是,要让写 Macros 的代码里断点生效,你必须运行 Test 单元测试才行——即便是在 app 内本地引用一个 Swift Macros Package,编译该 app 的时候,Macros 内的断点不会有作用。

在断点后,你可以在控制台通过 po 命令输出某个 Syntax 的 AST:

接下来,便是对 AST 的访问而已了。这是官方 WWDC23 的视频里也提到的方法。

另外,你可以通过一个也被 Swift Syntax 官方推荐的 Swift AST Explorer 来查看一段代码的 AST:

https://swift-ast-explorer.com/

不过目前使用的版本为 Swift 5.8,所以在这里你看到的 AST 跟上述使用 Xcode 断点打印的 AST 可能会有区别。实际使用下来,对输出代码帮助不是太大,但如果你本身对 Swift 的 AST 完全没有了解的话,不妨可以试试这个工具,把自己的一些代码放进去看看。

模仿 Retrofit 的网络库实现

学习新东西,除了要学习理论知识外,实践也是很重要的。

在 Android 的世界里,有一个很出名的 Square 出品的网络请求库叫 Retrofit,它通过 Java/Kotlin 的 AnnotationProcessor 来生成代码。你只需要定义一个接口,并在方法上添加注解,来描述一个请求的路径以及参数等信息:

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user);
}

然后 Retrofit 在编译时就会生成这个接口的对应实现,我们之后可以通过 Retrofit 的方式拿到实现了接口的类实例,然后就可以调用对应的方法来进行网络请求了。

如果使用 Swift Macros 来实现类似的效果,可行么?答案是可以的。

先来看看实现后的,使用的时候是什么效果。以下代码都已经经过验证,同时我作为一个库开源出来了(当然,作为一个比较初期的项目,我只实现了一小部分功能):

https://github.com/JuniperPhoton/Photonfire

首先我们需要定义这么一个 Protocol:

@PhotonfireService
protocol AccountService: PhotonfireServiceProtocol {
    @PhotonfireGet(path: "/account")
    func getAccount(id: String, name: String) async throws -> Account

    @PhotonfireGet(path: "/account")
    func getAccount(@PhotonfireQuery(name: "is_activated") activated: Bool) async throws -> Account
}

使用方式:

let url: URL = ...
let client = PhotonfireClient.Builder(baseURL: url).build()
let service = client.createService(of: PhotonfireAccountService.self)
let account = try? await service.getAccount(activated: true)

需要我们构造 client,并使用 createService 方法来构造生成的 PhotonfireOpenAIKeyService 这个类实现。

💡createService 这里需要具体地传入 PhotonfireOpenAIKeyService 这个生成类的 Type,返回的结果也是 PhotonfireOpenAIKeyService 类型。理论上可以给 PhotonfireClient 生成扩展方法来创建实例,但是 Macros 并不支持生成 extension。可能可以引入 @objc 来利用反射来实现,但这块不是太熟悉,所以还没实现,如果有什么更好思路的欢迎留言讨论。

进一步剖析以下上述的代码,实际上关于 Macros 做了几个事情:

  • @PhotonfireService 这个 Peer Macro 附加到 Protocol 的定义,以在同一个文件里生成对应的实现类,这个实现类的名字沿用 Protocol 的名字,然后在前面加上前缀以做区分
  • 在方法上,需要添加 @PhotonfireGet 这个 Member Macro(这里其实仅作为标记用,用于 @PhotonfireService 内识别),里面定义一些参数(比如如代码里的 path);方法的参数表示 Query 里要添加的参数,如果 Query 名字跟参数名不一样,那么可以用 @PhotonfireQuery 来修改(同样,这里只起到标记作用);生成的类里需要实现这个方法,解析 @PhotonfireGet@PhotonfireQuery 里的信息,然后调用 URLSession 相关的代码来发起请求

对照生成的代码,会是这样子的关系:

整体的 Macros 的代码不算很多,但如果全贴出来逐行分析也没什么意义。以下就几个点来进行解释:

  • 如何生成实现类 PhotonAccountService 的类定义,包括类名的提取
  • 如何从 getAccount 方法提取到 GET 方法以及路径参数
  • 如何组装类成员和类定义,输出整个类

生成实现类 PhotonAccountService 的类定义

先来看看 @PhotonfireService 的 Macro 定义:

@attached(peer, names: arbitrary)
public macro PhotonfireService() = #externalMacro(module: "PhotonfireMacros",
                                                  type: "PhotonfireServiceMacro")

此 Macro 是一个 Peer Macro,同时它会标记在 Protocol 上,因此它能在该 Protocol 所在的文件里定义类实现。

我们要实现的 PeerMacro 的这个方法:

public static func expansion(
        of node: SwiftSyntax.AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [SwiftSyntax.DeclSyntax] {

其中:我们通过 declaration 来获取所附加的声明的语法节点;通过 node 来获取此 Attribute(Macro 就属于 Swift Attribute 的一种,其他的还有比如 Property Wrapper) 语法节点;通过 context 来获取一些编译时的上下文信息,比如源代码的行数、文件名等。

我们首先需要检查它 Attach 的定义是否是一个 Protocol 定义。

// This macro must be attached to a protocol declaration
guard let protocolSyntax = declaration.as(ProtocolDeclSyntax.self) else {
    context.addDiagnostics(from: PhotonfireMacrosError.notAProtocol, node: node)
    return []
}

SyntaxProtocol 协议内定义的 func as<S: SyntaxProtocol>(_ syntaxType: S.Type) 方法可以方便把一个 Syntax “转换”为一个具体的类型(这里 Swift 本身的 as 是不可以的,因为这里不是真正地转换,而是构造一个对应的 SyntaxProtocol 的实例,在这里是 ProtocolDeclSyntax 实例)。

💡关于 context.addDiagnostics ,这个是为了让编译器能给出正确的诊断信息,具体这里不介绍了,请参考官方文档。

接着我们要找到 Protocol AccountService 这个 名字的字符串,通过打印这个 protocolSyntax 的 AST 信息:

可以看到,”AccountService” 正是这个 ProtocolDeclSyntax 的 identifier 属性,因此获取这个 Protocol 名字就简单了:

// The protocol name
let name = protocolSyntax.identifier.text

定义这个类的声明如下:

let classDecl = try ClassDeclSyntax("class \(raw: "Photonfire\(name)"): \(raw: name)") {
    // class members decls...
}

SwiftSyntaxBuilder 给我们提供了跟 SwiftUI 相似的描述式的声明方式,我们通过构造 ClassDeclSyntax 这个实例并传入类头来定义这个类的声明。这里的 name 我们通过 Interpolation 的方式来插入刚刚提取的名字 AccountService 到类的声明里。

如何从 getAccount 方法提取到 GET 方法以及路径参数

再看一下 getAccount 这个方法在 Protocol 里的定义:

@PhotonfireGet(path: "/account")
func getAccount(@PhotonfireQuery(name: "is_activated") activated: Bool) async throws -> Account

要生成对应的实现,我们需要:

  • 首先从上述的 protocolSyntax 里拿到这个方法的 Syntax
  • 识别到这个方法的 @PhotonfireGet Attribute,获取里面名叫 path 的参数
  • 识别到这个方法的 activated 参数,并获取到它的 @PhotonfireQuery Attribute
  • 之后把上述获取到的信息,填入使用 URLSession 请求的代码里并返回

要从上述的 protocolSyntax 里拿到这个方法的 Syntax。直接看这个成员方法的 AST 定义:

正如上文提到的,在看到这个 AST 的样子后,你需要的,就是不断查找子叶,通过类型以及 identifier 来判断是否是我们关心的 Syntax,然后提取出来即可。这块提取的复杂度,取决于你的这个 Macro 的复杂度,这里我不贴代码出来了。

有兴趣请查看这里源码:

https://github.com/JuniperPhoton/Photonfire/blob/main/Sources/PhotonfireMacros/PhotonfireMacro.swift#L65

如何组装类成员并输出整个类定义

前面提到,SwiftSyntaxBuilder 提供了类似 SwiftUI 生命式的构造来帮助我们构造类定义,在生成完方法定义后,最后通过这样的方式来组合起来:

let classDecl = try ClassDeclSyntax("class \(raw: "Photonfire\(name)"): \(raw: name)") {
    try FunctionDeclSyntax("static func createInstance(client: PhotonfireClient) -> \(raw: "Photonfire\(name)")") {
        CodeBlockItemSyntax(item: .decl("return \(raw: "Photonfire\(name)")(client: client)"))
    }
    
    DeclSyntax("private let client: PhotonfireClient")
    
    try InitializerDeclSyntax("private init(client: PhotonfireClient)") {
        CodeBlockItemListSyntax([
            .init(item: .decl("self.client = client"))
        ])
    }
    
    for function in functions  {
        MemberDeclListItemSyntax(decl: function)
    }
    
    generateSetHeaderDecl()
}

return [DeclSyntax(classDecl)]

这种构造方式的好处是,你可以在 block 里面使用 for-loop 来迭代输出。如上述的例子,functions 是 [DeclSyntax] 类型的,而类的成员方法的语法节点是 MemberDeclListItemSyntax,我们只需要迭代 functions 并构造 MemberDeclListItemSyntax 即可。

除了这种构造方式外,对于简单的不过多其他语法节点的声明代码,你可以直接使用字符串来定义:

private static func generateSetHeadersDecl() -> DeclSyntax {
    return """
    private func setHeaders(request: inout URLRequest, httpMethod: String, headers: [String: String]) {
        headers.forEach { (k, v) in
            request.setValue(v, forHTTPHeaderField: k)
        }
        request.httpMethod = httpMethod
    }
    """
}

这里等同于使用 DeclSyntax(stringLiteral: String)  来构造 DeclSyntax

如果你觉得组装声明的代码比较难写,那么可以尝试以下的方法:

  • 先在编辑器正常地写经过 Macros Expansion 后的代码
  • 复制这些代码到 Macro 的定义里,寻找要生成的代码里,哪些信息是需要提取的;把需要提取信息的方法,单独抽出来定义
  • 同时,新建一个 Swift Macro 项目,把经过 Expansion 后的代码放进去,同时随便添加一个 Attach Macro,配合 Xcode Test 的断点功能,po 出 AST 的根节点,然后对照这个 AST 来写 Macro 的代码

目前 Photonfire 的实现还是很初期,但 GET 方法经过测试是完全能用了。接下来也会视自己的精力和时间逐步实现。

再次附上项目 Github 地址:

https://github.com/JuniperPhoton/Photonfire

以上便是本文的所有内容了。再强调一下,建议先看官方文档以及 WWDC23 的内容再来看本文,可能会更有帮助。Swift Macro 作为 Swift 5.9 的一个重要功能,在今年的 SwiftUI Observation 和 SwiftData 里都有用上,算是官方自己扶持的「一等公民」了。另外对于使用 Swift Macros 的库,你可以通过 Expand 的功能来看到它在 Expansion 后的实现,顺便了解一下它的部分实现方法,比如下图为 SwiftData @Model 的实现为:

最后,希望本文能帮助到你,谢谢阅读。