编写 Swift API 客户端库(二)
学习如何利用泛型、Swift Concurrency 以及 Codable 协议,优雅高效地编写 Web API 客户端。
在上一篇文章中,我们通过定义 HTTPClient 和 APIClient 为 Swift API 客户端库奠定了基础。现在,我们将深入探讨如何利用这些组件来构建轻量、职责专一的服务层。
我们引入一个假想的 UserService 来演示服务层设计的最佳实践,然后讨论如何编写测试。
UserService 设计
Endpoint 与 APIRequest 类型
在我们的架构中,每个 API Endpoint 都对应一个遵循 APIRequest 协议的特定结构体。这种设计模式将请求的定义与其执行分离开来。
这些请求结构体是轻量且声明式的。它们不执行网络调用,仅保存发起调用所需的数据(path、method、参数)。这种设计使得我们能够将身份验证、URL 构建和 JSON 解码等繁重工作交由共享的 APIClient 处理。
至关重要的是,每个请求都通过 associatedtype 定义了自己的 Response 类型。这告诉编译器当请求完成时应期望什么样的对象,从而提供端到端的类型安全。
struct GetUserRequest: APIRequest {
// This request will always return a 'User' object
typealias Response = User
let id: String
var path: String { "/v1/users/\(id)" }
var method: HTTPMethod { .get }
// We can use default implementations for these,
// but here we show them for clarity.
var headers: [String: String]? { nil }
var body: Data? { nil }
var queryItems: [URLQueryItem]? { nil }
}
struct ListUsersRequest: APIRequest {
// This request returns a paginated list of users
typealias Response = Page<User>
let page: Int
let perPage: Int
let query: String?
var path: String { "/v1/users" }
var method: HTTPMethod { .get }
var headers: [String: String]? { nil }
var body: Data? { nil }
var queryItems: [URLQueryItem]? {
var items: [URLQueryItem] = [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "per_page", value: String(perPage))
]
if let q = query, !q.isEmpty {
items.append(URLQueryItem(name: "q", value: q))
}
return items
}
}
提示:
APIRequest协议的定义见上一篇文章。
通过将请求的细节隔离在这些结构体中,我们可以十分简洁地实现服务层,稍后我们将看到这一点。
模型与 CodingKeys
接下来,我们定义与 API 响应匹配的数据模型。我们使用 Swift 的 Codable 协议来自动处理 JSON 的编解码。
Swift 遵循 camelCase,而许多 Web API 使用 snake_case。我们使用 CodingKeys 来解决这一差异。这允许我们保持 Swift 代码的惯用风格(例如 avatarURL),同时正确映射到 API 的 JSON Key(例如 avatar_url)。
提示:Foundation 的
JSONDecoder十分强大,用户可以通过keyDecodingStrategy自动将snake_case转换为camelCase。虽然我们可以使用自定义JSONDecoder在全局范围内处理此问题,但这超出了本文的讨论范围。我们在此处采用显式的CodingKeys。
我们还让模型遵循 Sendable,这对于 Swift 6 中的严格并发安全至关重要。
public struct User: Codable, Sendable, Identifiable {
public let id: String
public let name: String
public let email: String
public let avatarURL: URL?
public let createdAt: Int
public let updatedAt: Int
enum CodingKeys: String, CodingKey {
case id, name, email
// Map JSON keys to Swift property names
case avatarURL = "avatar_url"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
public struct Page<T: Codable & Sendable>: Codable, Sendable {
public let page: Int
public let results: [T]
public let totalPages: Int
public let totalResults: Int
enum CodingKeys: String, CodingKey {
case page, results
case totalPages = "total_pages"
case totalResults = "total_results"
}
}
分页在 Web API 中广泛使用,因此我们创建了一个使用泛型的 Page<T> 模型来表示分页响应。你可以将其与任何 Codable 模型一起使用,例如本例中的 User。
UserService 实现
现在我们继续实现 UserService 本身。我们并没有定义 UserServiceProtocol。在许多 Swift 项目中,开发者会本能地为每个服务创建协议以支持 Mock。然而,由于我们已经有了一个基于协议的 APIClient(来自第一部分),我们可以 mock 网络层而不是服务层。这使我们免于为每个服务维护一个协议,同时仍然保证可测试性。
public final class UserService: Sendable {
private let apiClient: any APIClient
// Internal init: Users should access this via the main Client
init(apiClient: some APIClient) {
self.apiClient = apiClient
}
public func getUser(id: String) async throws -> User {
let request = GetUserRequest(id: id)
return try await apiClient.perform(request)
}
public func listUsers(
page: Int = 1,
perPage: Int = 20,
query: String? = nil
) async throws -> Page<User> {
let request = ListUsersRequest(page: page, perPage: perPage, query: query)
return try await apiClient.perform(request)
}
}
客户端入口
为了使我们的库易于使用,我们提供一个单一的服务入口:MyServiceClient。这个类管理所有 API 相关配置,并持有我们所有服务的实例。这种模式让 API 更加易于探索(便于代码提示)——用户只需输入 client. 即可查看所有可用服务。
public final class MyServiceClient: Sendable {
public let configuration: APIConfiguration
private let apiClient: any APIClient
// Services
public let users: UserService
// 1. Convenience init for simple usage
public convenience init(baseURL: URL, apiKey: String) {
let config = APIConfiguration(baseURL: baseURL, apiKey: apiKey)
self.init(configuration: config)
}
// 2. Init that builds the default stack
public convenience init(configuration: APIConfiguration) {
// Create a URLSession configuration that respects our API settings
let sessionConfig = URLSessionConfiguration.default
sessionConfig.timeoutIntervalForRequest = configuration.timeoutInterval
sessionConfig.timeoutIntervalForResource = configuration.timeoutInterval
let session = URLSession(configuration: sessionConfig)
let httpClient = URLSessionHTTPClient(session: session)
let client = TokenAuthAPIClient(httpClient: httpClient, configuration: configuration)
self.init(apiClient: client, configuration: configuration)
}
// 3. Designated init for dependency injection
// This allows us to inject a MockAPIClient or a custom implementation
public init(apiClient: some APIClient, configuration: APIConfiguration) {
self.apiClient = apiClient
self.configuration = configuration
// Initialize services
self.users = UserService(apiClient: apiClient)
// And any future services go here...
}
}
测试服务
测试对于 API 客户端至关重要。我们要确保在不访问真实 API 的情况下,请求能被正确构建并且响应能被正确解析。我们可以且应该编写两种类型的测试:单元测试和集成测试。
单元测试
Mock APIClient
由于我们的服务依赖于 APIClient,我们可以注入一个 Mock 对象来模拟请求并返回预定义的响应。
final class MockAPIClient: APIClient, @unchecked Sendable {
private var mockResponse: Any?
private(set) var lastPath: String?
private(set) var lastQueryItems: [URLQueryItem]?
private var mockError: Error?
func perform<Request: APIRequest>(_ request: Request) async throws -> Request.Response {
lastPath = request.path
lastQueryItems = request.queryItems
if let error = mockError {
throw error
}
guard let response = mockResponse as? Request.Response else {
throw URLError(.badServerResponse)
}
return response
}
func setMockResponse<Response>(_ response: Response) {
self.mockResponse = response
}
func setMockError(_ error: Error) {
self.mockError = error
}
}
编写测试
为了保持测试的条理清晰,我们应该为每个服务创建一个 Suite。由于 Swift Testing 默认会并行运行测试,我们应该在每个测试用例中创建独立的 MockAPIClient 实例,以确保测试之间相互隔离。
接下来我们使用 MockAPIClient 验证 UserService 是否访问了正确的 API Endpoint 并返回了预期的模型。
@Suite("UserService Tests")
struct UserServiceTests {
@Test("List users sends correct parameters")
func listUsers() async throws {
let mock = MockAPIClient()
let service = UserService(apiClient: mock)
mock.setMockResponse(Page<User>(page: 1, results: [], totalPages: 1, totalResults: 0))
_ = try await service.listUsers(page: 2, query: "swift")
#expect(mock.lastPath == "/v1/users")
let items = try #require(mock.lastQueryItems)
#expect(items.contains { $0.name == "page" && $0.value == "2" })
#expect(items.contains { $0.name == "q" && $0.value == "swift" })
}
@Test("Get user returns correct user")
func getUser() async throws {
let mock = MockAPIClient()
let service = UserService(apiClient: mock)
let user = User(
id: "123",
name: "Swift",
email: "[email protected]",
avatarURL: nil,
createdAt: 0,
updatedAt: 0
)
mock.setMockResponse(user)
let response = try await service.getUser(id: "123")
#expect(response.id == "123")
#expect(mock.lastPath == "/v1/users/123")
}
}
集成测试
虽然单元测试非常适合验证逻辑,但没有什么比访问真实 API 更能确保客户端与服务器实际协同工作了。这就需要我们编写集成测试。然而,集成测试的编写也有一些痛点:速度较慢,需要网络访问,并且需要有效的身份验证凭据。
为了安全地处理凭据,我们可以从环境变量中读取它们。这可以防止在 VCS 中意外提交 API 密钥。
struct IntegrationTestConfig {
static var baseURL: URL {
URL(string: ProcessInfo.processInfo.environment["API_BASE_URL"] ?? "https://api.example.com")!
}
static var apiKey: String {
ProcessInfo.processInfo.environment["API_KEY"] ?? ""
}
}
然后,我们可以编写一个使用真实网络的测试。注意,这里我们使用的是基于真实 URLSession 的客户端,而不是 Mock。
另外,我们应当为集成测试禁止并行执行。与隔离的单元测试不同,集成测试通常共享外部资源(如测试服务器上的数据库)。并行运行可能会导致数据竞争(例如,一个测试正在删除用户,而另一个测试正在尝试读取该用户),从而导致测试结果不稳定。在 Swift Testing 中,我们可以通过传入 .serialized 参数来实现这一点。
此外,我们应该为每个服务创建独立的集成测试 Suite。这样我们可以在 init 中统一检查配置并初始化 APIClient。
@Suite("UserService Integration Tests", .serialized)
struct UserServiceIntegrationTests {
let client: MyServiceClient
init() throws {
// Skip if no API key is present (e.g. in CI without secrets)
try #require(!IntegrationTestConfig.apiKey.isEmpty)
self.client = MyServiceClient(
baseURL: IntegrationTestConfig.baseURL,
apiKey: IntegrationTestConfig.apiKey
)
}
@Test("List Users")
func listUsers() async throws {
let page = try await client.users.listUsers(page: 1)
#expect(!page.results.isEmpty)
}
@Test("Get User")
func getUser() async throws {
// First get a user ID from the list
let page = try await client.users.listUsers(page: 1)
let firstUser = try #require(page.results.first)
// Then verify we can fetch specific details
let user = try await client.users.getUser(id: firstUser.id)
#expect(user.id == firstUser.id)
}
}
这些测试确保代码在真实环境中也能正常工作。
总结
在这两篇文章中,我们从零开始构建了一个健壮的 Swift API 客户端。
在第一部分中,我们建立了一个坚实的基础,包括通用的 HTTPClient 和基于协议的 APIClient,用于处理身份验证和解码等繁重、重复性高的工作。
在第二部分中,我们在此基础上构建了一个易于扩展、用户友好的服务层。我们讨论了如何:
- 定义类型安全的
APIRequest结构体。 - 使用
Codable将 JSON 映射到 Swift 模型。 - 将相互关联的功能分组到
Service类中。 - 将所有
Service统一在单个MyServiceClient下。 - 通过单元测试和集成测试验证逻辑正确性。
对于小型应用来说,这种分层架构可能看起来有些繁琐,但随着项目规模扩大,这样的设计将带来回报。通过将请求的发送、请求的处理与请求的实际内容分离开来,你可以随意更换底层网络实现、通过 Mock 编写单元测试、并免去了大部分实现新 Endpoint 时所需要的的模版代码。
希望本系列文章能对你有所启发。Happy coding!