0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

伝えたいこと

  • 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 型にする技を使うと、見通しよくエラーがあっても止まらないストリームを作り出せる

以上になります。

参考

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

0
0
0

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?