🌾

[Swift] [Combine] エラーがあっても止まらないストリームを作りたい!

2022/02/19に公開

伝えたいこと

  • tryMap() -> catch -> Empty の合わせ技で、エラーを握りつぶしたストリームをつくることができる
  • しかし、Failure の可能性がある Publisher を subscribe している状態で、一度でも Failure が発生すると、その時点で Completion となり、Output を受け付けなくなる
  • 上記の回避策として、flatMap()で subscribe することなく tryMap() -> catch -> Empty の合わせ技をかけることによって、エラーがあっても止まらないストリームを作り出せる
  • ただし、flatMap() 内でのエラーハンドリングは見通しが悪いため、Publisher の Output を Result 型にする技を使うと、見通しよくエラーがあっても止まらないストリームを作り出せる

やりたいこと

以下を実施したいときに、どのようにするか考えます。

  • numPublisher: PassthroughSubject<Int, Never> を用意して、これを subscribe して、奇数と偶数に分けて異なる処理する
  • 負の値が出力されたときは minusError としてエラー処理する
  • エラーが発生しても、ストリームは止めずに奇数と偶数の処理を行い続ける

ポイントは、エラーが発生してもストリームを止めないようにすることです。

以下はサンプルコードの共通の定義になります。

import Combine

enum TestError: Error {
    case minusError
}

let numPublisher = PassthroughSubject<Int, Never>()

var cancellables: Set<AnyCancellable> = []

案1(ダメな例): tryMap() -> catch -> Empty の合わせ技

tryMap() -> catch -> Empty の合わせ技でエラーは握りつぶすことができるので、その技を使ってみます。

// AnyPublisher<Int, Error> を宣言
let zeroCheckedPublisher: AnyPublisher<Int, Error>
zeroCheckedPublisher = numPublisher
    .tryMap {
        guard $0 >= 0 else {
            throw TestError.minusError
        }
        return $0
    }
    .eraseToAnyPublisher()

// AnyPublisher<Int, Error> -> AnyPublisher<Int, Never> にする
let outputPublisher: AnyPublisher<Int, Never>
outputPublisher = zeroCheckedPublisher
    .catch { error -> AnyPublisher<Int, Never> in
        print("error: \(error)")
        // catch -> Empty でなかったことにする
        return Empty<Int, Never>().eraseToAnyPublisher()
    }
    .share()
    .eraseToAnyPublisher()

let oddPublisher: AnyPublisher<Int, Never>
oddPublisher = outputPublisher
    .filter { $0 % 2 != 0 }
    .eraseToAnyPublisher()

let evenPublisher: AnyPublisher<Int, Never>
evenPublisher = outputPublisher
    .filter { $0 % 2 == 0 }
    .eraseToAnyPublisher()

evenPublisher
    .sink { print("evenPublisher: \($0)") }
    .store(in: &cancellables)

oddPublisher
    .sink { print("oddPublisher: \($0)") }
    .store(in: &cancellables)

numPublisher.send(1)
numPublisher.send(2)
numPublisher.send(3)
numPublisher.send(-1)
numPublisher.send(4)
numPublisher.send(-2)
numPublisher.send(5)

// (出力)
// oddPublisher: 1
// evenPublisher: 2
// oddPublisher: 3
// error: minusError

↑ あれ、、、エラーが発生したタイミングで止まってしまう。。。

どうやら Combine の思想として、AnyPublisher<Int, Error> などの Failure の可能性がある Publisher を subscribe している状態で、一度でも Failure が発生すると、その時点で Completion となり、Output を受け付けなくなるみたいです。

『一度でもエラーが発生したなら、もう一度 Publisher を生成するところからリトライしてね!』ということなんでしょう。

もしくは、そもそもやり直しが必要ないエラーは、エラーとするのではなく、compactMap() で握り潰してくださいということなのでしょう。

案2: flatMap() 内での tryMap() -> catch -> Empty の合わせ技

案 1 のように 1 度でもエラーを発生させると、その時点で Output を受け付けなくなってしまいます。

そうであるならば AnyPublisher<Int, Error> ではなく AnyPublisher<Int, Never> でやり通すしかありません。

案1 の処理を flatMap() 内で行うことで、Failure の可能性がある Publisher を subscribe することなく、エラーを握りつぶすことができます。

flatMap() 内に隠蔽する技として、flatMap() 内で Just() で囲う技があります。

// flatMap() 内で subscribe することなく tryMap() -> catch -> Empty の合わせ技をかける
let outputPublisher: AnyPublisher<Int, Never>
outputPublisher = numPublisher
    .flatMap {
        Just($0)
            .tryMap {
                guard $0 >= 0 else {
                    throw TestError.minusError
                }
                return $0
            }
            .catch { error -> AnyPublisher<Int, Never> in
                print("error: \(error)")
                // catch -> Empty でなかったことにする
                return Empty<Int, Never>().eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
    .share()
    .eraseToAnyPublisher()

let oddPublisher: AnyPublisher<Int, Never>
oddPublisher = outputPublisher
    .filter { $0 % 2 != 0 }
    .eraseToAnyPublisher()

let evenPublisher: AnyPublisher<Int, Never>
evenPublisher = outputPublisher
    .filter { $0 % 2 == 0 }
    .eraseToAnyPublisher()

evenPublisher
    .sink { print("evenPublisher: \($0)") }
    .store(in: &cancellables)

oddPublisher
    .sink { print("oddPublisher: \($0)") }
    .store(in: &cancellables)

numPublisher.send(1)
numPublisher.send(2)
numPublisher.send(3)
numPublisher.send(-1)
numPublisher.send(4)
numPublisher.send(-2)
numPublisher.send(5)

// (出力)
// oddPublisher: 1
// evenPublisher: 2
// oddPublisher: 3
// error: minusError
// evenPublisher: 4
// error: minusError
// oddPublisher: 5

↑
error で止まらなくなりました🌟

とりあえず、これでやりたいことはできるようになりました。

案3: Output を Result 型にする

案2 でやりたいことはできるようになったのですが、エラーハンドリングを flatMap() 内で行っているため、全体的な処理の見通しが悪いという欠点があります。

正直、サンプルコードぐらい単純な処理であれば問題ないのですが、調子に乗っていると 1 つの Publisher が大きくなりすぎて、後から修正がかけづらくなるということが発生してしまいます。

そこで、 flatMap() を使わずに、以下のように Result 型 を用いることで、見通しをよくする方法を考えました。

// <Result<Int, Error>, Never> に変更
let zeroCheckedPublisher: AnyPublisher<Result<Int, Error>, Never>
zeroCheckedPublisher = numPublisher
    .map {
        guard $0 >= 0 else {
            return .failure(TestError.minusError)
        }
        return .success($0)
    }
    .share()
    .eraseToAnyPublisher()

// Result<Int, Error> から Int のみを抽出
let outputPublisher: AnyPublisher<Int, Never>
outputPublisher = zeroCheckedPublisher
    .compactMap {
        if case let .success(output) = $0 {
            return output
        }
        return nil
    }
    .share()
    .eraseToAnyPublisher()

// Result<Int, Error> から Error のみを抽出
let failurePublisher: AnyPublisher<Error, Never>
failurePublisher = zeroCheckedPublisher
    .compactMap {
        if case let .failure(error) = $0 {
           return error
        }
        return nil
    }
    .eraseToAnyPublisher()

let oddPublisher: AnyPublisher<Int, Never>
oddPublisher = outputPublisher
    .filter { $0 % 2 != 0 }
    .eraseToAnyPublisher()

let evenPublisher: AnyPublisher<Int, Never>
evenPublisher = outputPublisher
    .filter { $0 % 2 == 0 }
    .eraseToAnyPublisher()

evenPublisher
    .sink { print("evenPublisher: \($0)") }
    .store(in: &cancellables)

oddPublisher
    .sink { print("oddPublisher: \($0)") }
    .store(in: &cancellables)

failurePublisher
    .sink { print("failurePublisher: \($0)") }
    .store(in: &cancellables)

numPublisher.send(1)
numPublisher.send(2)
numPublisher.send(3)
numPublisher.send(-1)
numPublisher.send(4)
numPublisher.send(-2)
numPublisher.send(5)

// (出力)
// oddPublisher: 1
// evenPublisher: 2
// oddPublisher: 3
// failurePublisher: minusError
// evenPublisher: 4
// failurePublisher: minusError
// oddPublisher: 5

ちょっと記述が冗長に感じられますが、Publisher のボリューム次第で、案 2 よりも案 3 のほうが見通しが良い場合もあるかと思います。

結論

  • tryMap() -> catch -> Empty の合わせ技で、エラーを握りつぶしたストリームをつくることができる
  • しかし、Failure の可能性がある Publisher を subscribe している状態で、一度でも Failure が発生すると、その時点で Completion となり、Output を受け付けなくなる
  • 上記の回避策として、flatMap()で subscribe することなく tryMap() -> catch -> Empty の合わせ技をかけることによって、エラーがあっても止まらないストリームを作り出せる
  • ただし、flatMap() 内でのエラーハンドリングは見通しが悪いため、Publisher の Output を Result 型にする技を使うと、見通しよくエラーがあっても止まらないストリームを作り出せる

以上になります。

参考

書いている途中でほぼ同じ内容の記事に巡り合いました。

GitHubで編集を提案

Discussion