LINEヤフー Tech Blog

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

コード品質向上のテクニック:第37回 長いライフサイクルに巻かれる

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

この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 37 回です。 LINEヤフー社内には、高い開発生産性を維持するための Review Committee という活動があります。ここで集まった知見を、Weekly Report と称して毎週社内に共有しており、その一部を本ブログ上でも公開しています。(Weekly Report の詳細については、過去の記事一覧を参照してください)

長いライフサイクルに巻かれる

以下のような FooBufferedLogger というクラスがあるとします。このクラスの役割は、bufferLogElement でログ (LogElement) を溜め込み、close で溜め込んだログを一斉に送信することです。ログは tag によってカテゴリ分けされることを前提としており、tag を変えたい場合は新たな FooBufferedLogger インスタンスを作り直す必要があります。ログの送信には、コンストラクタパラメータで指定された FooApiClient が使われます。

class FooBufferedLogger(
    private val apiClient: FooApiClient,
    private val tag: String
) : Closeable {
    // `null` represents already closed.
    private var buffer: MutableList<String>? = mutableListOf()

    /**
     * Buffers a given value as long as this logger is valid. Then, this returns true.
     *
     * The buffered values are sent to the server when [close] is called.
     * If this buffer is already closed, this does nothing but returns false.
     */
    fun bufferLogElement(logElement: String): Boolean {
        val currentBuffer = buffer ?: return false
        currentBuffer += logElement
        return true
    }

    override fun close() {
        val currentBuffer = buffer ?: return
        buffer = null
        apiClient.sendLogElements(tag, currentBuffer)
    }
}

これを使う側のコードは以下のようになります。FooBufferedLoggerCloseable を継承しているので、Kotlin 標準の拡張関数 use で簡単に使えます。(似たような仕組みは、他の言語でも try-with-resource や ARC のデストラクタなどで実現可能です。)

FooBufferedLogger(..., tag = "bar").use { logger ->
    logger.bufferLogElement("start with $someValue")

    ...
    logger.bufferLogElement("intermediate with $anotherValue")

    ...
    logger.bufferLogElement("finished with $elapsedTimeInNanos")
}

ここで、「複数の tag で共通の FooApiClient インスタンスを使うことを保証したい」という要求があったとします。この場合、FooBufferedLogger を複数の tag を使い回せるようにして、FooApiClienttag の管理を隠蔽したくなるのは自然な発想です。

この仕様を満たすために、FooBufferedLogger の実装を以下のように書き換えました。

class FooBufferedLogger(private val apiClient: FooApiClient) {
    // `null` represents logging is not started.
    private var tag: String? = null
    private val buffer: MutableList<String> = mutableListOf()

    /** ... (snip) ... */
    fun startLogging(tag: String) {
        finishLogging()
        this.tag = tag
    }

    /** ... (snip) ... */
    fun bufferLogElement(logElement: String): Boolean {
        if (tag == null) {
            return false
        }
        buffer += logElement
        return true
    }

    /** ... (snip) ... */
    fun finishLogging() {
        val currentTag = tag ?: return
        tag = null
        apiClient.sendLogElements(currentTag, buffer)
        buffer.clear()
    }
}

しかし、この実装には問題点があります。それは何でしょうか?

巻かれすぎたスパゲッティ

以下のコードは、FooBufferedLogger を使うコードです。executeBarexecuteBaz では、それぞれ "Bar""Baz"tag でログを行っています。

class BarBaz(private val logger: FooBufferedLogger) {

    fun executeBar() = logWith("Bar") { logger ->
        logger.bufferLogElement("bar 1")
        ...
        executeBaz()
        logger.bufferLogElement("bar 2")
    }

    private fun executeBaz() = logWith("Baz") { logger ->
        logger.bufferLogElement("baz 1")
        ...
        logger.bufferLogElement("baz 2")
    }

    private fun logWith(tag: String, action: (FooBufferedLogger) -> Unit) {
        logger.startLogging(tag)
        action(logger)
        logger.finishLogging()
    }
}

ここで、executeBarexecuteBaz を呼び出していることに注意してください。executeBaz の最初で logger.startLogging("Baz") が呼ばれるため、"Bar" のタグは上書きされてしまいます。その結果、executeBar に戻ったときに呼び出される logger.bufferLogElement("bar 2") は、正しく実行されません。

この問題を解決する方法としては、buffer を複数用意し、tag と紐づけて Map として保持することが考えられます。

class FooBufferedLogger(private val apiClient: FooApiClient) {
    private val categorizedLogElements: MutableMap<String, MutableList<String>> =
        mutableMapOf()
    ...

しかしこの解決策にも、まだ以下のような問題があります。

  • tagbufferLogElement の呼び出しでも与える必要がある。
  • startLogging を同じ tag で複数回呼び出してしまうと、結局同じ問題が発生する。

ライフサイクルの長さで分ける

この問題の根本的な原因は、FooApiClienttag のライフサイクルが違うにもかかわらず、インスタンスの共有のスコープを同じにしてしまったことにあります。「複数の tag で共通の FooApiClient が使われることを保証したい」のであれば、通常、tag のライフサイクルは FooApiClient のライフサイクルより短くなります。

このようなときは、FooApiClient の管理と tag の管理でクラスを分けるのが解決策の 1 つです。以下の実装では、tag の管理のためにインターフェース LogBuffer を作成しています。そして、実装 LogBufferImplprivate にすることで、共通の FooApiClient が使われることを保証できます。

class FooLogBufferFactory(private val apiClient: FooApiClient) {

    /** ... */
    fun createLogBuffer(tag: String): LogBuffer =
        LogBufferImpl(apiClient, tag)
    
    /** ... */
    sealed interface LogBuffer : Closeable {
        /** ... */
        fun bufferLog(logElement: String): Boolean
    }
    
    private class LogBufferImpl(
        private val apiClient: FooApiClient,
        private val tag: String
    ) : LogBuffer {
        // `null` represents already closed.
        private var buffer: MutableList<String>? = mutableListOf()

        override fun bufferLog(logElement: String): Boolean {
            ...
        }

        override fun close() {
            ...
        }
    }
}

この実装では createLogBuffer という関数を提供していますが、この他にも解決方法はあります。fun logWith(tag: String, (LogBuffer) -> Unit) といった高階関数にするのも良いでしょう。

一言まとめ

ライフサイクルが異なるものを管理するときは、クラスを分けることを考慮する。

キーワード: disposable instance, cyclic state, state lifecycle

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

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