【Swift】Combine + APIKit で複数リクエストを直列で実行するサンプル (ついでに並列も)

![title.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6691/08dc09e3-ff8d-d19f-f324-61a0d28869e1.png)

Combine.frameworkを体験する取っ掛かりとして
APIKitのRequestにPublisherを生やして
複数の非同期通信を直列・並列で叩くサンプルを実装してみました。

まだまだCombineニュービーですのでオススメの方法や
間違い等有りましたらご指摘頂けると幸いです🙇🏻‍♂️

## 環境
* Xcode 12.1
* APIKit 5.1.0
* SwiftUI 

## サンプルコード
https://github.com/SatoshiN303/RequestsWithCombine

## プロジェクト構成
APIKitにPublisherを生やす実装方法については
Developers.IO さんの以下の記事が
非常にわかりやすかったのでほぼコピさせていただきました。
[[iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ](https://dev.classmethod.jp/articles/ios13-swiftui-combine-incremental-search/)

今回のサンプルコードでは複数の通信を叩くために
GitHub REST APIを使う形に変更しています。

![スクリーンショット 2021-01-21 16.00.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6691/2e8cca5e-4431-4040-1f18-43227a628d8f.png)

## 処理の流れ

主な登場人物は上記赤枠の3つです。
処理の流れは以下になります。

1. `ContentView`の`UISearchBar`の入力を受け取る
2. `SampleModel`でリクエストを作成して直列の通信を開始する
3. 最初に`/search/repositories`を叩く
4. 上記のレスポンスを利用して`/repos/{owner}/{repositry}`を叩く
5. 通知された結果を表示する

## 直列で叩く(Publisher)
最初に`/search/repositories` を叩き、レスポンスを利用して 
`/repos/{owner}/{repositry}` を叩いてみます。
正直最初のリクエストで欲しいデータは取得できますが学びということで。

```swift
        // `/search/repositories` のリクエスト作成
        let searchRepositories = SearchRepositoriesRequest(query: searchText).publisher.eraseToAnyPublisher()
        self.requestCancellable = searchRepositories
            // レスポンスはSearchRepositoriesResponse型なのでitems.firstをとりあえず取得
            .compactMap { $0.items.first }
            // 上記レスポンスを利用して GetRepositoyRequest を作成
            .flatMap { (repository) -> AnyPublisher<GetRepositoyRequest.Response, Error> in
                /* 処理を繋げるとコンパイラが型がネストして解釈して複雑な型になるため
                 * eraseToAnyPublisher()で型消去した
                 * AnyPublisher<GetRepositoyRequest.Response, Error> を返す
                 */ 
                GetRepositoyRequest(owner: repository.owner.login, repositry: repository.name).publisher.eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { (completion) in
                switch completion {
                case .finished:
                    debugPrint("request finished")
                case let .failure(error):
                    debugPrint("request failed : \(error)")
                }
            } receiveValue: { [weak self] repository in
                // `/repos/{owner}/{repositry}` のレスポンスをContentViewに渡す
                self?.items = [repository]
            }
   
```

無事直列で叩けてる模様です。
![スクリーンショット 2021-01-21 17.18.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6691/1cb80514-9474-8530-cb4e-a4f2f871b37b.png)


### 補足リンク eraseToAnyPublisher()での型消去について
https://developer.apple.com/documentation/combine/publisher/3241548-erasetoanypublisher

## Futureを使って直列で叩く
FutureはSubscribeされなくても
インスタンスが生成されたタイミングで即時実行されるので
とりあえずクロージャーで返すメソッドを作って処理を繋げてみました。
こちらも本来であれば再利用のためにAPIKitに生やすのが望ましいでしょうか。

```swift
        // MARK: - Futureを使って直列で叩く

        self.requestCancellable = fetchRepos(query: searchText)
            .compactMap { $0.items.first }
            .flatMap { [unowned self] repository in
                self.fetchRepository(owner: repository.owner.login, repo: repository.name)
            }.sink { (completion) in
                switch completion {
                case .finished:
                    debugPrint("request finished")
                case let .failure(error):
                    debugPrint("request failed : \(error)")
                }
            } receiveValue: { [weak self] repository in
                self?.items = [repository]
            }


    // MARK: - 各リクエストの生成処理

    // ※Futureは即時実行される => インスタンスが生成されたタイミングでSubscribeをしなくても処理が走る
    private func fetchRepos(query: String) -> Future<SearchRepositoriesRequest.Response, Error> {
        return Future<SearchRepositoriesRequest.Response, Error> { promise in
            Session.send(SearchRepositoriesRequest(query: query), callbackQueue: .main) { (result) in
                switch result {
                case .success(let res):
                    // 処理成功
                    promise(.success(res))
                case .failure(let error):
                    // 処理失敗
                    promise(.failure(error))
                }
            }
        }
    }
    
    // ※Futureは即時実行される => インスタンスが生成されたタイミングでSubscribeをしなくても処理が走る
    private func fetchRepository(owner: String, repo: String) -> Future<GetRepositoyRequest.Response, Error> {
        return Future<GetRepositoyRequest.Response, Error> { promise in
            Session.send(GetRepositoyRequest(owner: owner, repositry: repo), callbackQueue: .main) { (result) in
                switch result {
                case .success(let res):
                    // 処理成功
                    promise(.success(res))
                case .failure(let error):
                    // 処理失敗
                    promise(.failure(error))
                }
            }
        }
    }
```

こちらも無事に直列で叩けてる模様です。
![スクリーンショット 2021-01-21 17.19.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6691/dd0fe798-259e-8121-f37f-d54966b96c88.png)


## ついでに並列でも叩いてみる

Publishers.Zipに渡せば並列で叩けるので
`/search/repositories` と `/search/users`を同じクエリで叩いてみます。

```swift
        let searchRepositories = SearchRepositoriesRequest(query: searchText).publisher.eraseToAnyPublisher()
        let searchUsers = SearchUsersRequest(query: searchText).publisher.eraseToAnyPublisher()

        self.requestCancellable = Publishers.Zip(searchRepositories, searchUsers)
            .receive(on: DispatchQueue.main)
            .sink { _ in } receiveValue: { (repos, users) in
                print(repos)
                print(users)
            }

```

無事叩けてました。
![スクリーンショット 2021-01-21 17.21.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6691/a3f6431f-9c47-59e7-6f26-29d1422ac21b.png)


## ついでに補足比較: RxSwiftで直列で叩く場合

RxSwiftの場合はSessionにObservableを生やしてflatMapで繋ぐ感じでしょうか。

```swift
extension APIKit.Session {
    func rx_sendRequest<T: Request>(request: T) -> Observable<T.Response> {
        return Observable.create { observer in
            let task = self.send(request) { result in
                switch result {
                case let .success(res):
                    observer.on(.next(res))
                    observer.on(.completed)
                case let .failure(err):
                    observer.onError(err)
                }
            }
            return Disposables.create {
                task?.cancel()
            }
        }
    }
}

   // Observableで包んで返す

    func searchRepositories(request: SearchRepositoriesRequest) -> Observable<SearchRepositoriesRequest.Response> {
        return Session.rx_sendRequest(request: request)
    }

    func getRepository(request: GetRepositoryRequest) -> Observable<GetRepositoryRequest.Response> {
        return Session.rx_sendRequest(request: request)
    }

    // もろもろ省略

    let observable = searchRepositories(request: searchRepositoriesRequest)
            .flatMap { [unowned self] repos -> Observable<GetRepositoryRequest.Response> in
                let item = repos.items.first //とりあえず成功してアンラップできてるものとみなす
                let getRepositoryRequest = getRepositoryRequest(owner: item.owner.login, repo: item.name)
                return self.getRepository(request: getRepositoryRequest)
            }.もろもろ続く
```

## 参考にさせて頂きました。ありがとうございます。

* [[iOS 13] SwiftUI + Combine + APIKitでインクリメンタルサーチ](https://dev.classmethod.jp/articles/ios13-swiftui-combine-incremental-search/)
* [[iOS]APIKit + Combine + SwiftUIでGitHubリポジトリ検索](https://qiita.com/usk2000/items/1da56e4082822605fca3)
* [【Swift】Combineで一つのPublisherの出力結果を共有するメソッドやクラスの違い(share, multicast, Future)](https://qiita.com/shiz/items/f089c93bdebfaef2196f)
* [Combine で非同期処理を直列で実行する(flatMap)](https://qiita.com/motoshima1150/items/7c38ec0d2804b1e31fc0)
* [Modern Networking in Swift 5 with URLSession, Combine and Codable](https://www.vadimbulavin.com/modern-networking-in-swift-5-with-urlsession-combine-framework-and-codable/)
* [Creating a search bar for SwiftUI](https://axelhodler.medium.com/creating-a-search-bar-for-swiftui-e216fe8c8c7f)