モチベーション
Publisherはライフサイクル上、一度Failureを返すと出力をやめてしまいます。
しかし一度Failureを返しても上流から値を流した時に引き続き値を出力し続けて欲しい場面がありました。
今更なトピックスですが、勘違いしてハマったりしたので思考の整理のためにまとめてみました。
PublisherがFailureを返した時の動作
以下のPublisherを使ってその挙動を再現してみます。
- 受け取った数字が0から3の範囲なら成功
- 受け取った数字が0から3の範囲外なら失敗
import Combine
import Foundation
var cancellables = Set<AnyCancellable>()
// 引数が3以内なら成功、3以内じゃなければ失敗を返すPublisher
func makeLengthCheckPublisher(number: Int) -> AnyPublisher<String, LengthError> {
Future<String, LengthError> { promise in
if 0...3 ~= number {
return promise(.success("\(number)は3以内だからOK"))
} else {
return promise(.failure(LengthError.outBounds(number)))
}
}
.eraseToAnyPublisher()
}
(1...6).publisher // 1から6を出力
.flatMap { makeLengthCheckPublisher(number: $0) } // AnyPublisher<String, LengthError>に変換
.sink(
receiveCompletion: { result in
switch result {
case .finished:
break
case let .failure(error):
print(error.localizedDescription)
}
},
receiveValue: { message in
print(message)
}
)
.store(in: &cancellables)
enum LengthError: Error {
case outBounds(Int)
}
extension LengthError: LocalizedError {
var errorDescription: String? {
switch self {
case .outBounds(let number):
return "\(number)は範囲外だからNG"
}
}
}
実行するとこんな結果になります。
1は3以内だからOK
2は3以内だからOK
3は3以内だからOK
4は範囲外だからNG
1~3までは成功し、4でFailureを出力して、以降値を出力しなくなっているのが分かります。
上流のPublisherは値を出力してることの確認
次に「Failureを返したPublisherの上流では値が出力されていること」の確認をしておきます。
もしかしたら上流から途切れてるかもしれないですからね。
さっきのコードを修正して、途中で出力をprintするように修正します。
(1...6).publisher
.handleEvents(receiveOutput: { print("\($0)を出力します。") }) // handleEventを追加
.flatMap {
...
実行するとこんな結果になります。
1を出力します。
1は3以内だからOK
2を出力します。
2は3以内だからOK
3を出力します。
3は3以内だからOK
4を出力します。
4は範囲外だからNG
5を出力します。
6を出力します。
4が出力されてFailureが出てからも、上流では5と6を流していたことが分かりました。
Failureの出力後も途切れないようにする
本題です。
PublisherがFailureを出力すると、以降値を出力しなくなってしまうことが分かりました。
けどFailureを出力後も上流から値が流れてきたら同じくハンドリングしたいシーンもあります。
以下のやり方があります。
-
replaceError(with:)
で失敗自体出力されないようにする -
catch
で別のPublisherに変換して出力を継続する
1.replaceError(with:)
で失敗自体出力されないようにする
replaceError(with:)
はFailureが出力される時に成功時の値として出力するoperatorです。
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.replaceError(with: "範囲外でした。")
}
.sink { message in
print(message)
}
.store(in: &cancellables)
1は3以内だからOK
2は3以内だからOK
3は3以内だからOK
範囲外でした。
範囲外でした。
範囲外でした。
このハンドリング方法の問題はfailureのassociatedValueからErrorを受け取れないことです。
失敗しても成功として握り潰せる場面以外は使わないだろうなという感じです。
2.catch
で別のPublisherに変換して出力を継続する
catch
はErrorを引数で受け取りつつ別のPublisherに変換して出力を継続できるOperatorです。
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.catch { error -> Just<String> in
return .init(error.localizedDescription)
}
}
.sink { message in
print(message)
}
.store(in: &cancellables)
1は3以内だからOK
2は3以内だからOK
3は3以内だからOK
4は範囲外だからNG
5は範囲外だからNG
6は範囲外だからNG
catch
だとErrorを受け取れるので、Errorをハンドリングしつつ流れを継続できます。
以下のようにcatchのクロージャでErrorを使ってハンドリングしても良いし
.catch { [weak self] error -> Just<String> in
self?.showAlert(message)
return .init(error.localizedDescription)
}
handleEvent
でcompletionとしてFailureをハンドリングしておいて、catchはPublisherへの変換だけにするのも各Operatorの責務が明確になって良さそうです。
.handleEvents(receiveCompletion: { result in
guard case let .failure(error) = result else { return }
self?.showAlert(message)
})
.catch { error -> Just<String> in
return .init(error.localizedDescription)
}
このハンドリングで大体対応できると思います。
catch
によるハンドリングのリスク
ただcatchで変換したPublisherの出力がその後も流れてしまうので、catchとreceivedValueが両方実行されて、「失敗のハンドリングをしてるのに成功のハンドリングも実行される」という挙動になるリスクがあります。
以下の例に戻って考えてみます。
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.catch { error -> Just<String> in
return .init(error.localizedDescription)
}
}
.sink { message in
print(message)
}
.store(in: &cancellables)
1は3以内だからOK
2は3以内だからOK
3は3以内だからOK
4は範囲外だからNG
5は範囲外だからNG
6は範囲外だからNG
上記はcatch
でStringを出力するJustを作って出力を続けさせているので、エラーが起きた後もsink(receivedValue: )
が呼ばれています。
エラー時と正常時でsinkを共有して問題ないなら良いのですが、エラーが起きたらsinkには流したくない場面の方が多いと思います。
こんな時はOperaterでエラー後の出力をカットすればsinkに流れるのを防げます。
カットする方法はいくつかありますが、以下の二つが使いやすいと思います。
compactMap
filter
compactMap
による出力の制御
compactMap
を噛ませればnilの時に出力させないようにできるので、Justで返す値をOptionalにしておき、エラーならnilを出力すればOKです。
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.map { Optional($0) }
.catch { error -> Just<String?> in
print("catch: \(error.errorDescription ?? "")")
return .init(nil)
}
}
.compactMap { $0 }
.sink { message in
print("sink: \(message)")
}
.store(in: &cancellables)
実行してみるとFailureからnilを出力するJustに変換した時はsinkが呼ばれていないことが分かります。
sink: 1は3以内だからOK
sink: 2は3以内だからOK
sink: 3は3以内だからOK
catch: 4は範囲外だからNG
catch: 5は範囲外だからNG
catch: 6は範囲外だからNG
filter
による出力の制御
filter
を噛ませると指定した条件がtrueの時だけ値を出力するようにできるのでcompactMapより柔軟に出力を排除できます。
※出力に「OK」が含まれる時だけ通すようにした場合
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.catch { error -> Just<String> in
print("catch: \(error.errorDescription ?? "")")
return .init("")
}
}
.filter { $0.contains("OK") }
.sink { message in
print("sink: \(message)")
}
.store(in: &cancellables)
sink: 1は3以内だからOK
sink: 2は3以内だからOK
sink: 3は3以内だからOK
catch: 4は範囲外だからNG
catch: 5は範囲外だからNG
catch: 6は範囲外だからNG
通常時と失敗時で定義したenumに出力をラップしたりするとfilterが使いやすいかもしれません。
enumの名前適当だけどこんな感じでしょうか。
func makeLengthCheckPublisher(number: Int) -> AnyPublisher<PublishKind<String>, LengthError> {
Future<PublishKind<String>, LengthError> { promise in
if 0...3 ~= number {
return promise(.success(.next("\(number)は3以内だからOK")))
} else {
return promise(.failure(LengthError.outBounds(number)))
}
}
.eraseToAnyPublisher()
}
enum PublishKind<T> {
case next(T)
case error
var isError: Bool {
if case .error = self {
return true
} else {
return false
}
}
}
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.catch { error -> Just<PublishKind<String>> in
print("catch: \(error.errorDescription ?? "")")
return .init(.error)
}
}
.filter(\.isError)
.sink { message in
print("sink: \(message)")
}
.store(in: &cancellables)
いややっぱりAnyPublisherでErrorを出力してるのにさらに.errorみたいなcaseにラップするのは冗長な気もしますね。。。
ビタッとくる方法が思いつきませんでしたが、オプショナルにしてcompactMapが手取り早くていいかもしれません。
[2021/05/14 追記] Emptyを使えばcatch後の出力を握り潰せる。
コメントで@iceman5499さんに教えていただきました。
compactMap
やfilter
で出力を排除しなくてもEmpty
を出力すれば握り潰せます。
(1...6).publisher
.flatMap {
makeLengthCheckPublisher(number: $0)
.catch { error in
Empty()
}
}
.sink { message in
print(message)
}
.store(in: &cancellables)
1は3以内だからOK
2は3以内だからOK
3は3以内だからOK
Empty
は値を出力しないPublisherです。
Empty
にしておけばわざわざnullableにしたり条件指定しなくても良いので楽だしコードも汚れないです。
catchでfailerのハンドリングをした後は基本Empty
を返しておこうと思いました。
まとめ
-
PublisherがFailureを出力した後もストリームを続けたい場合は
-
replaceError(with:)
で成功したことにする。 -
catch
で別のPublisherに変換して出力させる。
-
-
catch
後にrecevedValue
に値を流したくない場合は- オプショナルにしてnil流してから
compactMap
で排除する。 -
filter
で条件指定して排除する。
- オプショナルにしてnil流してから