こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 29 回です。Weekly Report については、第 1 回の記事を参照してください。
ゴルディアスの変数
ローカルとリモートの 2 つのストレージ上に FooData
というデータが保存されているとし、それらを同期するための FooSynchronizer
を実装するとします。
想定している状況は以下のとおりです。
- リモートの
FooData
がオリジナルであり、それを使ってローカル のFooData
を更新する。 - リモートとローカルのデータは、それぞれ
FooLocalDao
とFooRemoteClient
から取得できる。 - (問題の簡略化のため、トランザクションなどは考慮しなくて良い。)
FooSynchronizer
の関数 synchronizeWithRemoteEntries
では、FooData
の ID の一覧をローカルとリモートで比較し、その結果に応じて追加、更新、削除を行います。
- 追加: リモートのみに ID が存在する場合に実行 (
id !in localEntryIds && id in remoteEntryIds
がtrue
のときに実行) - 更新: ローカルとリモート両方に ID が存在する場合に実行 (
id in localEntryIds && id in remoteEntryIds
がtrue
のときに実行) - 削除: ローカルのみに ID が存在する場合に実行 (
id in localEntryIds && id !in remoteEntryIds
がtrue
のときに実行)
この仕様を満たすように、FooSynchronizer
が以下の通りに実装されました。
class FooSynchronizer(
private val fooRemoteClient: FooRemoteClient,
private val fooLocalDao: FooLocalDao
) {
fun synchronizeWithRemoteEntries() {
val remoteEntries: List<FooModel> = fooRemoteClient.fetch()
val remoteEntryIds = remoteEntries.map(FooModel::id).toSet()
val localEntries: List<FooModel> = fooLocalDao.getAllEntries()
val localEntryMap = localEntries.associateBy(FooModel::id)
val localEntryIds = localEntryMap.keys
val createdEntryIds = remoteEntryIds.subtract(localEntryIds)
val deletedEntryIds = localEntryIds.subtract(remoteEntryIds)
remoteEntries.forEach { remoteEntry ->
// エントリの追加
if (remoteEntry.id in createdEntryIds) {
... // `remoteEntry` を使って「追加」するロジック
return@forEach
}
// エントリの更新
val localEntry = localEntryMap[remoteEntry.id]
?: error("This must not happen.")
... // `remoteEntry` と `localEntry` を使って「更新」するロジック
}
localEntries.asSequence()
.filter { it.id in deletedEntryIds }
.forEach { deletedEntry ->
... // `deletedEntry` を使って「削除」するロジック
}
}
}
ここで、List.associateBy
は List
から Map
を作る関数です。つまり、localEntries.associateBy(FooModel::id)
は ID をキーとし、FooData
をバリューとする Map
を返します。また、subtract
は集合の差 (レシーバに含まれ、引数に含まれない要素) を返す関数です。
このコードで改善できる点はありますか?
結び目を解くための中間目標
このコードの流れを追うためには、かなりの労力が必要になります。その原因の 1 つに、データの依存関係が複雑に絡み合っている 点が挙げられます。例えば、追加する要素の一覧 createdEntryIds
を求める流れは、以下のようになっています。
remoteEntries
からremoteEntryIds
を求める。localEntries
からlocalEntryMap
を求め、そこからさらにlocalEntryIds
を求める。remoteEntryIds
とlocalEntryIds
比較し、createdEntryIds
を求める。
さらに、createdEntryIds
は ID のみ持つため、実際に追加を行うためには FooData
を取得する必要があります。そのため、上記のコードでは再度 remoteEntries
を使っています。このように、複数回同じデータを用いると、処理の流れが煩雑になります。
また、更新と削除のコードについても、依存関係の複雑さを原因とする問題があります。
- 更新コードの問題:
localEntryMap
への依存のため、本来起こり得ないランタイムエラーの定義が必要になる。 - 削除コードの問題: 追加や更新のコード と一貫性が取れていない。
このような問題を解決するためには、理想的な中間データはどのようなものかを想像し、そこから関数の構成を逆算すると上手くいくことがあります。今回の場合は、データを追加・更新・削除を行うので、createdEntries
, updatedEntries
, deletedEntries
という 3 つ組があれば、コードを単純にできそうです。
この 3 つ組を作るためには、以下のようにすればよいでしょう。
- ローカルとリモート全ての ID のセット
allEntryIds
を作る。 - それぞれの ID に対し、ローカルとリモートのデータのペア
Pair<FooData?, FooData?>
を作る。 Sequence<Pair<S?, T?>>
から 3 つ組を作る関数partitionByNullity
を作る。3 つ組の各要素は、以下の通り。- 1 要素目
List<S>
:S
が non-null でT
が null の場合 - 2 要素目
List<Pair<S, T>>
:S
とT
の両方が non-null の場合 - 3 要素目
List<T>
:S
が null でT
が non-null の場合
- 1 要素目
この partitionByNullity
を使うことで、関数の流れを簡潔にできます。大まかには、以下のような流れになります。
- ID -> リモートの
FooData
のMap
、remoteEntryMap
を作る。 - ID -> ローカルの
FooData
のMap
、localEntryMap
を作る。 remoteEntryMap
とlocalEntryMap
から追加、更新、削除の項目(createdEntries, updatedEntries, deletedEntries)
を作る。createdEntries
、updatedEntries
、deletedEntries
のそれぞれに対して追加、更新、削除を実行する。
実装例は以下の通りになります。
fun synchronizeWithRemoteEntries() {
val remoteEntries: List<FooModel> = fooRemoteClient.fetch()
val remoteEntryMap = remoteEntries.associateBy(FooModel::id)
val localEntries: List<FooModel> = fooLocalDao.getAllEntries()
val localEntryMap = localEntries.associateBy(FooModel::id)
val allEntryIds = remoteEntryMap.keys + localEntryMap.keys
val (createdEntries, updatedEntries, deletedEntries) = allEntryIds.asSequence()
.map { id -> remoteEntryMap[id] to localEntryMap[id] }
.partitionByNullity()
createdEntries.forEach { createdEntry ->
... // `createdEntry` を使って「追加」するロジック
}
updatedEntries.forEach { (remoteEntry, localEntry) ->
... // `remoteEntry` と `localEntry` を使って「更新」するロジック
}
deletedEntries.forEach { deletedEntry ->
... // `deletedEntry` を使って「削除」するロジック
}
}
companion object {
/** ... */
private fun <S : Any, T : Any> Sequence<Pair<S?, T?>>.partitionByNullity():
Triple<List<S>, List<Pair<S, T>>, List<T>> {
val leftEntries: MutableList<S> = mutableListOf()
val bothEntries: MutableList<Pair<S, T>> = mutableListOf()
val rightEntries: MutableList<T> = mutableListOf()
forEach { (left, right) ->
when {
left != null && right == null -> leftEntries += left
left != null && right != null -> bothEntries += left to right
left == null && right != null -> rightEntries += right
else /* left == null && right == null */ -> Unit
}
}
return Triple(leftEntries, bothEntries, rightEntries)
}
}
このようにすることで、synchronizeWithRemoteEntries
の中で最も重要なコードである 追加・更新・削除の forEach
を目立たせることができます。
一言まとめ
データの依存関係が複雑なときは、理想的な中間データを作ることで整理できる可能性がある。
キーワード: data dependency
, function flow
, intermediate data structure