日々をより良く

〜てくのろじーをそえて〜

Spring Boot + Kotlin で依存性を注入する

f:id:nbtk39:20211217092812p:plain

この記事は以下の続編です!

nbtk.hatenablog.com

前回は以下の「体系的に依存性の注入の理解を深める」ための整理をしてきました。

  • 体系的に依存性の注入の理解を深める
    • そもそもの課題
    • DI、DI Container
    • DI を知る上で必要な概念
      • 依存性逆転の原則
      • 制御の逆転

今回は以下のフレームワークを利用した DI の実装」「他に DI で知ってるといいこと」についてお話しします!

  • フレームワークを利用した DI の実装
    • Kotlin + Spring Boot での実装方法
    • DI のスコープについて
    • 注入の種類(コンストラクタ・セッター・フィールド)
  • 他に DI で知ってるといいこと
    • Service Locator
    • 疎結合にしすぎるデメリット

🙂 Spring Boot の例が必要ない方でも、「対象クラスを DI Container に登録する」の項目を読み飛ばして進めていただくと、ざっくりしたイメージは掴んでいただけると思います。


各バージョン

macOS Monterey 12.0.1

Kotlin 1.6.0

Spring Boot 2.5.7


DI Container を利用する

Spring では公式ドキュメントによると DI Container ではなく IoC Container と呼んでいるようです。この記事ではわかりやすいように DI Container で統一してお話しします。

前回の記事では Application クラスが DI をしていました。IoC により DI をフレームワークが吸収してくれるため、Applicaiton クラスを BookController というコントローラクラスに置き換えて説明します。

f:id:nbtk39:20211217093421p:plain

対象クラスを DI Container に登録する

Spring Boot ではアノテーションを付与するだけで簡単に DI の管理ができます。

@SpringBootApplication
class ExampleDependencyInjectionApplication

fun main(args: Array<String>) {
    runApplication<ExampleDependencyInjectionApplication>(*args)
}

@RestController
@RequestMapping("/books")
class BookController(
    private val bookListService: BookListService
) {
    @GetMapping
    fun getBooks(): List<String> {
        return bookListService.getAllBooks()
    }
}

interface BookListService {
    fun getAllBooks() : List<String>
}

@Service
class BookListServiceImpl(
    private val bookRepository: BookRepository
): BookListService {
    override fun getAllBooks() : List<String> {
        return bookRepository.getAll()
    }
}

interface BookRepository {
    fun getAll(): List<String>
}

@Repository
class BookRepositoryImpl: BookRepository {
    override fun getAll(): List<String> {
        return File("booklist.txt").readLines()
    }
}

なんとこれだけで DI してくれます!@RestController @Service @Repository アノテーションを付与しただけです。 簡単ですね。むしろ簡単すぎてわかりづらいですね!仕組みを解説します。

Spring Boot では @Component をクラスに付与することで DI 対象になります。

サンプルコードでは @RestController @Service @Repository を付与しましたが、それぞれのアノテーション@Component を継承しています。なので DI Container に登録できたわけです。

また、詳細は後述しますが Spring Boot はデフォルトで Singleton インスタンスが生成されます。つまり、インジェクト対象の変数全てに同一インスタンスがセットされます。

@Component @RestController @Service @Repository 何が違う??

なお @Component とそれ以外の上記のアノテーションが何が違うかというと、特に違いはありません!人間が見て責務がわかりやすいようにつけています。

例えば @Service のクラスとドキュメントは以下です。(一部抜粋)

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Service { ... }

Indicates that an annotated class is a "Service", originally defined by Domain-Driven Design (Evans, 2003) as "an operation offered as an interface that stands alone in the model, with no encapsulated state."

ドメイン駆動設計によって定義されているサービスクラスを示す、と書いています。

@RestController @Repository なども同様で、ドメイン駆動設計に基づいて実装されているようです。

注入(インジェクト)する仕組み

Spring Boot では @SpringBootApplication アノテーションにより@SpringBootConfiguration, @EnableAutoConfiguration, @ComponentScan が有効になります。これらの仕組みにより、自動的に DI の対象クラス(@Component が付与されたクラス)を DI Container に登録し、さらに必要な箇所にインスタンスを自動的にインジェクトしてくれます。これをコンポーネントスキャンと言います。便利ですね!

また、覚えておきたい定義に ConfigurationBean があります。Spring では DI 対象にするクラスを Bean といいます。ConfigurationBean を定義するクラスファイルです。@SpringBootApplication だけを利用している場合は無意識に使うこともできますが、内部では Configuration と Bean を定義しています。

@SpringBootApplication 以外にも DI する仕組みがあり、その一つに Java Config があります。Java Config は以下のように書きます。

@Configuration
public class AppConfig { 
  
    @Bean
    public BookRepository bookRepository() {
        return new BookRepositoryImpl();
    }
}

@Configuration@Bean を定義することにより DI Container に登録するインスタンスを定義しています。(コンポーネントスキャンと Java Config は併用可能)

@SpringBootApplication の各アノテーションのポイントは以下です。

  • @SpringBootConfiguration
    • Configuration クラスであることを定義しています。 @Configuration を継承しています。
  • @ComponentScan
    • このアノテーションが付与されたパッケージは以下の Bean をスキャンします。
  • @EnableAutoConfiguration
    • Auto-configuration を有効にします。これにより、Bean の登録や DI を行ってくれます。

省略可能だけど大事な @Autowired

先ほどのサンプルコードの中に省略されているアノテーションが存在します。 @Autowired です。

@Autowired を付与することで Bean をインジェクトしてくれます。

ただし、Spring 4.3 からは単一コンストラクタの場合、 @Autowired を省略可能になりました。

省略せずに実装すると以下になります。(一部抜粋)

@RestController
@RequestMapping("/books")
class BookController(
    @Autowired private val bookListService: BookListService // ⇦ココ
) {
    @GetMapping
    fun getBooks(): List<String> {
        return bookListService.getAllBooks()
    }
}

@Service
class BookListServiceImpl(
    @Autowired private val bookRepository: BookRepository  // ⇦ココ
): BookListService {
    override fun getAllBooks() : List<String> {
        return bookRepository.getAll()
    }
}

DI 対象のインスタンスを保持するスコープ

先ほど「Spring Boot はデフォルトで Singleton インスタンスが生成されます」とお話ししました。

インスタンスの生成・破棄のタイミング(スコープ)には種類があり、 singleton prototype request session application websocket があります。

詳細は公式ドキュメントを参照していただきたいのですが、注意すべき点は一番多く利用すると思われるデフォルトの singleton の場合、Bean がフィールドを保持しているとプログラムに不具合が発生する可能性が高いということです。

注入(インジェクション)の種類

さて、ここまででだいーぶ整理できてきました!ここまで付き合ってくださったみなさまみなさまありがとうございます。ただあともう少し説明させてください。

DI には種類があり、 コンストラクタインジェクション セッターインジェクション フィールドインジェクション があります。それぞれ解説します。

コンストラクタインジェクション

サンプルコードでは コンストラクタインジェクション を利用しました。これが一番安全で推奨されています。

サンプルコードをもう一度見てみましょう。コンストラクタに変数を定義してインジェクトされていることがわかります。

@RestController
@RequestMapping("/books")
class BookController(
    private val bookListService: BookListService // ⇦ココ
) {
    @GetMapping
    fun getBooks(): List<String> {
        return bookListService.getAllBooks()
    }
}

コンストラクタインジェクションだと後から変数を変更できません。これが安全な理由です。

セッターインジェクション

実装は以下のようになります。

@RestController
@RequestMapping("/books")
class BookController {

    // ↓ココ
    private var bookListService: BookListService? = null
        @Autowired
        set(bookListService) {
            this.bookListService = bookListService
        }

    @GetMapping
    fun getBooks(): List<String> {
        return bookListService.getAllBooks()
    }
}

セッターに @Autowired しています。後から代入するため var で宣言して null を代入しています。

変更可能性があるのでちょっと危険ですね。

フィールドインジェクション

実装は以下のようになります。

@RestController
@RequestMapping("/books")
class BookController {

    // ↓ココ
    @Autowired
    lateinit var bookListService: BookListService

    @GetMapping
    fun getBooks(): List<String> {
        return bookListService.getAllBooks()
    }
}

フィールドに変数を宣言しています。

Kotlin のフィールド変数は宣言時に初期化が必要なので、DI の場合は lateinit を修飾して、後から代入するため var にする必要があります。

変更可能性があるのでちょっと危険ですね。

他に DI で知ってるといいこと

Appendix 的な感じですが、補足で知っておくと良いことをお話しします。

Service Locator

これまで依存性逆転の法則を実現する方法として DI Container を解説してきました。しかし他にも手段があります。Service Locator です。

Service Locator はDI対象のインスタンスを生成・保持しているクラスです。

DI Container はフレームワークによってインスタンス生成・保持などの振る舞いが隠蔽されることがありますが、Service Locator はそれ自体のインスタンスをシングルトンで生成し、依存性を注入する場所に Service Locator パターンが実装されたクラスのインスタンスを引数で受け取ります。

Service Locator のインスタンスを受け取ったクラスは、Service Locator から必要なDI対象のインスタンスを取り出します。

Service Locator は DI Container と比較して主に以下の理由で推奨されておりません。

  • Service Locator 自体に依存してしまう
  • Service Locator で保持しているインスタンスがどこに依存しているか分かり難い

より詳細な説明や実装例は以下が参考になりますのでぜひご覧ください。

Inversion of Control コンテナと Dependency Injection パターン

なんでも疎結合にすればいいというわけではない

ここまで、モジュール同士は疎結合に保とう!と行ってきました。

しかし、全てのモジュール同士に抽象を挟んで疎結合にすると、それだけコードが増えて人間が把握する手間や、実装・保守のコストがかさみます。

クラスだけでなく、DI Container の設定項目(ファイルやアノテーション)も増えてコードの可読性が低下してしまいます。

チームの設計方針にもよりますが、本当に必要な箇所だけ疎結合にされている方がメリットが大きいことが多いと思いますので、どこを疎結合にするかは慎重に決めたいですね。

おわりに

DI や DI Container、その周辺の概念について具体的な実装を交えて包括的にお話ししてきました。かなり長くなりましたが、付き合ってくださった方ありがとうございます。

DI まわりは概念的な話も多く、またフレームワークが吸収してくれるためわかりにくい部分もあります。百聞は一見にしかずと言うことで手を動かして馴染ませたいものですね!

参考にさせていただいた記事

コンテナから紐解く本当のSpring入門 #jsug / Understanding Spring Container - Speaker Deck