はじめに
1つのアクションに対して、サーバからのデータ取得、ログ送信、AsyncSequenceの購読など、複数の非同期処理を実行しなければならないケースがあります。
これらの非同期処理を順列に実行していたのではユーザ体験が悪くなってしまうため並列実行させるのが基本ですが、非同期処理を並列実行する実装は条件分岐などが入ってくると可読性が低くなったり冗長なコードになってしまいがちです。
本記事では、複数の非同期処理を並列実行する3つの実装パターンについて書いています。
おそらくどれが良い悪いということではなく、ケースバイケース、あるいは好みの問題で使い分けることになるのかなと思っています。
本記事が可読性の高いReducerのコードを書く助けになれば嬉しいです。
もっと良い方法がある、あるいは記事の内容についてご指摘等あれば是非教えてください!
async let
例えば2つのログを並列に送信するケースをasync letで実装するとこうなります。
case .buttonTapped:
return .run { _ in
async let log1: Void = analyticsClient.sendLog("log1")
async let log2: Void = analyticsClient.sendLog("log2")
_ = await (log1, log2)
}
ログ送信と、サーバからデータを取得して追加のアクションを送信するケースだとこうなります。
userClientを呼び出している部分を先に書いてしまうと、サーバからのレスポンスを待ってからログ送信することになるので並列実行にならないので注意です。
case .buttonTapped:
return .run { send in
async let log1: Void = analyticsClient.sendLog("log1")
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
_ = await log1
}
Effect.merge
async letと同じケースを、mergeで実装するとこうなります。
mergeは引数に渡された複数のEffectを並列に実行します。
case .buttonTapped:
return .merge(
.run { _ in
await analyticsClient.sendLog("log1")
},
.run { _ in
await analyticsClient.sendLog("log2")
}
)
async letと比べると、2つの異なる処理がそれぞれ独立したブロックに分かれるので可読性は若干高いですね。
case .buttonTapped:
return .merge(
.run { _ in
await analyticsClient.sendLog("log1")
},
.run { send in
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
}
)
withTaskGroup (withThrowingTaskGroup)
同様のケースをwithTaskGroupで実装するとこうなります。
ログ送信だけのケースだと、async letよりは若干コード量が多くなりますね。
可読性は特に損なうことは無い気がします。
case .buttonTapped:
return .run { send in
await withTaskGroup(of: Void.self) { group in
group.addTask {
await analyticsClient.sendLog("log1")
}
group.addTask {
await analyticsClient.sendLog("log1")
}
}
}
ログ送信とサーバからのデータ取得処理があるケースだと、それぞれを独立したブロックに実装できるので、async letを使うパターンよりも可読性が高いですね。
case .buttonTapped:
return .run { send in
await withTaskGroup(of: Void.self) { group in
group.addTask {
await analyticsClient.sendLog("log1")
}
group.addTask {
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
}
}
}
ロジックが複雑なケースではwithTaskGroupが有効
シンプルな例を見てきましたが、条件分岐などを含む複雑なロジックのケースでは、withTaskGroupを選択するのが良いです。
例えば、ログは常に送信するが、サーバからのデータ取得処理はある条件を満たしたときだけ実行する、というケースを考えます。
これをasync letで実装するとこうなります。
条件関係なくログは常に送信する必要があるため、2箇所に同じようなログ送信の実装を書く必要があります。
case .buttonTapped:
return .run { send in
guard state.shouldFetchUser else {
await analyticsClient.sendLog("log1")
return
}
async let log1: Void = analyticsClient.sendLog("log1")
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
_ = await log1
}
withTaskGroupで実装するとどうなるでしょうか。
ログ送信処理は独立したタスクとなり、常に実行されます。
サーバからのデータ取得処理は、「条件を満たすときだけ実行する」という仕様が一目見てわかるようになりました。
case .buttonTapped:
return .run { send in
await withTaskGroup(of: Void.self) { group in
group.addTask {
await analyticsClient.sendLog("log1")
}
group.addTask {
guard state.shouldFetchUser else { return }
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
}
}
}
さらにAsyncSequenceの購読処理なども入ってくると、もはやasync letでは実装が不可能です。
withTaskGroupを使えば、このような複雑なロジックでも可読性高く実装することができます。
case .buttonTapped:
Task.cancel(id: CancelID.listen)
return .run { send in
await withTaskGroup(of: Void.self) { group in
group.addTask {
await analyticsClient.sendLog("log1")
}
group.addTask {
await withTaskCancellation(id: CancelID.listen, cancelInFlight: true) {
for await value in numberClient.listen() {
await send(.numberResponse(value))
}
}
}
group.addTask {
guard state.shouldFetchUser else { return }
await send(.fetchUserResponse(TaskResult {
try await userClient.fetch(userId)
}
}
}
}
おわりに
シンプルなケースではasync letでも十分でかつコード量もwithTaskGroupより若干少なくて見た目がスッキリします。
しかし、ちょっとした分岐が入ったりするだけで冗長なコードになりやすいので、その場合はwithTaskGroup(withThrowingTaskGroup)を積極的に使うのが良いのではないかと思います。
isowordsでwithThrowingTaskGroupで検索すると、参考になる実装がいくつか見つかりますので、そちらも是非参照してみてください。