LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック:第29回 ゴルディアスの変数

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 29 回です。Weekly Report については、第 1 回の記事を参照してください。

ゴルディアスの変数

ローカルとリモートの 2 つのストレージ上に FooData というデータが保存されているとし、それらを同期するための FooSynchronizer を実装するとします。

想定している状況は以下のとおりです。

  • リモートの FooData がオリジナルであり、それを使ってローカルの FooData を更新する。
  • リモートとローカルのデータは、それぞれ FooLocalDaoFooRemoteClient から取得できる。
  • (問題の簡略化のため、トランザクションなどは考慮しなくて良い。)

FooSynchronizer の関数 synchronizeWithRemoteEntries では、FooData の ID の一覧をローカルとリモートで比較し、その結果に応じて追加、更新、削除を行います。

  • 追加: リモートのみに ID が存在する場合に実行 (id !in localEntryIds && id in remoteEntryIdstrue のときに実行)
  • 更新: ローカルとリモート両方に ID が存在する場合に実行 (id in localEntryIds && id in remoteEntryIdstrue のときに実行)
  • 削除: ローカルのみに ID が存在する場合に実行 (id in localEntryIds && id !in remoteEntryIdstrue のときに実行)

この仕様を満たすように、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.associateByList から Map を作る関数です。つまり、localEntries.associateBy(FooModel::id) は ID をキーとし、FooData をバリューとする Map を返します。また、subtract は集合の差 (レシーバに含まれ、引数に含まれない要素) を返す関数です。

このコードで改善できる点はありますか?

結び目を解くための中間目標

このコードの流れを追うためには、かなりの労力が必要になります。その原因の 1 つに、データの依存関係が複雑に絡み合っている 点が挙げられます。例えば、追加する要素の一覧 createdEntryIds を求める流れは、以下のようになっています。

  • remoteEntries から remoteEntryIds を求める。
  • localEntries から localEntryMap を求め、そこからさらに localEntryIds を求める。
  • remoteEntryIdslocalEntryIds 比較し、createdEntryIds を求める。

さらに、createdEntryIds は ID のみ持つため、実際に追加を行うためには FooData を取得する必要があります。そのため、上記のコードでは再度 remoteEntries を使っています。このように、複数回同じデータを用いると、処理の流れが煩雑になります。

また、更新と削除のコードについても、依存関係の複雑さを原因とする問題があります。

  • 更新コードの問題: localEntryMap への依存のため、本来起こり得ないランタイムエラーの定義が必要になる。
  • 削除コードの問題: 追加や更新のコードと一貫性が取れていない。

このような問題を解決するためには、理想的な中間データはどのようなものかを想像し、そこから関数の構成を逆算すると上手くいくことがあります。今回の場合は、データを追加・更新・削除を行うので、createdEntries, updatedEntries, deletedEntries という 3 つ組があれば、コードを単純にできそうです。

この 3 つ組を作るためには、以下のようにすればよいでしょう。

  1. ローカルとリモート全ての ID のセット allEntryIds を作る。
  2. それぞれの ID に対し、ローカルとリモートのデータのペア Pair<FooData?, FooData?> を作る。
  3. Sequence<Pair<S?, T?>> から 3 つ組を作る関数 partitionByNullity を作る。3 つ組の各要素は、以下の通り。
    • 1 要素目 List<S>: S が non-null で T が null の場合
    • 2 要素目 List<Pair<S, T>>: ST の両方が non-null の場合
    • 3 要素目 List<T>: S が null で T が non-null の場合

この partitionByNullity を使うことで、関数の流れを簡潔にできます。大まかには、以下のような流れになります。

  1. ID -> リモートの FooDataMapremoteEntryMap を作る。
  2. ID -> ローカルの FooDataMaplocalEntryMap を作る。
  3. remoteEntryMaplocalEntryMap から追加、更新、削除の項目 (createdEntries, updatedEntries, deletedEntries) を作る。
  4. createdEntriesupdatedEntriesdeletedEntries のそれぞれに対して追加、更新、削除を実行する。

実装例は以下の通りになります。

    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

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧