こんにちは。コミュニケーションアプリ「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
}
}
リポジトリには NewsArticleRepository
と NewsSourceRepository
の 2 つがあり、それらは複雑なクラスだとします (ネットワークに依存しているなど)。文字列のフォーマッタには StringTruncator
と TimeTextFormatter
の 2 つがありますが、その実装は十分に単純だとします。(本来、文字列の取り扱いは複雑になりがちなのですが、説明のために簡単化します。)
コンストラクタパラメータ modelFactory
は NewSnippet
のインスタンスを作る関数を示します。そのデフォルト引数としては、NewSnippet
のコンストラクタの参照 ::NewSnippet
が定義されています。つまり、デフォルト引数の動作としては、modelFactory(...)
の呼び出しはコンストラクタ NewSnippet(...)
の呼び出しと同じです。
ここで StringTruncator
と TimeTextFormatter
、NewsSnippet
の 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
を外部から渡せるようにする必要はありません。また、StringTruncator
と TimeTextFormatter
についても、シングルトンなどに依存していないならば、インターフェースを分離して外部から実装を渡すこと (依存性の注入) は不要でしょう。このコードで外部から渡せるようにすべきなのは、環境によって変わる値 (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.truncate
と TimeTextFormatter
で使う Locale
は同じであることを暗に期待しています。しかし実際には、LatestNewsSnippetUseCase(locale1, ..., TimeTextFormatterImpl(locale2), ...)
のように別の locale
を渡すことができてしまいます。特に、依存の解決が別の場所で行われている場合は、違う値が渡されたとしても見落としやすいです。また、共通の値が使われていることを検証するテストも、却って書きにくくなりやすいでしょう。
一言まとめ
依存性の注入を行うときは、その目的を明確にする。
キーワード: dependency injection
, dependency explicitness
, constructor parameter