[Swift] 複雑な非同期処理が作り出すコールバック地獄とネスト地獄を解消したい! セマフォを使ったシンプルなリファクト方法を試してみた

Promissを使わなくても非同期処理を同期処理っぽくできますし、実装もシンプルになりますよ。
2021.07.16

CX事業本部モバイル事業部の中安です。まいどです。

非同期処理のツラみ = コールバック地獄&ネスト地獄

アプリの開発では、いくつもの複雑なタスクを順番にこなしていかなければならない場面があると思います。

たとえば

  1. APIを叩いて画像ダウンロード用のURLを取得する
  2. 取得した画像ダウンロード用URLを叩いて実際に画像をダウンロードする
  3. ダウンロードした画像を加工する
  4. APIを叩いて画像アップロード用のURLを取得する
  5. 加工した画像を取得したアップロード用のURLに対してアップロードする

といった手順を踏まなければならないとします。

APIのキックや、ファイルのアップロード・ダウンロード、画像の加工などは時間がかかる処理になることが多いため、 こういったケースはUI上ではプログレスバーやインジケータなどを表示して、裏で実行させる流れになると思います。

さて、その場合によく起こる問題は「コールバック地獄」「ネスト地獄」というものです。

上記の手順を踏んでいくために、たとえば下記のようなソースコードに落とし込まれることがよくあるのではないでしょうか。

// APIを叩いて画像ダウンロード用のURLを取得する
self.requestImageDownloadUrl() { url, errorOrNil in
    if let error = errorOrNil {
        // エラー処理
        return
    }
    // 取得した画像ダウンロード用URLを叩いて実際に画像をダウンロードする
    self.downloadImage(url: url) { image, errorOrNil in
        if let error = errorOrNil {
            // エラー処理
            return
        }
        // ダウンロードした画像を加工する
        self.processImage(image) { processedImage, errorOrNil in
            if let error = errorOrNil {
                // エラー処理
                return
            }
            // APIを叩いて画像アップロード用のURLを取得する
            self.requestImageUploadUrl() { url, errorOrNil in
                if let error = errorOrNil {
                    // エラー処理
                    return
                }
                // 加工した画像を取得したアップロード用のURLに対してアップロードする
                self.uploadImage(image: processedImage, to url: url) { errorOrNil in
                    if let error = errorOrNil {
                        // エラー処理
                        return
                    }
                    
                    // すべて成功したときの処理
                }
            }
        }
    }
}

いかがでしょうか。

このようにまともに書いてしまうと、ネストがどんどん深くなっていきます。

こうなってしまうと、途中で処理の順番を変えたり、新しい処理を途中に差し込もうとするときに面倒なことになりますし、非常にメンテナンス性がよくないですよね。

この問題のネックになっているのは「各処理が非同期処理になっていること」であり、「完了時の処理をブロック内に書かなくてはいけない」ことに起因するのだと思います。

これが非同期な処理ではなく同期的処理であれば、ソースコードの見通しがもっと良くなるはずです。

解決策のひとつとしては、PromissKitをはじめとした「非同期処理を簡易化するライブラリ」を使用することです。 しかし、今回はそういったライブラリは使用せず、標準に用意されてるAPIで同じようなことをするのが主なテーマとなります。

セマフォを使用する

今回ご紹介するのは セマフォ (semaphore)を使用する例になります。

セマフォという英単語は「手旗信号」という意味らしいですが、コンピュータの世界では「複数のプログラムを並行して処理するときにメッセージ制御や割り込み処理をするための仕組み」とされています。 (詳しくは wikipedia を参照ください)

排他制御に関わる仕組みではありますが、これを使用して非同期な処理を同期処理として扱うことができます。

使用するのは DispatchSemaphore というクラスです。 このクラスのインスタンスには内部でvalueという数値をカウントする仕組みがあって、シンプルに言うと「valueが0になるまでは処理を止める」ということができるようになります。

今回は話を簡潔にするために、セマフォの2つのメソッドに着目します。

wait

名前の通り「処理を待つ」ために呼び出すメソッドになりますが、 頭の中ではvalueがデクリメントされる(1引かれる)と認識しておきましょう。

signal

シグナルは「待ってるところに"進めの合図"を送る」というイメージのメソッドになります。 頭の中ではvalueがインクリメントされる(1足される)と認識しておきましょう。

時系列の整理

あらためて、どういう流れで実際に非同期処理と同期処理が行われるのかを時系列で整理をしてみます。

非同期処理

  1. メソッドを呼び出す
  2. 非同期処理が始まる
  3. メソッドの実行から抜ける
  4. 非同期処理が終わったときに ブロック内が実行される

同期処理

  1. メソッドを呼び出す
  2. 同期処理が始まる
  3. メソッドの実行から抜ける

比較すると、同期処理はメソッドが呼び出されて実行後に抜けるため、コールバックを渡してやる必要もなく、戻り値も返すことができます。

セマフォを使用した非同期処理

次に、セマフォを使用することで非同期処理がどのような流れになるかを書いてみます。

  1. メソッドを呼び出す
  2. DispatchSemaphoreインスタンスを value=0 で作成する。
  3. この時点でvalue=0なので処理が止まらない。
  4. 非同期処理が始まったときに waitを呼んで value=-1 にする。
  5. value=-1なので処理が止まる
  6. 非同期処理が終わったときに signalを呼んで value=0 にする。
  7. value=0なのでwaitで止まってた場所から処理が始まる。
  8. メソッドの実行から抜ける

このように「メソッドが呼び出されて実行後に抜ける」という同期処理と同じ流れになるわけです。

ソースコードに落とし込む

ここまでは概念的な話になりましたが、ソースコードに落とし込んだ例を書いていきます。

ここでは冒頭の例のうちの画像ダウンロードをする例をあげます。 仮定としてFirebaseStorageから画像ファイルをダウンロードするものとします。

普通に非同期処理として書いてみたパターン

比較対象があったほうがいいので、まずは冒頭の「コールバック地獄」「ネスト地獄」に陥ってしまうであろう非同期処理の書き方をしてみます。

// URLを指定して画像をダウンロードする
func downloadImage(url: URL, completion: (UIImage?, Error?) -> ()) {
    let ref = Storage.storage().reference(withPath: "path/to/image.jpg")
    let task = ref.write(toFile: URL(fileURLWithPath: "path/to/image.jpg"))
    
    // ブログレス処理
    task.observe(.progress) { _ in
        // ブログレス処理(割愛)
    }
    // 失敗時
    task.observe(.failure) { snapshot in
        task.cancel()
        completion(nil, snapshot.error)
    }
    // 成功時
    task.observe(.success) { _ in
        let image: UIImage = // ダウンロードした画像を撮ってくる処理は割愛
        completion(image, nil)
    }
}

当然task.observe内のブロックは、このメソッドが抜けた後に適宜なタイミングで呼び出されることになります。 ごれは前項の非同期処理の時系列に当てはまることが分かると思います。

そのため、このメソッドはコールバック (ここではcompletion)を受け取らなければならないですし、戻り値を設定することはできません。 戻り値の代わりにコールバックに値を渡してやる必要があります。

DispatchSemaphoreを使うパターン

続いては、この記事のテーマであるDispatchSemaphoreを使って、非同期処理を同期処理のように扱うための書き方です。

func downloadImage(url: URL) -> UIImage? {
    let semaphore = DispatchSemaphore(value: 0)
    var imageOrNil: UIImage?
    
    let ref = Storage.storage().reference(withPath: "path/to/image.jpg")
    let task = ref.write(toFile: URL(fileURLWithPath: "path/to/image.jpg"))
    
    task.observe(.progress) { _ in
        // ブログレス処理(割愛)
    }
    task.observe(.failure) { snapshot in
        task.cancel()
        semaphore.signal()
    }
    task.observe(.success) { _ in
        imageOrNil = // ダウンロードした画像を撮ってくる処理は割愛
        semaphore.signal()
    }
    
    semaphore.wait()
    return imageOrNil
}

ハイライトした部分がDispatchSemaphoreの仕組みを使った記述です。 流れは先程の「時系列の整理」の「セマフォを使用した非同期処理」を確認しながら読んでいただければと思います。

まず、semaphore変数に value=0DispatchSemaphoreインスタンスを代入しています。

メソッドを抜ける前にwaitを呼び出しています。 そうすると、このメソッドは20行目で止まって抜けることがありません。

しかし、非同期な処理自体は止まっているわけではなくtask.observeブロック内は適宜呼び出されることになります。 そして、ブロック内でsignalが呼ばれることでwaitされている場所に「進め信号」を送ることになります。

そこで初めて21行目まで進んで、このメソッドから抜けることになるというわけです。

UIImage?型の変数をtask.observeブロック内で代入した上で、メソッドの戻り値として返すことができるというのが大きなポイントではないかと思います。

使う側の書き方

こうして、コールバック地獄だった冒頭のサンプルコードは、コールバック自体がなくなったことで解消されることになります。 戻り値が返ってくるので以下のように書けるようになったからです。

if let downloadUrl = self.requestImageDownloadUrl() {
    if let downloadedImage = self.downloadImage(url: downloadUrl) {
        if let processedImage = self.processImage(downloadedImage) {
            if let uploadUrl = self.requestImageUploadUrl() {
                self.uploadImage(image: processedImage, to url: uploadUrl)
            }
        }
    }
}

しかし、このソースコードは問題が解決されたわけではないですね。 if let文により「ネスト地獄」であることには変わりません。 これはguard文を使うことによって解決することはできますが、 エラーハンドリングがうまくできていないことがお分かりだと思います。 (途中でエラーが起きても何が起きたのか捕捉できないため)

エラーを意識した書き方

ここまでで長たらしい書き方になりましたが、つまりは非同期処理は「成功時」と「失敗時」があることが多いので、 エラーを意識してメソッドの設計をしたほうがよいということです。 同時にネスト地獄からの解消も視野に入れたいところです。

そのためにはthrowをうまく使って、使う側でエラーハンドリングができるようにしてあげましょう。

func downloadImage(url: URL) throws -> UIImage  {
    let semaphore = DispatchSemaphore(value: 0)
    var errorOrNil: Error?
    var image: UIImage!
    
    let ref = Storage.storage().reference(withPath: "path/to/image.jpg")
    let task = ref.write(toFile: URL(fileURLWithPath: "path/to/image.jpg"))
    
    task.observe(.progress) { _ in
        // ブログレス処理(割愛)
    }
    task.observe(.failure) { snapshot in
        task.cancel()
        errorOrNil = snapshot.error
        semaphore.signal()
    }
    task.observe(.success) { _ in
        image = // ダウンロードした画像を撮ってくる処理は割愛
        semaphore.signal()
    }
    
    semaphore.wait()
    if let error = errorOrNil {
        throw error
    }
    return image
}

処理中にエラーが発生した場合は、そのエラーを変数に代入しておき、最後にthrowするようにしています。

では、使う側はどのように変化するかというと

do {
    let downloadUrl = try self.requestImageDownloadUrl()
    let downloadedImage = try self.downloadImage(url: downloadUrl)
    let processedImage = try self.processImage(downloadedImage)
    let uploadUrl = try self.requestImageUploadUrl()
    try self.uploadImage(image: processedImage, to url: uploadUrl)
} catch {
    // エラー処理
}

すべてをdo-try-catch文に変更することができ、コールバックもネストもすべてなくすことができました。

コールバック地獄とネスト地獄だった数十行のソースコードは、このように主機能だけでいうと5行だけに収まることになりました。 こうなると変更に強い高メンテナンス性のソースコードになると思いませんか。

同期処理であるがゆえの注意

同期処理によって処理が止まるということは、メインスレッドでこれを実行してしまうと描画に影響が出てしまいます。 ですので、一連の流れは別スレッドで実行して最後にメインスレッドに戻してあげないといけません。

// 実装例です
indicator.show() // インジケータ出す
DispatchQueue.global().async {
    do {
        let downloadUrl = try self.requestImageDownloadUrl()
        let downloadedImage = try self.downloadImage(url: downloadUrl)
        let processedImage = try self.processImage(downloadedImage)
        let uploadUrl = try self.requestImageUploadUrl()
        try self.uploadImage(image: processedImage, to url: uploadUrl)
    } catch {
        // エラー処理
    }
    DispatchQueue.main.async {
        indicator.hide() // インジケータ隠す
    }
}

最後に

メソッドの設計では、ひとつのメソッドに処理を詰め込まないようにして、メソッド名を「シンプルな処理の名前」にすることで「何がどの順番に行われるのか」が読みやすくなると思います。

また、signalwaitは実際の中身は値カウンタによる管理になりますので、これもひとつのメソッドに何個も置くのはバグの温床になると思いますので、注意してください。

たくさんのサービスやAPI、重い処理をシーケンシャルに実行していく場合に、 どうしてもソースコードがスパゲッティ化していってしまうというときには、 この記事で紹介した方法を試してみてはいかがでしょうか。

どなたかのお役に立てば幸いです。

では、また。