Swift:创建基于async/await的API的组合兼容版本教程

530 阅读5分钟

许多开发者在长期维护各种代码库时面临的一个挑战是,如何将不同的框架和API整齐地连接起来,以适当地遵守所涉及的每种技术的惯例。

例如,随着世界各地的团队开始采用Swift 5.5的async/await-poweredconcurrency system,我们可能会发现自己需要创建与其他异步编程技术(如Combine)兼容的async-marked API版本的情况。

虽然我们已经看了Combine与异步序列和流等并发API的关系,以及我们如何能够在Combine管道中调用async-marked函数,但在本文中,让我们探讨如何能够轻松创建任何async API的基于Combine的变体,无论它是由我们定义的,还是由苹果定义的,还是作为第三方依赖的一部分。

异步期货

假设我们正在开发的一个应用程序包含以下ModelLoader ,它可以用来通过网络加载任何Decodable 模型。它通过一个看起来像这样的async 函数来执行其工作:

class ModelLoader<Model: Decodable> {
    ...

    func loadModel(from url: URL) async throws -> Model {
        ...
    }
}

现在,让我们假设我们也想创建一个基于Combine的上述loadModel API的版本,例如,为了能够在我们代码库的特定部分中调用它,这些部分可能是用Combine框架以更多的反应式方式编写的。

当然,我们可以选择专门为我们的ModelLoader 类型编写这种兼容性代码,但由于这是一个我们在使用基于Combine的代码时可能会多次遇到的普遍问题,让我们转而创建一个更通用的解决方案,我们将能够在我们的代码库中轻松重用。

由于我们正在处理async ,这些函数要么返回一个单一的值,要么抛出一个错误,让我们使用Combine的Future publisher来包装这些调用。这个发布者类型是专门为这类用例建立的,因为它给了我们一个闭包,可以用来向框架报告一个Result

所以让我们继续用一个方便的初始化器来扩展Future 类型,使之有可能用一个async 闭包来初始化一个实例。

extension Future where Failure == Error {
    convenience init(operation: @escaping () async throws -> Output) {
        self.init { promise in
            Task {
                do {
                    let output = try await operation()
                    promise(.success(output))
                } catch {
                    promise(.failure(error))
                }
            }
        }
    }
}

创建这样一个抽象的力量,它与任何特定的用例无关,我们现在可以将它应用于任何我们想让 Combine 兼容的async API。只需要几行代码,在传递给我们新的Future 初始化器的闭包中调用我们想要连接的API--就像这样:

extension ModelLoader {
    func modelPublisher(for url: URL) -> Future<Model, Error> {
        Future {
            try await self.loadModel(from: url)
        }
    }
}

很好!loadModel 请注意,我们可以选择给基于Combine的版本起一个与我们的async 的版本相同的名字(因为Swift支持方法重载)。然而,在这种情况下,将两者明确分开可能是个好主意,这就是为什么上述新API的名称中明确包含 "Publisher "一词。

反应式异步序列

异步序列和流也许是Swift标准库中最接近采用反应式编程的,这反过来又使这些API的行为与Combine非常相似--因为它们使我们能够随着时间的推移而发出数值

事实上,在"异步序列、流和Combine "一文中我们看了Combine发布器如何使用其values 属性直接转换为异步序列--但如果我们想反其道而行之,将一个异步序列(或流)转换为发布器呢?

继续之前的ModelLoader 的例子,假设我们的加载器类也提供了以下的API,它让我们创建一个AsyncThrowingStream ,发射一系列从URL阵列加载的模型。

class ModelLoader<Model: Decodable> {
    ...

    func loadModels(from urls: [URL]) -> AsyncThrowingStream<Model, Error> {
        ...
    }
}

就像以前一样,与其急着写代码将上述loadModels API转换为Combine发布器,不如试着想出一个通用的抽象,当我们想在项目中的其他地方写类似的桥接代码时,我们就可以重复使用。

这一次,我们将扩展Combine的PassthroughSubject 类型,这使我们能够完全控制它的值何时被发出,以及何时和如何终止。然而,我们不打算将这个API建模为一个方便的初始化器,因为我们要清楚地表明,调用这个API实际上将使创建的主体立即开始发射值。所以让我们把它变成一个静态工厂方法--像这样:

extension PassthroughSubject where Failure == Error {
    static func emittingValues<T: AsyncSequence>(
        from sequence: T
    ) -> Self where T.Element == Output {
        let subject = Self()

        Task {
            do {
                for try await value in sequence {
                    subject.send(value)
                }

                subject.send(completion: .finished)
            } catch {
                subject.send(completion: .failure(error))
            }
        }

        return subject
    }
}

有了上述内容,我们现在就可以像之前的async-marked API一样轻松地封装我们的基于流的loadModels API--在这种情况下,唯一需要的额外步骤是我们的PassthroughSubject 实例类型化为 AnyPublisher ,以防止任何其他代码能够向我们的主体发送新值。

extension ModelLoader {
    func modelPublisher(for urls: [URL]) -> AnyPublisher<Model, Error> {
        let subject = PassthroughSubject.emittingValues(
            from: loadModels(from: urls)
        )
        
        return subject.eraseToAnyPublisher()
    }
}

就这样,我们现在已经创建了两个方便的 API,使我们可以非常直接地使使用 Swift 并发系统的代码向后兼容 Combine--当与在某种程度上使用 Combine 的代码库一起工作时,这应该证明是非常方便的。

总结

尽管Swift现在确实有一个内置的并发系统,涵盖了与Combine相同的领域,但我认为这两种技术在未来几年内仍将非常有用--所以我们越能在它们之间建立顺畅的桥梁就越好。

虽然一些开发者可能会选择使用Swift并发性完全重写他们基于Combine的代码,但好消息是我们不需要这样做。只要有几个方便的API,我们就可以在这两种技术之间传递数据和事件,这反过来又可以让我们继续使用基于Combine的代码,即使我们开始采用async/await 和Swift的其他并发系统。

谢谢你的阅读!