こんにちは。コミュニケーションアプリ「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)
}
}
これを使う側のコードは以下のようになります。FooBufferedLogger
は Closeable
を継承しているので、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
を使い回せるようにして、FooApiClient
と tag
の管理を隠蔽したくなるのは自然な発想です。
この仕様を満たすために、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
を使うコードです。executeBar
と executeBaz
では、それぞれ "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()
}
}
ここで、executeBar
が executeBaz
を呼び出していることに注意してください。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()
...
しかしこの解決策にも、まだ以下のような問題があります。
tag
をbufferLogElement
の呼び出しでも与える必要がある。startLogging
を同じtag
で複数回呼び出してしまうと、結局同じ問題が発生する。
ライフサイクルの長さで分ける
この問題の根本的な原因は、FooApiClient
と tag
のライフサイクルが違うにもかかわらず、インスタンスの共有のスコープを同じにしてしまったことにあります。「複数の tag
で共通の FooApiClient
が使われることを保証したい」のであれば、通常、tag
のライフサイクルは FooApiClient
のライフサイクルより短くなります。
このようなときは、FooApiClient
の管理と tag
の管理でクラスを分けるのが解決策の 1 つです。以下の実装では、tag
の管理のためにインターフェース LogBuffer
を作成しています。そして、実装 LogBufferImpl
を private
にすることで、共通の 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