LoginSignup
7
7

More than 3 years have passed since last update.

[Swift5][Combine]PublisherがFailureを出力した後もストリームを途切れさせない方法の考察

Last updated at Posted at 2021-05-13

モチベーション

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を出力後も上流から値が流れてきたら同じくハンドリングしたいシーンもあります。

以下のやり方があります。
1. replaceError(with:)で失敗自体出力されないようにする
2. 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さんに教えていただきました。
compactMapfilterで出力を排除しなくても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で条件指定して排除する。
7
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7