有时,我们可能想自动重试一个失败的异步操作,例如,为了解决临时的网络问题,或者重新建立某种形式的连接。
在这里,我们在使用苹果的Combine框架来实现一个网络调用时就是这样做的,在处理遇到的任何错误之前,我们最多重试3次:
struct SettingsLoader {
var url: URL
var urlSession = URLSession.shared
var decoder = JSONDecoder()
func load() -> AnyPublisher<Settings, Error> {
urlSession
.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Settings.self, decoder: decoder)
.retry(3)
.eraseToAnyPublisher()
}
}
请注意,上面的例子将无条件地重试我们的加载操作(最多 3 次),而不管抛出的是什么类型的错误。
但是,如果我们想实现类似的东西,但使用Swift Concurrency来代替呢?虽然Combine的Publisher 协议包括上述的retry 操作符作为一个内置的API,但Swift的新并发API都没有提供类似的东西(至少在写这篇文章的时候没有),所以我们必须发挥创意
Swift的新并发系统,特别是async/await ,有一个非常好的方面,就是它使我们能够将各种异步调用与标准的控制流结构混合在一起,比如if 语句和for 循环。因此,为await 标记的调用实现自动重试的一种方法是,将我们想要运行的异步代码放在一个循环中,该循环在一个范围内迭代,而这个范围又描述了我们希望执行的重试次数--就像这样:
struct SettingsLoader {
var url: URL
var urlSession = URLSession.shared
var decoder = JSONDecoder()
func load() async throws -> Settings {
// Perform 3 attempts, and retry on any failure:
for _ in 0..<3 {
do {
return try await performLoading()
} catch {
// This 'continue' statement isn't technically
// required, but makes our intent more clear:
continue
}
}
// The final attempt (which throws its error if it fails):
return try await performLoading()
}
private func performLoading() async throws -> Settings {
let (data, _) = try await urlSession.data(from: url)
return try decoder.decode(Settings.self, from: data)
}
}
上面的实现工作得非常好,但如果我们想在整个项目的多个地方添加同样的重试逻辑,那么可能值得将这些代码转移到某种形式的实用程序中,以便于重复使用。
做到这一点的一个方法就是用一个方便的API来扩展Swift的Task 类型,让我们快速创建这样的自动重试任务。我们的实际逻辑可以保持与以前几乎相同,但我们将对最大重试次数进行参数化,并且我们还将添加对取消的支持:
extension Task where Failure == Error {
@discardableResult
static func retrying(
priority: TaskPriority? = nil,
maxRetryCount: Int = 3,
operation: @Sendable @escaping () async throws -> Success
) -> Task {
Task(priority: priority) {
for _ in 0..<maxRetryCount {
try Task<Never, Never>.checkCancellation()
do {
return try await operation()
} catch {
continue
}
}
try Task<Never, Never>.checkCancellation()
return try await operation()
}
}
}
这已经是一个非常有用的、完全可重用的实现了,但让我们把事情再往前推一步,好吗?
当重试异步操作时,在每次重试之间添加一些延迟是很常见的--也许是为了给外部系统(比如服务器)一个机会,在我们再次尝试调用它之前从某种错误中恢复过来。因此,让我们也添加对这种延迟的支持,这可以很容易地使用内置的Task.sleep API来完成:
extension Task where Failure == Error {
@discardableResult
static func retrying(
priority: TaskPriority? = nil,
maxRetryCount: Int = 3,
retryDelay: TimeInterval = 1,
operation: @Sendable @escaping () async throws -> Success
) -> Task {
Task(priority: priority) {
for _ in 0..<maxRetryCount {
do {
return try await operation()
} catch {
let oneSecond = TimeInterval(1_000_000_000)
let delay = UInt64(oneSecond * retryDelay)
try await Task<Never, Never>.sleep(nanoseconds: delay)
continue
}
}
try Task<Never, Never>.checkCancellation()
return try await operation()
}
}
}
注意我们现在可以在我们的for 循环开始时删除checkCancellation 的调用,因为如果任务被取消,我们的Task.sleep 调用将自动抛出一个错误。要了解更多关于延迟Task 实例的信息,请查看"延迟一个异步 Swift 任务"。
如果我们想的话,我们也可以给我们的operation 闭包添加 "半公开 "的@_implicitSelfCapture 属性,这将给它带来与直接向Task 类型本身传递闭包时一样的隐式自捕获行为。
然而,我并不推荐这样做(考虑到下划线属性可以在任何时候改变),所以让我们通过重构之前的SettingsLoader 例子来代替使用我们新的Task 扩展来执行其重试来结束事情:
struct SettingsLoader {
var url: URL
var urlSession = URLSession.shared
var decoder = JSONDecoder()
func load() async throws -> Settings {
try await Task.retrying {
let (data, _) = try await urlSession.data(from: url)
return try decoder.decode(Settings.self, from: data)
}
.value
}
}
非常好!注意我们如何使用一个给定任务的value 属性来观察它的返回值(或者重新抛出任务本身的任何错误)。
总结
当然,我们有很多方法可以将这篇文章的Task 扩展到更远的地方--例如,使其可以只在某些错误时重试(也许可以在创建任务时传递类似于retryPredicate 闭包的东西)--但我希望这篇文章给了你一些想法,让你可以在你的项目中实现自动重试任务。
谢谢你的阅读!