LINEヤフー Tech Blog

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

コード品質向上のテクニック:第27回 依存も積もれば

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

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

依存も積もれば

以下の LatestNewsSnippetUseCase は、ニュース記事のスニペットをデータモデル NewsSnippet として提供するクラスです。このデータモデルは、リポジトリのデータを組み合わせ、文字列をフォーマットすることで作られます。

class LatestNewsSnippetUseCase(
    private val locale: Locale,
    private val articleRepository: NewsArticleRepository,
    private val sourceRepository: NewsSourceRepository,
    private val stringTruncator: StringTruncator,
    private val timeFormatter: TimeTextFormatter = TimeTextFormatterImpl(locale),
    private val modelFactory: (title: String, content: String, dateText: String, source: String) -> NewsSnippet =
        ::NewsSnippet
) {
    fun getLatestSnippet(): NewsSnippet {
        val article = articleRepository.getLatestArticle()
        val articleText = stringTruncator.truncate(article.contextText, ARTICLE_TEXT_LENGTH, locale)
        val dateText = timeFormatter.toShortMonthDayText(article.timestampInMillis)
        val sourceName = sourceRepository.getSource(article.sourceId).shortName
        return modelFactory(article.title, articleText, dateText, sourceName)
    }

    companion object {
        private const val ARTICLE_TEXT_LENGTH = 280
    }
}

リポジトリには NewsArticleRepositoryNewsSourceRepository の 2 つがあり、それらは複雑なクラスだとします (ネットワークに依存しているなど)。文字列のフォーマッタには StringTruncatorTimeTextFormatter の 2 つがありますが、その実装は十分に単純だとします。(本来、文字列の取り扱いは複雑になりがちなのですが、説明のために簡単化します。)

コンストラクタパラメータ modelFactoryNewSnippet のインスタンスを作る関数を示します。そのデフォルト引数としては、NewSnippet のコンストラクタの参照 ::NewSnippet が定義されています。つまり、デフォルト引数の動作としては、modelFactory(...) の呼び出しはコンストラクタ NewSnippet(...) の呼び出しと同じです。

ここで StringTruncatorTimeTextFormatterNewsSnippet の 3 つは以下のように定義されています。

interface StringTruncator {
    fun truncate(string: String, length: Int, locale: Locale, suffix: String = "…" /* U+2026 */): String
}

interface TimeTextFormatter {
    fun toShortMonthDayText(millis: Long): String
}

class NewsSnippet(val title: String, val content: String, val dateText: String, val source: String)

さらに、StringTruncator の実装 StringTruncatorImpl のコンストラクタは、引数を取らないとします。

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

注入のレゾンデートル

端的に言うと、modelFactory を外部から渡せるようにする必要はありません。また、StringTruncatorTimeTextFormatter についても、シングルトンなどに依存していないならば、インターフェースを分離して外部から実装を渡すこと (依存性の注入) は不要でしょう。このコードで外部から渡せるようにすべきなのは、環境によって変わる値 (Locale) と、複雑なクラス (リポジトリ) のみです。コードをより単純にするには、以下のように書き換えると良いでしょう。

  • modelFactory: NewsSnippet のコンストラクタを直接呼び出す。
  • stringTruncator/timeFormatter: インスタンスを通常の private プロパティとして保持する。
    • インターフェースと実装を統合することも考慮に入れる。
    • StringTruncator については、ステートレスなのであれば object に変えても良い。
class LatestNewsSnippetUseCase(
    private val locale: Locale,
    private val articleRepository: NewsArticleRepository,
    private val sourceRepository: NewsSourceRepository
) {
    private val stringTruncator: StringTruncator = StringTruncatorImpl()
    private val timeFormatter: TimeTextFormatter = TimeTextFormatterImpl(locale)
    
    suspend fun getLatestSnippet(): NewsSnippet {
        val article = articleRepository.getLatestArticle()
        val articleText = stringTruncator.truncate(article.contextText, ARTICLE_TEXT_LENGTH, locale)
        val dateText = timeFormatter.toShortMonthDayText(article.timestampInMillis)
        val sourceName = sourceRepository.getSource(article.sourceId).shortName
        return NewsSnippet(article.title, articleText, dateText, sourceName)
    }

    companion object {
        private const val ARTICLE_TEXT_LENGTH = 280
    }
}

依存性の注入を利用する場合、その目的を明確にしなければなりません。注入の目的としては、以下のようなものが挙げられます。

  • 依存先のスコープとライフサイクルの管理: オブジェクトを共有するため (例: 状態の共有、横断的関心事の分離) や、自身より生存期間が長いオブジェクトを使うため。
  • 依存性の逆転: モジュールの循環依存を解決するためや、アーキテクチャで定めた依存の方向にするため。
  • 実装の切り替え: 設定などによる機能の切り替えや、テストやデバッグ、検証用の実装に差し替えるため。
  • 実装の分離: ビルドを高速化するためや、プロプライエタリなライブラリを提供するため。

これらが必要でない場合、例えば参照透過なユーティリティ関数や単純なモデルクラスなどは、注入をする必要はほとんどありません。不必要な注入を行うことで、以下のような問題を引き起こす可能性があります。

不要で暗黙な依存関係: コンストラクタによる注入をしている場合、依存先の動作を追跡するために、コンストラクタの呼び出し元を確認する必要があります。コンストラクタが様々な場所で呼ばれている場合、すべての呼び出し元を確認にするのには労力がかかるでしょう。他の方法で注入が行われている場合でも、インターフェースが分離されている状況では実装を辿る手間が増えます。

呼び出し元の責任の増大: コンストラクタやセッターによる注入を行うと、呼び出し元が依存を解決しなくてはなりません。特に、依存性の解決が連鎖的に転送されると、大元のクラスにありとあらゆる依存関係が集まります。(この問題は、セカンダリコンストラクタ、ファクトリ関数、デフォルト引数、DI コンテナなどによって緩和できます。)

値の関連性の破壊: 「複数のオブジェクトで共通の値を使って欲しい」という制約があっても、値の注入によってその制約が破られてしまうことがあります。上記の UseCase の例の場合、StringTruncator.truncateTimeTextFormatter で使う Locale は同じであることを暗に期待しています。しかし実際には、LatestNewsSnippetUseCase(locale1, ..., TimeTextFormatterImpl(locale2), ...) のように別の locale を渡すことができてしまいます。特に、依存の解決が別の場所で行われている場合は、違う値が渡されたとしても見落としやすいです。また、共通の値が使われていることを検証するテストも、却って書きにくくなりやすいでしょう。


一言まとめ

依存性の注入を行うときは、その目的を明確にする。

キーワード: dependency injection, dependency explicitness, constructor parameter

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

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