LoginSignup
44
17

More than 1 year has passed since last update.

Swiftでビジネスロジックを実行するUseCaseのprotocolを作りたい話 2019

Last updated at Posted at 2019-12-02

この記事はSwift 5.7以前に書かれたものです。そのため下記のprotocolは型消去せずとも実装可能となっています。

最初にこの文章の結論

この文章は、ロジックを処理する次のような型をアプリケーションの中で定義してみたらどうかな、と考えているのを文章にしてみたものです。

protocol UseCase {
    associatedtype Parameters
    associatedtype Success

    func execute(
      _ parameters: Parameters, 
      completion: ((Result<Success, Error>) -> ())?
    )
  
    func cancel()
}

なぜこんな事を考えているかというと、iOS VIPERアーキテクチャ研究読本(仮)という電子書籍を作ってみたいなと考えていて、まずはサンプルコードを作ろうとしているためです。

VIPER研究読本用サンプルコードのリポジトリ
https://github.com/yimajo/VIPERBook1Samples

UseCaseとは?

この記事でのUseCaseの前提として、

  • システムを水平レイヤに分割したときのビジネスロジックを実装するもの
  • 単一の目的を持ったコンポーネント

としてます。

この前提は、書籍「Clean Architecture 達人に学ぶソフトウェアの構造と設計」での"ユースケース"から参考にしました。

注文入力システムに注文を追加するユースケースは、注文を削除するユースケースと比べると、明らかに異なる頻度と理由で変更される。ユースケースはシステムを分割する自然な方法である。また、ユースケースは、システムの水平レイヤーを薄く垂直にスライスしたものである。それぞれのユースケースは、UIの一部、アプリケーション特有のビジネスルールの一部、アプリケーションに依存しないビジネスルールの一部、データベース機能の一部を使用する。したがって、システムを水平レイヤーに分割するときには、それらを薄く垂直にユースケースとしても分割するのである。

ここまでで述べているのは、まず2つのユースケースがあるということ、そしてその分割方向についてです。

そして同書籍の中でユースケースの単位について「注文追加」「注文削除」を少し掘り下げています。それを読むと注文追加がaddOrderで注文削除がdeleteOrderとのこと

ユースケースがお互いに切り離されていれば、addOrder(注文追加)のユースケースにフォーカスしたチームがdeleteOrder(注文削除)のユースケースにフォーカスしたチームの邪魔をする可能性は低い。

一応書いておくんですが、ユースケースは2つあるとは書いてあるもののそれらは2つのclassやstructとは書いてないんですね。むしろaddOrderdeleteOrderというのがメソッドにすら見える。そうなると「1つのclass/structに2つのメソッドがある」のかそれとも「2つのclass/structにそれぞれ1つのメソッドがある」のかということが気になってくるのですが、今回の話では後者の「2つのclass/structにそれぞれ1つのメソッドがある」ということでやっていきます。

具体例

同書籍に書いてある内容をざっくりコードに落とすと、だいたい次のようなことだと思います

struct AddOrderUseCase {
  func execute(_ order: Order)
}

struct DeleteOrder {
  func execute(_ orderID: OrderID)
}

ここからテスト自動化できるようにしていくことや、その他のことを雑に考えるとprotocolを作ったりしたくなるというわけですよね。

// AddOrder
protocol AddOrderUseCase {
  func execute(_ order: Order)
}

struct AddOrderDefaultUseCase: AddOrderUseCase {
  func execute(_ order: Order)
}

struct テスト用AddOrderUseCase: AddOrderUseCase {
  func execute(_ order: Order)
}

// DeleteOrder

protocol DeleteOrderUseCase {
  func execute(_ orderID: OrderID)
}

struct DeleteOrderDefaultUseCase: DeleteOrderUseCase {
  func execute(_ orderID: OrderID)
}

struct テスト用OrderDefaultUseCase: DeleteOrderUseCase {
  func execute(_ orderID: OrderID)
}

で、こういうことをやっていくともっと良いアプローチが無いかなと思うじゃないですか。...というのが発端です。

他の言語/プラットフォームではどうやってる?

Ruby on Railsでの例

まずは全然違う例としてサーバサイドを取り上げます。Ruby on RailsでオレオレUseCaseを考えている人たちも勿論いるので興味深いわけです。

A Case For UseCase
https://webuild.envato.com/blog/a-case-for-use-cases/

上の記事でやりたいことを勝手に要約すると

  • 写真を買うというユースケースがある
    • class PurchasePhotoの内容
      • 購入時の細かい動作としては次の動作がある
        • Purchaseテーブルにinsert
        • 請求書の送付
        • カウンタのインクリメント
        • レビューの権利を付与する
  • class PurchasePhotoの作り方として
    • 抽象化したUseCaseというmoduleを作る
    • PurchasePhotoはmoduleをimportする

コードとしては次のようにstaticにアクセスして上記3つの動作をさせたいわけです。

PurchasePhoto.perform(buyer, photo) 

写真購入のほかのパターンは書かれてませんが、
必要な情報はパラメータとしてメソッド実行時に揃ってますし、良さそうに見える。

ただもともとデータベースに結果を入れていることもあり、テストコードはそのデータベースの値を見れば分かるようになっています。

しかし我々iOSアプリ開発するときはそういうことばっかりじゃないわけです。大抵は通信してJSONに色を付けないといけなかったりしますんで、その通信の結果を見るという作りからさらに改良してDBに保存してテストできるようにするというのは手間に感じます。

さらに非同期処理に対応しないといけない。というわけでこっからモバイルアプリのAndroidでのやり方を参考にしていきます。

Android Blue Print - todo-mvp-clean の場合

Android Blue Printを見てみます。

TODOアプリを様々な作り方でブランチごとに分けていて、MVPでClean Architectureっぽく作られてまして、そこでUseCaseはabstract classになってるわけです。

public abstract class UseCase<Q extends UseCase.RequestValues, P extends UseCase.ResponseValue>

abstract class UseCase

もう少し細かい特徴は

  • RequestとResponseの型を渡す
  • 外からはexecuteUseCase メソッドで呼び出される
    • mUseCaseCallbackに成功か失敗を送る

GetTaskのUseCaseを例にとった利用シーンとしては次のような感じ

mUseCaseHandler.execute(mGetTask, new GetTask.RequestValues(mTaskId),
                new UseCase.UseCaseCallback<GetTask.ResponseValue>() {
                    @Override
                    public void onSuccess(GetTask.ResponseValue response) {
                        showTask(response.getTask());
                    }

                    @Override
                    public void onError() {
                        showEmptyTaskError();
                    }
                });

外部からはコールバックのハンドラーを登録して結果はそれが動作するイメージ。

同じパターンであることを示すためにSaveTaskも抜粋すると次のように共通点から、差分を見て具体的に何がやりたいかが分かるはずです。

mUseCaseHandler.execute(mSaveTask, new SaveTask.RequestValues(newTask),
        new UseCase.UseCaseCallback<SaveTask.ResponseValue>() {
            @Override
            public void onSuccess(SaveTask.ResponseValue response) {
                mAddTaskView.showTasksList();
            }

            @Override
            public void onError() {
                showSaveError();
            }
        });

google/iosched の場合

同じAndroidで参考にしたいのは、Google IOアプリのコードgoogle/ioschedです。そのGitHubリポジトリのREADMEでは「データレイヤーとプレゼンテーションレイヤーの間に、lightweight domain layer実装した。UIスレッドとは別にビジネスロジック処理する」と書いていて、UseCaseもあります。

UseCase
https://github.com/google/iosched/blob/4054aa3f8934b8b1208d5823fdbf531a8eb367af/shared/src/main/java/com/google/samples/apps/iosched/shared/domain/UseCase.kt

UseCaseの実行メソッドは大きく分けて3種類

  • コールバック方式でResultを返す
    • fun invoke(parameters: P, result: MutableLiveData<Result<R>>)
  • ObserverパターンのLiveDataを返す
    • operator fun invoke(parameters: P): LiveData<Result<R>>
  • 即時実行してResultを返す
    • fun executeNow(parameters: P): Result<R>

これは前述のAndroid Blue Print - todo-mvp-clean で結果を全てコールバックで取得していたのと比較すると、「コールバックして返す必要のないものを戻り値としてすぐ取得できる」というメリットを感じます。


最新版ではexecuteNowなどはコルーチンにより置き換えられており、存在しません。


さて、実際の利用例として同期的に取得しているSearchUseCaseは次のような感じです。

まずは簡単なexecuteNowから

val result = useCase.executeNow(parameters = "session 0")

つぎにObserverパターンのLiveDataを返していたほうoperator fun invokeしているとおそらくobserveメソッドで呼び出すっていう感じになるんだと思います。

val resultLiveData = useCase.observe()
useCase.execute(FeedbackParameter(
    "userIdTest",
    TestData.userEvents[0],
    TestData.userEvents[0].id,
    mapOf("q1" to 1)
))
// 結果を取り出して
val result = LiveDataTestUtil.getValue(resultLiveData)
// テストしてる
assertEquals(Result.Success(Unit), result)

でまあ何が言いたいかというと、結果をコールバックで返す処理/結果を戻り値で返す処理それぞれに応じたインタフェースをabstract classとして用意していて、どちらかを実装していればそれが使えるし、実装していない方は使えないようになってるんじゃないでしょうか。

本題: Swift でUseCaseをつくる

これをGitHubのWeb APIを利用したリポジトリ検索のビジネスロジックとしてUseCaseを作ってみようってのが本題です。

一番最初に述べたUseCaseをもう一度書いておきます。

protocol UseCase {
    associatedtype Parameters
    associatedtype Success

    func execute(
      _ parameters: Parameters,
      completion: ((Result<Success, Error>) -> ())?
    )

    func cancel()
}

今回は1つのexecuteメソッドとcancelメソッドのみとし、executeメソッドはクロージャによって結果を取得します。できれば先述のexecuteNowのように単純なインタフェースを増やしたいところですが、OptionalなprotocolメソッドをSwiftで表現できないのが残念なところです。

なお、これまで紹介したUseCaseではcancelすることについて触れていませんでした。話がややこしくなるのでそこを掘り下げたりしないほうが良いかと思っています。RxSwiftなどを使ってObservableを戻り値に返せばそれを使って自動的にキャンセルもできるし、そもそもメソッドもコールバックなしのシンプルなものが用意できますが、一旦それは忘れましょう。

続いてこのUseCaseに準拠した具体的なclassについて考えます。GithubRepoSearchInteractorではWeb APIにアクセスするGithubRepoSearchAPIRequestを利用してその結果をクロージャで取得できるようにしています。

class GithubRepoSearchInteractor: UseCase {

    var request: GithubRepoSearchAPIRequest?

    func execute(_ parameters: String,
                 completion: ((Result<[GithubRepoEntity], Error>) -> ())?) {
        let request = GithubRepoSearchAPIRequest(word: parameters)
        request.perform { result in
            switch result {
            case .success(let response):
                completion?(.success(response.items))
            case .failure(let error):
                completion?(.failure(error))
            }
        }

        self.request = request
    }

    func cancel() {
        request?.cancel()
    }
}

実際に利用するのは次のようになると思えるでしょう。

let githubRepoSearch: UseCase = GithubRepoSearchInteractor()

引数に使うんだったら

func なにかの関数(githubRepoSearch: UseCase) {
   // ... 省略
}

しかしこれはコンパイルできません。

何が問題か

UseCaseのprotocolがassociatedtypeを使っていて、その型が解決していないことで次のようにエラーメッセージが表示されます。

Protocol 'UseCase' can only be used as a generic constraint because it has Self or associated type requirements

これをなんとかしなければいけない。

Type Erasure: 継承 box 方式を使う

というわけでこのprotocolをそのまま使うのではなく、
AnyUseCaseclassの引数にしつつ抽象的な扱いをするようにしたいわけです。

let githubRepoSearch: AnyUseCase<String, [GithubRepoEntity]> = AnyUseCase(GithubRepoSearchInteractor())

参考にさせてもらったのは次の記事の「type erasure: 継承 box 方式」
https://qiita.com/omochimetaru/items/5d26b95eb21e022106f0

実際にやってみると

// 実際の型情報として利用されるAnyUseCse
final class AnyUseCase<Parameters, Success>: UseCase {
    // UseCaseの実体。Parameters, Successの型を合わせること
    private let box: AnyUseCaseBox<Parameters, Success>

    init<T: UseCase>(_ base: T) where T.Parameters == Parameters, T.Success == Success {
        box = UseCaseBox<T>(base)
    }

    func execute(_ parameters: Parameters, completion: ((Result<Success, Error>) -> ())?) {
        box.execute(parameters, completion: completion)
    }
    func cancel() {
        box.cancel()
    }
}

// MARK: - AnyUseCaseさえ知ってればいい情報

private extension AnyUseCase {
    class AnyUseCaseBox<Parameters, Success> {
        func execute(_ parameters: Parameters, completion: ((Result<Success, Error>) -> ())?) {
            fatalError()
        }

        func cancel() {
            fatalError()
        }
    }

    // Parameters, Success を UseCase のそれと合わせるために AnyUseCaseBox を継承する
    final class UseCaseBox<T: UseCase>: AnyUseCaseBox<T.Parameters, T.Success> {
        private let base: T

        init(_ base: T) {
            self.base = base
        }

        override func execute(_ parameters: T.Parameters, completion: ((Result<T.Success, Error>) -> ())?) {
            base.execute(parameters, completion: completion)
        }

        override func cancel() {
            base.cancel()
        }
    }
}

これで解決です。
登場人物それぞれは次の役割となっています

  • AnyUseCse
    • 実際の型情報として外から利用する
    • インタフェースを内部の AnyUseCaseBox に保持して利用する
  • AnyUseCaseBox
    • Parameters, Success を UseCase のそれと合わせるため
  • UseCaseBox
    • 内部に UseCase 自体を保持したい

具体的にこのAnyUseCseを利用するときは次のようになるはずです

func sample(_ githubRepoSearch: AnyUseCase<String, [GithubRepoEntity]>) {
    githubRepoSearch.execute("検索したいワード") { result in
        switch result {
        case .success(let items):
          // ... 省略
        case .failure(let error):
          // ... 省略
        }
    }
}

sample(AnyUseCase(GithubRepoSearchInteractor()))

できました。これで中からは具体的な実装を知らず、外から実体を入れることができます。

デメリット

全てコールバックでデータを取得することになる

同期的に戻り値で取得するようなデータでさえ、クロージャを使いコールバックされることになります。

Errorが起こらない場合はNeverを使えるようにできる方が良い、という改善

UseCaseでエラーが発生しないことが分かりきっている場合がある。
そういう場合はNeverを使いたい。

Neverを使うメリットは?

メリットは2つ

  • インタフェースを見たらそれがエラーを発生しないことがすくわかる
  • switch caseを書く際に成功のcaseのみ書けばいい
func alwaysSucceeds(completion: Result<String, Never>)
alwaysSucceeds { (result) in
    switch result {
    case .success(let string):
        print(string)
    }
}

Neverを使えるようにする

protocol UseCase where Failure: Error {
    associatedtype Parameters
    associatedtype Success
    associatedtype Failure

    func execute(_ parameters: Parameters, completion: ((Result<Success, Failure>) -> ())?)
    func cancel()
}

final class AnyUseCase<Parameters, Success, Failure: Error>: UseCase {
    private let box: AnyUseCaseBox<Parameters, Success, Failure>

    init<T: UseCase>(_ base: T) where T.Parameters == Parameters,
                                      T.Success == Success,
                                      T.Failure == Failure {
        box = UseCaseBox<T>(base)
    }

    func execute(_ parameters: Parameters, completion: ((Result<Success, Failure>) -> ())?) {
        box.execute(parameters, completion: completion)
    }
    func cancel() {
        box.cancel()
    }
}

private extension AnyUseCase {
    class AnyUseCaseBox<Parameters, Success, Failure: Error> {
        func execute(_ parameters: Parameters, completion: ((Result<Success, Failure>) -> ())?) {
            fatalError()
        }

        func cancel() {
            fatalError()
        }
    }

    final class UseCaseBox<T: UseCase>: AnyUseCaseBox<T.Parameters, T.Success, T.Failure> {
        private let base: T

        init(_ base: T) {
            self.base = base
        }

        override func execute(_ parameters: T.Parameters, completion: ((Result<T.Success, T.Failure>) -> ())?) {
            base.execute(parameters, completion: completion)
        }

        override func cancel() {
            base.cancel()
        }
    }
}

最後に

言語やフレームワークにとらわれず、envatoさん(Ruby on Rails)のやり方やAndroidのやり方を見てみました。

しかし、envatoさんやらgoogle/ioschedなどは別にクリーンアーキテクチャのUseCaseだとは言ってないわけで、まあそこは別にこだわらずにUseCaseとしてインタフェースを共通にし、やりたいことを1つのまとまりでくくるって良いやん、ということが達成できれば良いとは思います。

そしてSwiftでのやり方をAndroid mvp-cleanを参考にそのままprotocolは使えないのでtype erasureを使ってやってみましたが、もうちょっと他の良いやり方はないもんかなと常に考えています。

みなさんはどんなUseCaseを作ってますか?

44
17
1

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
44
17