英文:
Swift combine - return Future conditionally
问题
我有一个返回类型为Future<Void, Error>的函数,从API返回并保存项目。
我希望检查返回的项目数组是否为空,如果是,则调用另一个服务端点来接收/保存项目。
显然,第二次调用需要在第一次完成后有条件地发生。
我找到了使用flatMap的示例,它们似乎非常接近我想要的,但这些示例使用AnyPublisher而不是Future,尝试使用Futures似乎给我奇怪的返回类型。
你可能能告诉我只知道Combine的基础知识!如果很简单,我会将这些调用转换为async await,并有条件地调用替代端点,但是Combine在整个网络和API层中都在使用,如果可能的话,我希望使用已建立的Combine模式来完成。
如果有人有一个从函数返回条件未来的骨架示例,我将不胜感激(下面是当前函数的示例)
谢谢!
英文:
I have a function with return type Future<Void, Error> that returns and saves items from an API
What I'm hoping to do is check whether the items array returned is empty and if so make a call to another service endpoint to receive / save items
Obviously the second call needs to happen conditionally and after the first has completed
I've found examples using flatMap that seem very close to what I want but these use AnyPublisher rather than Future and trying with Futures seems to yield me strange return types
You can probably tell I only know the fundamentals of combine! If it was simple I would convert these calls to async await and make a conditional call to the alternative endpoint but combine is used throughout the network and API layers and if possible hope to do it with the established combine pattern
If anyone has a skeleton example of returning conditional futures from a function I'd be very grateful (example of current function below)
Thanks!
func sync() -> Future<Void, Error> {
return Future { [weak self] promise in
guard let strongSelf = self else { return }
strongSelf.service.getItems
.receive(on: strongSelf.scheduler)
.sink {
if case .failure(let error) = $0 {
promise(.failure(error))
}
} receiveValue: { items in
// if items empty make another api call
strongSelf.saveItems(items)
promise(.success(()))
}.store(in: &strongSelf.subscriptions)
}
}
答案1
得分: 1
这里的挑战之一是订阅发布者是同步操作。你的Future
会按顺序执行,而订阅你使用sink
创建的会立即超出范围并被取消。
一个巧妙的解决方法是在闭包中捕获订阅并手动处理:
func sync(service: Service) -> Future<[Int], Error> {
Future<[Int], Error> { continuation in
var subscription: AnyCancellable?
subscription = service.getItems()
.flatMap { items in
if items.isEmpty {
return service.generateItems().eraseToAnyPublisher()
} else {
return Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
}.sink {
if case .failure(let error) = $0 {
continuation(.failure(error))
}
subscription?.cancel()
} receiveValue: {
continuation(.success($0))
subscription?.cancel()
}
}
}
在这里,订阅以AnyCancellable?
变量的形式在sink
的闭包中被捕获。这可以防止在future
的闭包结束时释放(和取消)订阅。订阅必须在成功和失败的情况下在sink
中被取消。
我假设你的API调用,比如service.getItems()
,返回一个Future
。flatMap
将这次调用的结果转换为一个新的发布者。如果getItems()
调用返回一个空数组,那么flatMap()
创建的发布者就是由generateItems()
返回的(我编造的另一个API调用)。如果getItems
调用不为空,则我使用Just
提供一个简单返回项目的发布者(通过一些操作符的修饰使数据类型对齐)。
对于这个例子,我编造了一个类似于下面的服务:
class Service {
var shouldReturnItems: Bool;
init(shouldReturnItems: Bool) {
self.shouldReturnItems = shouldReturnItems
}
func getItems() -> Future<[Int], Error> {
Future<[Int], Error> { continuation in
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
if self.shouldReturnItems {
continuation(.success([1, 2, 3, 4]))
} else {
continuation(.success([]))
}
}
}
}
func generateItems() -> Future<[Int], Error> {
Future<[Int], Error> { continuation in
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
continuation(.success([5, 6, 7, 8]))
}
}
}
}
你也可以使用Swift结构化并发来解决这个问题。你无需像Combine那样“转换”任何调用,因为Combine已经执行了这个操作。你仍然可以保留基于Future
的接口,以供不想使用async/await的调用者使用:
func sync(service: Service) -> Future<[Int], Error> {
Future<[Int], Error> { continuation in
Task {
do {
var items = try await service.getItems().value
if items.isEmpty {
items = try await service.generateItems().value
}
continuation(.success(items))
} catch (let error) {
continuation(.failure(error))
}
}
}
}
我觉得这比在闭包中使用订阅的版本更易读。对外部世界来说,sync
使用与服务API中使用的相同的Futures
。
我在Playground中完成了所有这些,并使用以下代码进行了测试:
Task {
do {
let items = try await sync(service: Service(shouldReturnItems: true)).value
print("service 1 returned \(items)")
} catch {
print("service 1 returned error")
}
}
Task {
do {
let items = try await sync(service: Service(shouldReturnItems: false)).value
print("service 2 returned \(items)")
} catch {
print("service 2 returned error")
}
}
对于不支持async value
属性的iOS 14,你可能可以根据上述主题的变体进行修改:
extension Future {
func myValue() async throws -> Output {
return try await withUnsafeThrowingContinuation({ continuation in
var subscription: AnyCancellable?
subscription = self.sink {
subscription?.cancel()
subscription = nil;
if case .failure(let error) = $0 {
continuation.resume(throwing: error)
}
} receiveValue: {
subscription?.cancel()
subscription = nil;
continuation.resume(returning: $0)
}
})
}
}
并在调用站点使用:
if #unavailable(iOS 15) {
await someFuture.myValue()
} else {
await someFuture.value
}
(也许给myValue
取个更好的名字)
英文:
One of the challenges here is the fact that subscribing to a publisher is a synchronous operation. Your Future
, as written, is going to execute from top to bottom and end. The subscription you create with sink
is going to fall out of scope immediately, and be cancelled.
One sneaky way to get around this is to capture the subscription in the closure and dispose of it manually:
func sync(service: Service) -> Future<[Int], Error> {
Future<[Int],Error> { contination in
var subscription: AnyCancellable?
subscription = service.getItems()
.flatMap { items in
if items.isEmpty {
return service.generateItems().eraseToAnyPublisher()
} else {
return Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
}.sink {
if case .failure(let error) = $0 {
contination(.failure(error))
}
subscription?.cancel()
} receiveValue: {
contination(.success($0))
subscription?.cancel()
}
}
}
Here the subscription is captured in the sink's closure as an AnyCancellable?
variable. This prevents it from being released (and cancelled) when the future's closure ends. The subscription must be cancelled, in the sink, in every success and failure case.
I assumed that your API calls, like service.getItems()
, return a Future
. The flatMap
converts the results that call into a new publisher. If the getItems()
call returns an empty array, then the publisher created by flatMap()
is the one returned by generateItems()
(another API call I invented). If the getItems
call is not empty, then I use Just
to provide a publisher that simply returns the items (with some decoration of operators to make the data types line up).
For this example, I made up a service that looks like this:
class Service {
var shouldReturnItems: Bool;
init(shouldReturnItems: Bool) {
self.shouldReturnItems = shouldReturnItems
}
func getItems() ->Future<[Int], Error> {
Future<[Int], Error> { continuation in
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
if self.shouldReturnItems {
continuation(.success([1, 2, 3, 4]))
} else {
continuation(.success([]))
}
}
}
}
func generateItems() -> Future<[Int], Error> {
Future<[Int], Error> { continuation in
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .seconds(1))) {
continuation(.success([5, 6, 7, 8]))
}
}
}
}
You can also solve this problem using Swift Structured Concurrency. You don't have to "convert" any of the calls as Combine does that already. You can still preserve the Future
based interface for callers that don't want to use async/await:
func sync(service: Service) -> Future<[Int], Error> {
Future<[Int],Error> { contination in
Task {
do {
var items = try await service.getItems().value
if items.isEmpty {
items = try await service.generateItems().value
}
contination(.success(items))
} catch (let error) {
contination(.failure(error))
}
}
}
}
I find this much more readable than the version using the subscription in the closure. To the outside world, sync
uses the same Futures
that are used in the service's API.
I did all this in a Playground and tested it with this code:
Task {
do {
let items = try await sync(service: Service(shouldReturnItems: true)).value
print("service 1 returned \(items)")
} catch {
print("service 1 returned error")
}
}
Task {
do {
let items = try await sync(service: Service(shouldReturnItems: false)).value
print("service 2 returned \(items)")
} catch {
print("service 2 returned error")
}
}
For iOS 14 where the async value
property of a future is not available you might be able to use a variation on the themes above:
extension Future {
func myValue() async throws -> Output {
return try await withUnsafeThrowingContinuation({ continuation in
var subscription: AnyCancellable?
subscription = self.sink {
subscription?.cancel()
subscription = nil;
if case .failure(let error) = $0 {
continuation.resume(throwing: error)
}
} receiveValue: {
subscription?.cancel()
subscription = nil;
continuation.resume(returning: $0)
}
})
}
}
and at the call site:
if #unavailable(iOS 15) {
await someFuture.myValue()
} else {
await someFuture.value
}
(perhaps giving myValue
a nicer name)
答案2
得分: 1
有几种解决方法。如果你实际上不需要知道作业何时完成,那么可以使你的 sync
返回 Void 并执行以下操作:
func syncʹ() {
service.getItems
.flatMap { items in
items.isEmpty
? anotherAPICall().map { items }.eraseToAnyPublisher()
: Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
.sink {
if case .failure(let error) = $0 {
// 处理失败
}
} receiveValue: { [weak self] items in
guard let self else { return }
self.saveItems(items)
}
.store(in: &subscriptions)
}
如果你希望 sync 仍然告诉你何时完成,以供某些下游流程使用,可以使用类似以下的方法:
func sync() -> AnyPublisher<Void, Error> {
return service.getItems
.flatMap { items in
items.isEmpty
? anotherAPICall().map { items }.eraseToAnyPublisher()
: Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
.flatMap { [unowned self] items in
self.saveItemsʹ(items)
}
.eraseToAnyPublisher()
}
func saveItemsʹ(_ items: [Item]) -> Future<Void, Never> {
Future { [unowned self] promise in
self.saveItems(items)
promise(.success(()))
}
}
第一个 flatMap 将进行 API 调用并在数组为空时发出其值(或错误),否则它将只发出数组。
另一种选择是,如果你想将作业分解为逻辑单元:
func async() -> AnyPublisher<Void, Error> {
let items = service.getItems
.share()
let otherCall = items
.filter { $0.isEmpty }
.flatMap { _ in anotherAPICall() }
return Publishers.Merge(
otherCall.map { [] },
items.filter { !$0.isEmpty }
)
.flatMap { [unowned self] items in
self.saveItemsʹ(items)
}
.eraseToAnyPublisher()
}
上述代码为每个副作用提供了自己的响应对象。
英文:
There's a couple of ways to solve this. If you don't actually need to know when the job is finished, then you can make your sync
return Void and do this:
func syncʹ() {
service.getItems
.flatMap { items in
items.isEmpty
? anotherAPICall().map { items }.eraseToAnyPublisher()
: Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
.sink {
if case .failure(let error) = $0 {
// handle failure
}
} receiveValue: { [weak self] items in
guard let self else { return }
self.saveItems(items)
}
.store(in: &subscriptions)
}
If you want sync to still tell you when it's done for some downstream process then something like this:
func sync() -> AnyPublisher<Void, Error> {
return service.getItems
.flatMap { items in
items.isEmpty
? anotherAPICall().map { items }.eraseToAnyPublisher()
: Just(items).setFailureType(to: Error.self).eraseToAnyPublisher()
}
.flatMap { [unowned self] items in
self.saveItemsʹ(items)
}
.eraseToAnyPublisher()
}
func saveItemsʹ(_ items: [Item]) -> Future<Void, Never> {
Future { [unowned self] promise in
self.saveItems(items)
promise(.success(()))
}
}
The flatMap (first one) will make the api call and emit it's value (or error) if the array is empty, otherwise it will just emit th array.
Another option if you want to break up the job into logical units:
func async() -> AnyPublisher<Void, Error> {
let items = service.getItems
.share()
let otherCall = items
.filter { $0.isEmpty }
.flatMap { _ in anotherAPICall() }
return Publishers.Merge(
otherCall.map { [] },
items.filter { !$0.isEmpty }
)
.flatMap { [unowned self] items in
self.saveItemsʹ(items)
}
.eraseToAnyPublisher()
}
The above gives each side effect it's own response object.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论