将 ARouter 的思想带入 Swift:用声明式语法构建类型安全的 API 请求
最近读到喵神的《编译器,靠你了!使用类型改善状态设计》,深受启发。这篇文章让我意识到,我在设计 RaAPIWrapper 时,为了解决一些工程问题而采用的方案,在不经意间与“类型即状态”的设计思想不谋而合。
我之前还没有正式介绍过这个库,所以借此机会,正式与大家分享 RaAPIWrapper 的设计,特别是它为了解决特定问题而采用的架构,以及这些决策在事后看来,是如何“碰巧”实现了更先进的设计模式。
RaAPIWrapper 的诞生
RaAPIWrapper 的设计初衷,源于对现有网络库 Moya 的反思,以及对 Android 路由框架 ARouter 设计模式的借鉴。
为何不直接用 Moya?
在 Swift 社区,Moya 是一个非常优秀的类型安全网络库,它通过 enum
和 protocol
的组合,解决了大量 API 管理中的问题。然而,在深度使用后,我们发现 Moya 的设计在某些方面存在优化空间。
Moya 的核心是 TargetType
协议,一个 API 的完整定义需要你在 enum
的 switch
中,为 path
, method
, task
等多个属性分别提供实现。这导致了一个问题:
一个 API 的核心信息(如请求方法、路径、参数)被分散在了多个代码块中。
当项目 API 数量增多时,开发者需要在一个庞大的 switch
语句中不断上下文切换和上下跳转,才能拼凑出一个 API 的完整样貌。这降低了代码的可读性和维护性,也是 RaAPIWrapper 希望解决的核心痛点。
不过需要强调的是,RaAPIWrapper 的目的并非全盘否定 Moya,当前也不具备替代 Moya 的能力。Moya 依然是一个功能强大的网络执行层,尤其在插件化、请求执行等领域。RaAPIWrapper 的设计初衷是专注于解决 API 定义的内聚性问题,它被设计为一个上层的“API 定义层”,可以与像 Moya 这样的成熟网络库协同工作,形成优势互补。
ARouter 是什么?
ARouter 是 Android 生态中一个著名的路由框架,由阿里巴巴开发并开源。该框架通过注解 (@Route
) 的方式,让开发者能够以一种极其声明式和解耦的方式定义页面路径。在 README 中有如下的示例:
@Route(path = "/test/activity")
public class Test1Activity extends Activity { ... }
我非常欣赏这种将“路由”从具体的实现中抽离出来,变成一个集中、静态、易于管理的设计。
于是,一个想法在我脑中萌生:我能否将这种优雅的、基于注解的声明式语法,从“页面路由”领域迁移到“API 定义”领域?
这个想法,成为了 RaAPIWrapper 的起点。我的目标是创建一个网络层,它应该具备以下特质:
-
API 声明化:API 的定义应该像 ARouter 的路由一样,通过类似注解的语法附加到一个静态属性上,而不是散落在各个业务代码中去手动拼接 URL。
-
集中化管理:所有单个 API 定义所需要的内容都应该被清晰地组织在一起,不会散落在多个地方,查找起来要非常方便。
-
定义与实现解耦:API 的“定义”应该与其“如何被请求”完全分离。定义部分只关心 API 的元数据(路径、方法、参数结构),而底层的网络请求库(是 Alamofire 还是 URLSession)可以随时被替换,而不影响定义。
像定义路由一样定义 API
为了实现上述目标,我将 RaAPIWrapper 的核心架构设计为一个声明式的 API “地址簿”:
项目带有一个用于演示的 Playground,您可以通过该项目进一步了解本框架。
@GET("/api/v1/no_param")
static var noParamAPI: APIParameterBuilder<()>? = nil
@POST("/api/v1/tuple_param")
static var tupleParamAPI: APIParameterBuilder<(id: Int, name: String?)>? = .init {
// `Dictionary` and `Array` can be used directly as parameters.
["id": $0.id, "name": $0.name]
}
@POST("/post")
static var postWithModel: APIParameterBuilder<Arg>? = .init {
// When the parameter `Arg` complies with the `APIParameter` (`Encodable & Hashable`) protocol,
// it can be used directly as a parameter.
$0
}
就像 ARouter 的 @Route("/path/to/page")
一样,最核心的信息应该在声明的那一刻就一目了然。这种方式的优势是显而易见的:
- 高度可读:你可以像 Moya 一样将多个接口定义在一个文件中,任何人打开这个文件,就能立刻了解模块中有哪些可用的 API。
- 高内聚性:您不用再通过多次跳转,才能获取到一个接口的全部信息,所有信息都定义在一起。
- 易于维护:修改、添加或废弃一个 API,都只需要在这个“地址簿”中进行,而不需要深入到业务逻辑中去寻找那些零散的 URL 字符串。
- 天然解耦:
@GET
和@POST
的定义,只包含了 API 的元信息。它本身并不关心网络请求是如何发出的。底层的实现被 RaAPIWrapper 的执行层所封装,这为后续替换网络库或增加统一的请求处理逻辑(如加密、加签)提供了极大的便利。
核心设计:一个声明式的 API 定义
在确立了“声明式、高内聚低耦合”这些核心设计理念之后,我需要寻找一些足够强大的技术手段来支撑它。@propertyWrapper
属性包装器与 Swift 的泛型系统正是实现这些理念的最佳方案。
最初我使用泛型,只是想找到一种方法来避免
switch
语句和增强代码的扩展性,但最终的成果却意外地与“类型即状态”思想相契合。这对我来说,也是一个在实践中学习和发现的过程。
@propertyWrapper
属性包装器
@GET
、@POST
是基于一个名为 API
的通用属性包装器(@propertyWrapper
)构建的。
我们以 @POST
为例,它的定义非常简单,只是一个类型别名(typealias
):
// file: Sources/Core/Wrapper/HTTPMethod/POST.swift
public enum PostHTTPMethod: APIHTTPMethodWrapper {
public static var httpMethod: APIHTTPMethod { "POST" }
}
/// Encapsulates the data needed to request the `POST` api.
public typealias POST<Parameter> = API<Parameter, PostHTTPMethod>
真正的核心在于 API
这个类。它是一个泛型属性包装器,接受两个类型参数:Parameter
代表 API 的参数类型,HTTPMethod
则是一个遵循 APIHTTPMethodWrapper
协议的类型,用于提供具体的 HTTP 请求方法(如 "POST")。
// file: Sources/Core/Wrapper/API.swift
@propertyWrapper
public class API<Parameter, HTTPMethod: APIHTTPMethodWrapper> {
public var wrappedValue: APIParameterBuilder<Parameter>?
public let path: String
// ... other properties
public init(
wrappedValue: APIParameterBuilder<Parameter>?,
_ path: String,
// ... other parameters
) {
self.wrappedValue = wrappedValue
self.path = path
// ...
}
public var projectedValue: API<Parameter, HTTPMethod> { self }
}
@propertyWrapper
的设计要点在于:
- 初始化时捕获信息:当我们写下
@POST("/post")
时,Swift 编译器会调用API
的init(wrappedValue:_:)
方法。路径"/post"
作为参数被传入,并保存在path
属性中。wrappedValue
则是我们赋给静态变量的APIParameterBuilder
实例,它包含了参数的构建逻辑。 - 分离“定义”与“执行”:
API
包装器通过projectedValue
暴露了自身。这意味着,当我们使用$
符号(如BasicAPI.$postWithModel
)时,我们获取到的是API
这个包装器对象本身。这个对象已经包含了构建一个完整请求所需的所有信息:路径、HTTP 方法、以及参数构建器。这就实现了 API “定义”与“执行”的分离——定义时只关心元数据,执行时通过$
获取到一个可执行的请求对象。
通过这种方式,@propertyWrapper
不仅提供了声明式的语法,更在底层完成了信息的聚合与封装,是实现 API 高内聚设计的关键。
“类型驱动”思想
如果说 @propertyWrapper
提供了骨架,那么泛型系统则为这个骨架提供了核心的类型安全保障。如今回顾,我才意识到这个为解决扩展性问题而选择的方案,其本质恰好就是“类型即状态”思想的体现,并且这一思想同时运用在了 API
的两个泛型参数 HTTPMethod
和 Parameter
之上。
我们再次审视 API
的定义:API<Parameter, HTTPMethod: APIHTTPMethodWrapper>
。
首先,HTTPMethod
参数本身并不是一个字符串值(如 "POST"
),而是一个类型。例如 PostHTTPMethod
这个类型,它的唯一作用就是通过遵循 APIHTTPMethodWrapper
协议来携带 "POST"
这个字符串。通过 typealias POST<P> = API<P, PostHTTPMethod>
,我们将 POST
这个别名与 PostHTTPMethod
这个“状态类型”永久绑定。这样,请求方法就在类型层面被固定下来,杜绝了传入错误字符串的可能性。此外这种写法还有很多好处:
- 利于扩展,可以很简单的定义多个 HTTP Method。
- 通过
extension
+where PostHTTPMethod = ...
,可以针对不同的 HTTP Method 定义不同的请求方法。比如业务层面规定GET
请求不允许携带参数,那么就可以定义不带参数的request()
方法。 - 在业务层需要获取到 HTTP Method 时,可以避免
switch case
。
其次,Parameter
参数以一种更动态的方式应用了同样的思想。观察 API 的定义:
// API with no parameters
@GET("/api/v1/no_param")
static var noParamAPI: APIParameterBuilder<()>? = nil
// API with parameters
@POST("/api/v1/tuple_param")
static var tupleParamAPI: APIParameterBuilder<(id: Int, name: String?)>? = .init { ... }
我们并没有在 @GET
或 @POST
中显式指定 Parameter
的类型。这里的机制是 Swift 的类型推断。
- 当我们声明
static var noParamAPI: APIParameterBuilder<()>?
时,Swift 编译器会分析变量的类型。它看到APIParameterBuilder<()>
,便会推断出API
包装器的Parameter
泛型参数就是()
(即Void
)。 - 同理,当声明
tupleParamAPI
的类型为APIParameterBuilder<(id: Int, name: String?)>?
时,编译器就确切地知道,这个 API 的参数类型是一个元组(id: Int, name: String?)
。
这种设计带来了显著的优势:
- API 合约化:API 的参数类型(它的“状态”)被固化在了定义中,成为一个不可改变的“合约”。
- 编译期安全:当你调用 API 时,编译器会强制执行这份合约。
- 对于
$noParamAPI
,调用request()
方法时如果试图传入参数,编译器会直接报错。 - 对于
$tupleParamAPI
,调用request(with:)
方法时,你必须传入一个(id: Int, name: String?)
类型的元组,参数名、类型、数量、可选性稍有差池,都无法通过编译。
- 对于
这与传统 Moya 中使用 [String: Any]
作为参数的方式形成了鲜明对比。字典是类型不安全的,开发者很容易在运行时因为拼错 key、传错 value 类型而导致请求失败。而 RaAPIWrapper 将这种潜在的运行时错误提前到了编译期,这正是该设计的关键优势之一。
最后,APIParameterBuilder<Input>
扮演了类型转换的角色。它接收强类型的 Input
(即 Parameter
),并通过一个闭包,将其转换为网络层所需的、经过类型擦除的 any APIParameter
格式。这使得 API 的定义可以保持类型纯净,而将参数构建的细节封装在了构建器内部。
回顾与展望
设计的得与失
通过组合运用 @propertyWrapper
的声明式语法和以泛型为核心的类型系统,RaAPIWrapper 成功地将 API 定义从分散、易错的字符串和字典,转变成了集中的、类型安全的 API “地址簿”。这个以提升代码内聚性和可维护性为目标的尝试,最终收获了编译期安全这一关键优势。
不过,正如一些批判性声音指出的,任何一个设计良好的 Swift 强类型 API,都在某种程度上利用了“类型即状态”。RaAPIWrapper 的创新更多在于其语法和模式,而非思想本身。并且这种“事后诸葛亮”的回顾也让我意识到在技术方面我还差的很远,还有很长的路要走。
清晰的定位:API 定义层
更重要的是,必须明确 RaAPIWrapper 的定位是一个 API 定义的包装层(Wrapper),而非一个大而全的网络请求框架。
这一定位意味着它刻意地保持了职责的单一。它只做一件事:以类型安全和高内聚的方式,构建出一个完整的请求元数据。至于这个元数据如何被执行、如何处理回调、如何实现插件、如何管理通用请求头等问题,则完全交由其背后的执行层来处理。
这种关注点分离的设计,恰好回答了“为何不直接用 Moya”的问题:
- 您可以继续使用 Moya:Moya 就是一个非常出色的执行层。开发者完全可以将 RaAPIWrapper 作为 API “合约”层,将生成的请求数据传递给 Moya Provider,继续享受 Moya 强大的插件系统和请求管理能力。
- 拥抱变化:如果未来团队希望从 Moya 迁移到
URLSession
,或其它新的网络库,您只需要更换执行层的实现,而所有用 RaAPIWrapper 定义的 API 代码都无需改动。
未来规划
目前,RaAPIWrapper 尚不自带执行层,也未集成插件系统。未来的规划是提供一个基于 Alamofire 的官方默认执行层,并逐步完善其功能,使其成为一个更加开箱即用的网络解决方案。