自动重试一个异步Swift任务的教程

517 阅读4分钟

有时,我们可能想自动重试一个失败的异步操作,例如,为了解决临时的网络问题,或者重新建立某种形式的连接。

在这里,我们在使用苹果的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 闭包的东西)--但我希望这篇文章给了你一些想法,让你可以在你的项目中实现自动重试任务。

谢谢你的阅读!