Swift 网络,或者 swift-networking
,是一个用于在 Swift 应用程序内部构建灵活网络堆栈的库。它可以在任何 Apple 平台上使用。您可以使用它来提供丰富的网络功能,例如身份验证、去重、节流等等。
Swift 网络是一个 Swift Package,它提供了一些用于发出 HTTP 网络请求的核心工具。
它的理念围绕着这样一个想法:在高层,客户端发送请求并等待每个请求的响应。
与编程中的几乎所有事情一样,这是一个数据类型的转换,因此适合函数式编程风格。考虑到这一点,该库提供了可以组合在一起以执行此转换的组件。所有内置组件都经过了充分的测试,并带有测试助手,可以轻松测试您自己的自定义组件。
该库使用了 URLSession,因为它提供了最终负责发送请求的终端组件。 Swift Networking 抽象了这个细节,同时提供了比 URLSession 更多的便利。 此外,此库提供了许多有用的构建块,而 URLSession 本身并不提供这些构建块。
如果我们认为当客户端发出网络请求时,它本质上是一个函数:(Request) async throws -> Response
,这可以通过一个名为 NetworkingComponent
的协议来表示。我们可以在 URLSession
上提供对这个协议的遵循,并使用它来发出网络请求。但是,在将请求提供给 URLSession
之前,有机会进一步转换它,也许需要修改它,或者我们希望收集指标,或者甚至从另一个系统返回 Response。
更进一步,我们可以考虑一个组件链,
┌────────────────────────────────────────────┐
│ Network Stack │ ┌────────┐
┌─────────┐ │ ┌─────────┐ ┌─────────┐ ┌──────────┐ │ │ │
│ Request │─ ─ ─▶│ │ A │───▶│ B │───▶│ Terminal │ │─ ─ ─▶│ Server │
└─────────┘ │ └─────────┘ └─────────┘ └──────────┘ │ │ │
└────────────────────────────────────────────┘ └────────┘
在此图中,应用程序(或客户端)有 3 个组件:A
、B
和 Terminal
。当一个请求被提供给整个堆栈时,它从组件 A 开始,最终将其提供给组件 B,最终将其提供给终端组件,终端组件是最后一个组件,负责获取响应。 来自服务器的响应会通过堆栈返回。
为了使这种组合变得容易,该库提供了一个 NetworkingModifier
协议,它的工作方式与 SwiftUI 中的 ViewModifier
完全相同。除了终端组件之外的所有网络组件实际上都是修饰符,因此除了 Request
值之外,它们还会收到“上游”网络组件。这就是组件 A 如何将请求传递到组件 B 等的原因。
每个内置组件都在 NetworkingComponent
上提供公共扩展,就像 SwiftUI 中的 ViewModifiers 通常通过对 View
协议的扩展来提供 API 一样。 这会产生一个声明式的网络堆栈,如下所示
let network = URLSession.shared
.removeDuplicates()
.logged()
更新我们上面的图表,我们可以看到网络堆栈使我们能够将组件连接在一起,输入请求并获取响应。
┌─────────┐
│ Request │─ ─ ─▶┌────────────────────────────────────────────┐
└─────────┘ │ Network Stack │ ┌────────┐
│ ┌─────────┐───▶┌─────────┐───▶┌──────────┐ │ │ │
│ │ Logged │ │ De-dupe │ │URLSession│ │◀─ ─ ─▶│ Server │
┌──────────┐ │ └─────────┘◀───└─────────┘◀───└──────────┘ │ │ │
│ Response │◀ ─ ─ └────────────────────────────────────────────┘ └────────┘
└──────────┘
我强烈建议您使用 pointfreeco/swift-dependencies 进行依赖注入和客户端管理。这意味着您应该创建一个像这样的“网络客户端”
import Dependencies
import Foundation
import Networking
struct NetworkClient {
var network: () -> any NetworkingComponent
init(network: @escaping () -> any NetworkingComponent) {
self.network = network
}
}
extension NetworkClient: DependencyKey {
static let liveValue: Self = {
let network = URLSession.shared
.duplicatesRemoved()
.automaticRetry()
return .init(network: {
network
})
}()
static let testValue: Self = .init(
network: unimplemented("\(Self.self).network")
)
}
extension DependencyValues {
var networkClient: NetworkClient {
get { self[NetworkClient.self] }
set { self[NetworkClient.self] = newValue }
}
}
这是一个有些简陋的网络堆栈,可以通过访问 @Dependency(\.networkClient) var networkClient
来使用。
该库附带以下内置组件。
Authentication
可用于处理网络身份验证。这可能是最复杂的组件,它在应用程序中的使用需要委托一致性。 目前支持基本和 Bearer 身份验证方法。 未来的增强功能将是支持 OAuth 等。
Cached
可用于在内存中缓存网络响应。 未来的增强功能将是支持不同的缓存后端系统。
CheckedStatusCode
这是一个简单的组件,用于清理错误处理以挑选出一些基本情况。 目前,它可以进行自定义,因此作为内部组件更有用,但未来的增强功能可以允许它用于自定义错误处理。
Delayed
将请求延迟固定的 Duration
。 这使用 Swift 连续时钟,并且非常可测试。
DuplicatesRemoved
网络堆栈允许并发网络请求,这意味着多个请求可以同时处于活动状态。 此组件将阻止任何重复的请求触发,并与所有重复的请求共享唯一执行的请求的响应。 小心使用它,它可能会掩盖潜在的应用程序错误。
Logged
虽然完全可自定义,但此组件具有合理的默认设置,可以使用 Logger
记录有关请求开始和完成的信息。 此外,底层类型具有属性以启用漂亮的打印。
Instrument
可以将其包含在您的网络堆栈中以检测其性能。 它可以报告每个请求的总经过时间,包括每个组件的细分(支持检测)。 目前,这只是将指标记录到控制台。 未来的改进将允许更丰富的报告机制,包括会话统计信息。
Numbered
为当前会话中发送的每个请求添加一个单调递增的数字,即从堆栈初始化时开始。 这对于日志记录和调试非常方便。 同样值得注意的是,基本的 HTTP 请求类型 HTTPRequestData
也唯一标识每个请求。
Retry
自动重试失败的请求。 默认情况下,每个请求在 3 秒的固定延迟后最多重试 3 次。 但是,可以为每个请求配置此设置,并提供恒定、立即或指数策略。 或者通过遵循 RetryingStrategy
创建您自己的策略。
Server
一个构建块组件,用于配置通过堆栈发送的所有请求。 例如,设置默认请求标头、基本 URL、方案等。 通常,这允许您的应用程序仅创建每个请求的特定方面,例如查询参数或正文值。 但是,所有请求都将获得堆栈配置的默认请求参数。 此组件也可以链接在一起,因此通常会多次使用它,这使得每行/调用都成为配置的可读且可维护的点。 一般来说,最好在日志记录之后添加服务器组件,以便将它们包含在日志记录信息中。
Throttled
可用于限制并发请求的数量。 这对于保护您的后端免受用户行为可能淹没服务器的情况非常有帮助。 此外,请求会添加到内部队列中。
URLSession
目前,唯一的终端组件是 URLSession。 未来,可以支持适合请求/响应的其他传输方式。
该库提供了名为 HTTPRequestData
和 HTTPResponseData
的结构。 在内部,它们使用 Apple 的 HTTPRequest
和 HTTPResponse
值类型。
这些是该库的构建块,用于通过堆栈发出请求,如下所示
// Access the network client
@Dependency(\.networkClient) var networkClient
// Create a http request data value, more on this later...
let request = HTTPRequestData(path: "hello")
do {
// Await the data response
let response = try await networkClient.data(request)
// Access basic properties
let originalRequest = response.request
let payloadData: Data = response.data // might be empty.
} catch as NetworkingError {
// Access basic properties
let originalRequest = error.request
if let response = error.response {
// in some cases we might have an `HTTPResponseData` value,
// which allows access to underlying response info
}
}
虽然这很好,但它非常底层,不建议用于大多数用例。 相反,应用程序通常希望将有效负载 Data
值解码为特定的 Coadable
类型。 这是可以使用 Request
类型的地方。
Request
是一个泛型值,它组合了 HTTPRequestData
值,以及将 Data
解码为某些 Body
类型的能力。 如果所需的 Body
类型符合 Decodable
,这是自动的,但支持完全自定义。 甚至可以在将数据转换为应用程序域类型所需的 Body
之前,将数据解码为可解码的中间“数据传输对象”。
// Access the network client
@Dependency(\.networkClient) var networkClient
// Create a http request data value, more on this later...
let http = HTTPRequestData(path: "hello")
// Create a request, assuming that MyExpectedBody is Decodable
let request = Request<MyExpectedBody>(http: http) // This convenience uses default JSON decoder
// Await the value response
let (value, response) = try await networkClient.value(request)
虽然这可以正常工作,但不得不创建看似两个请求值有点麻烦。 相反,建议使用 Request
类型的受约束扩展。
extension Request where Body == MyExpectedBody {
static func myBodyValue() -> Self {
Request(http: .init(
path: "hello"
))
}
}
有了这个,我们的代码就变成了,
// Access the network client
@Dependency(\.networkClient) var networkClient
// Await the value response
let (value, response) = try await networkClient.value(.myBodyValue())
上面的示例显示了 NetworkClient 的用法。 这些示例都没有指定超出 "hello" 路径的任何内容,这缺少一些关键信息,例如 authority。 它默认为 "GET" HTTP 方法和 "https" 方案。 所有这些属性都可以在请求中获取/设置,
var request = HTTPRequestData(method: .post, authority: "my-server.com")
request.headerFields[.accepts] = "application/json"
request.path = "message"
request.greeting = "Hello World"
print(request.debugDescription) // POST https://my-server.com/message?greeting=Hello%20World
但当然,为每个请求执行此操作是不可取的,服务器的属性应该只配置一次,这就是建议创建 API Client 而不是或除了 Network Client 之外的原因。
让我们假设我们想通过使用 http://ipinfo.io/ 来找出客户端的地理位置。 这是一项为 IP 地址执行地理信息服务的服务。 如果您的应用程序需要连接到多个服务器,例如除了您自己的第一方服务器之外的第三方服务器,那么拥有一个带有多个 API Client 的单个 Network Client 是一个很好的理由。 在此示例中,我们可以创建一个 IpInfo 客户端,
import Dependencies
import Foundation
struct IpInfoClient {
typealias FetchIpInfoData = @Sendable (String?) async throws -> IpInfoData
var fetch: FetchIpInfoData
init(fetch: @escaping FetchIpInfoData) {
self.fetch = fetch
}
}
extension IpInfoClient: TestDependencyKey {
static var testValue: Self = .init(
fetch: unimplemented("\(Self.self).fetch")
)
}
extension DependencyValues {
var ipInfoClient: IpInfoClient {
get { self[IpInfoClient.self] }
set { self[IpInfoClient.self] = newValue }
}
}
// And the Live Network Client (could be a separate module)
import LiveNetwork // Lets assume we have a "live" network client as described above in this module
import Networking // Import this library, swift-networking
extension IpInfoClient: DependencyKey {
static let liveValue: Self = {
// Get the network client - to access the standard network stack
@Dependency(\.networkClient) var client
let network = client
.network()
.logged(using: .app(category: "Network"))
.server(headerField: .authorization, "Bearer \(<Secret API Key>)") // Don't store secrets in SCM though!
.server(headerField: .accept, "application/json")
.server(authority: "ipinfo.io")
return IpInfoClient(
fetch: { address in
try await network.value(.ipInfo(of: address)).body
}
)
}()
}
extension Request where Body == IpInfoData {
static func ipInfo(of address: String?) -> Self {
Request(
http: HTTPRequestData(path: address ?? "/")
)
}
}
要在应用程序中使用它,我们只需要导入一些模块并访问 api 客户端。
import IpInfoClient
import Dependencies
@Dependency(\.ipInfoClient) var ipInfoClient
// get the geographic info for the client's IP address
let ipInfo = try await ipInfoClient.fetch(nil)