Creating Combine-compatible versions of async/await-based APIs

随着时间的推移,许多开发人员在维护各种代码库时面临的一个挑战是如何以正确遵守每项相关技术约定的方式整齐地连接不同的框架和API。

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

让我们探索如何轻松创建任何异步API的基于Combine的变体,无论它是由我们定义的、苹果定义的,还是作为第三方依赖项的一部分。


1. Async futures

假设我们正在开发的应用程序包含以下ModelLoader,可用于通过网络加载任何可解码模型。它通过如下所示的异步函数执行其工作:

class ModelLoader<Model: Decodable> {
    ...

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

现在,假设我们还想创建一个基于Combine的上述loadModel API版本,例如,以便能够在我们代码库的特定部分中调用它,这些部分可能使用Combine框架以更被动的风格编写。

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

由于我们正在处理返回单个值或抛出错误的异步函数,让我们使用Combine的Future发布器来包装这些调用。该publisher类型是专门为此类用例构建的,因为它为我们提供了一个闭包,可用于将单个Result报告回框架。

因此,让我们继续使用方便的初始化器扩展Future类型,该初始化器可以使用异步闭包初始化实例:

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兼容的任何异步API。只需要几行代码来调用API,我们希望在传递给我们新的Future初始化器的闭包中桥接API——如下所示:

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

整洁!请注意,我们如何选择为基于Combine的版本提供与我们异步驱动的版本相同的loadModel名称(因为Swift支持方法重载)。然而,在这种情况下,明确区分两者可能是一个好主意,这就是为什么上述新API的名称明确包括“Publisher”一词。


2. Reactive async sequences

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

如果我们想将异步序列(或流)转换为publisher怎么办?

继续之前的ModelLoader示例,假设我们的加载程序类还提供以下API,它允许我们创建一个AsyncThrowingStream,发出从URL数组加载的一系列模型:

class ModelLoader<Model: Decodable> {
    ...

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

就像以前一样,与其匆忙编写专门将上述loadModels API转换为Combine publisher的代码,不如尝试想出一个通用抽象,每当我们想在项目中的其他地方编写类似的桥接代码时,我们都可以重用。

这一次,我们将扩展Combine的PassthroughSubject类型,该类型使我们能够完全控制其值何时发射,以及何时以及如何终止。然而,我们不会将此API建模为方便的初始化器,因为我们想明确表示,调用此API实际上将使创建的subject立即开始发出值。因此,让我们把它改成静态工厂方法——如下所示:

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
    }
}

有了上述规定,我们现在几乎可以像之前异步标记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的代码库时,这应该是非常方便的。


3. Conclusion

尽管Swift现在确实有一个内置并发系统,其覆盖范围与Combine大致相同,但我认为这两种技术在未来几年将继续非常有用——因此我们越能在它们之间建立更平坦的桥梁,就越好。

虽然一些开发人员可能会选择使用Swift并发完全重写基于Combine-based的代码,但好消息是,我们不必这样做。只需几个方便的API,我们就可以使在这两种技术之间传递数据和事件变得微不足道,这反过来又将使我们能够继续使用基于Combine的代码,即使我们开始采用async/await和Swift并发系统的其余部分。