8、依赖注入:Koin框架集成、模块定义、作用域管理、测试支持

依赖注入,说白了就是帮你把对象的创建和组装工作外包出去。你想想看,如果每个类都自己 new 依赖对象,那代码耦合度得多高?改一个地方,牵一发而动全身。我在项目中见过太多这种「硬编码依赖」的烂摊子,后来大家普遍用上了 DI 框架,世界才清净了。

在 Ktor 的世界里,Koin 是我个人最常用的选择。它轻量、无侵入、纯 Kotlin 实现,没有注解处理器,没有代码生成。嗯,这里要注意:Koin 不是编译时注入,而是运行时注入,所以性能上有一点点开销,但对于服务器端应用来说,完全不是问题。

8.1 为什么选择 Koin?

我最早接触 DI 是从 Spring 开始的,后来转 Kotlin 后试过 Dagger、Kodein,最后锁定了 Koin。原因很简单:

  • 纯 Kotlin DSL:写起来就像在写配置代码,而不是 XML 或注解
  • 无代码生成:编译速度快,调试方便
  • 与 Ktor 集成自然:Koin 官方提供了 ktor-koin 插件
  • 作用域管理灵活:可以精确控制对象的生命周期

核心思想:Koin 本质上是一个轻量级的服务定位器 + 作用域容器。它通过 DSL 定义「配方」,在需要的时候按配方创建对象。

8.2 集成 Koin 到 Ktor 项目

第一步,加依赖。在 build.gradle.kts 里加上:

// Koin 核心
implementation("io.insert-koin:koin-core:3.5.0")
// Koin 与 Ktor 集成
implementation("io.insert-koin:koin-ktor:3.5.0")
// 可选:日志支持
implementation("io.insert-koin:koin-logger-slf4j:3.5.0")

第二步,在 Application 模块中启动 Koin。我个人习惯在 Application 的扩展函数里做这件事:

fun Application.configureDI() {
    install(Koin) {
        // 加载日志
        slf4jLogger()
        // 加载模块
        modules(
            repositoryModule,
            serviceModule,
            controllerModule
        )
    }
}

然后在 main 函数里调用:

fun main() {
    embeddedServer(Netty, port = 8080) {
        configureDI()
        configureRouting()
    }.start(wait = true)
}

小技巧:我建议把模块定义放在单独的包(比如 di 包)里,不要和路由、控制器混在一起。这样后期维护时,一眼就能找到所有依赖定义。

8.3 模块定义:从简单到复杂

模块是 Koin 的核心概念。一个模块就是一组依赖定义的集合。我们来看几种常见的定义方式。

8.3.1 单例 vs 工厂

val repositoryModule = module {
    // 单例:整个应用生命周期内只有一个实例
    single<UserRepository> { UserRepositoryImpl() }

    // 工厂:每次注入都创建新实例
    factory { UserMapper() }
}

什么时候用 single?什么时候用 factory?

  • single:无状态的服务、数据库连接池、配置对象
  • factory:有状态的处理器、请求级别的对象、DTO 映射器

我曾经犯过一个错误:把一个本该是 factory 的 RequestContext 定义成了 single,结果多个请求共享了同一个上下文,数据全乱了。嗯,从那以后我对作用域就特别敏感。

8.3.2 带参数的注入

有时候你需要根据运行时参数创建对象。Koin 支持参数注入:

val serviceModule = module {
    factory { params ->
        UserService(
            repository = get(),
            tenantId = params.get()
        )
    }
}

// 使用时
val service = get<UserService> { parametersOf("tenant_123") }

注意:参数注入虽然灵活,但不要滥用。如果某个类需要太多参数,说明它的职责可能过重了。我一般控制在 3 个参数以内。

8.3.3 绑定接口与实现

Koin 支持按类型注入,也支持按名称注入:

val controllerModule = module {
    // 按接口注入
    single<UserController> { UserControllerImpl(get()) }

    // 按名称注入(多个实现时)
    single<CacheService>(named("redis")) { RedisCacheService() }
    single<CacheService>(named("memory")) { MemoryCacheService() }
}

// 使用时
val redisCache = get<CacheService>(named("redis"))

8.4 作用域管理:控制对象的生命周期

作用域是 Koin 最强大的特性之一。它允许你创建「局部容器」,在特定范围内共享对象,超出范围后自动释放。

为什么需要作用域?你想想看,一个 HTTP 请求进来,你可能需要创建一个 RequestScope 对象,里面包含当前用户信息、请求参数、事务上下文等。这些对象只在请求处理期间有效,请求结束后就应该销毁。

8.4.1 创建作用域

val requestScopeModule = module {
    scope<RequestScope> {
        scoped { RequestContext() }
        scoped { UserSession(get()) }
        scoped { TransactionManager(get()) }
    }
}

// 定义作用域标识
class RequestScope

8.4.2 在 Ktor 中使用作用域

我通常会在拦截器里创建和销毁作用域:

fun Application.configureScopedRoutes() {
    install(StatusPages) {
        // 全局拦截器
    }

    routing {
        route("/api") {
            // 每个请求创建新的作用域
            intercept(ApplicationCallPipeline.Plugins) {
                val scope = getKoin().createScope(
                    id = call.request.local.uri,
                    scopeId = qualityOf<RequestScope>()
                )
                call.attributes.put(ScopeKey, scope)
                try {
                    proceed()
                } finally {
                    scope.close()
                }
            }
        }
    }
}

关键点:作用域内的 scoped 对象,在作用域存活期间是单例的。作用域关闭后,所有 scoped 对象都会被 GC 回收。这比手动管理生命周期安全得多。

8.4.3 作用域嵌套

Koin 支持作用域嵌套。比如一个 WebSocket 连接可以创建一个连接作用域,里面再包含多个消息作用域:

val connectionScope = getKoin().createScope("conn_001", qualityOf<ConnectionScope>())
val messageScope = connectionScope.createScope("msg_001", qualityOf<MessageScope>())

// 子作用域可以访问父作用域的对象
val context = messageScope.get<ConnectionContext>()  // 来自父作用域

我曾经在项目中用作用域管理数据库事务。每个请求创建一个事务作用域,里面放一个 scoped 的 Session 对象。请求结束时自动提交或回滚。代码干净多了。

8.5 测试支持:让依赖注入为测试服务

依赖注入最大的好处之一就是方便测试。Koin 提供了专门的测试模块,让你可以轻松替换真实依赖。

8.5.1 添加测试依赖

testImplementation("io.insert-koin:koin-test:3.5.0")
// 如果使用 JUnit 5
testImplementation("io.insert-koin:koin-test-junit5:3.5.0")

8.5.2 编写测试

Koin 测试的核心是 checkModulesKoinTest

class UserServiceTest : KoinTest {

    // 注入真实依赖
    private val userService: UserService by inject()

    @Before
    fun setUp() {
        startKoin {
            modules(
                repositoryModule,
                serviceModule
            )
        }
    }

    @After
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun `test get user by id`() {
        val user = userService.getUser("123")
        assertNotNull(user)
        assertEquals("123", user.id)
    }
}

8.5.3 使用 Mock 替换依赖

真正的测试需要隔离外部依赖。Koin 允许你在测试模块中覆盖生产模块:

class UserServiceMockTest : KoinTest {

    private val userService: UserService by inject()
    private val mockRepository: UserRepository = mockk()

    @Before
    fun setUp() {
        startKoin {
            // 加载生产模块
            modules(serviceModule)
            // 覆盖 repository 为 mock
            modules(module {
                single<UserRepository> { mockRepository }
            })
        }

        // 设置 mock 行为
        every { mockRepository.findById("123") } returns User("123", "Test User")
    }

    @Test
    fun `test get user with mock`() {
        val user = userService.getUser("123")
        assertEquals("Test User", user.name)
        verify { mockRepository.findById("123") }
    }
}

个人经验:我习惯把测试模块定义成单独的函数,方便复用:

fun mockModule(repo: UserRepository) = module {
    single<UserRepository> { repo }
}

8.5.4 模块验证

Koin 提供了一个很实用的功能:checkModules。它会遍历所有模块定义,尝试创建每个对象,确保没有循环依赖或缺失依赖:

class ModuleCheckTest {
    @Test
    fun `verify all modules`() {
        startKoin {
            modules(
                repositoryModule,
                serviceModule,
                controllerModule
            )
        }.checkModules()
    }
}

这个测试我建议放在 CI 流程里。每次提交代码,先跑一遍模块验证,能提前发现 90% 的 DI 配置问题。

8.6 知识体系总览

下面这张图概括了 Koin 在 Ktor 项目中的完整集成路径:

Koin 在 Ktor 中的集成架构 Application 入口 install(Koin) 插件 模块定义 single / factory / scoped 作用域管理 scope / scoped 测试支持 checkModules / mock repositoryModule serviceModule / controllerModule RequestScope ConnectionScope / MessageScope JUnit 5 测试 MockK / 模块覆盖 解耦 · 可测试 · 生命周期可控

8.7 避坑指南

最后,分享几个我踩过的坑:

  • 循环依赖:A 依赖 B,B 又依赖 A。Koin 会在启动时检测到并抛出异常。解决方案是引入接口,或者用懒加载 lazy { get() }
  • 作用域泄漏:创建了作用域但忘记关闭。我建议用 use 模式或者 try-finally 确保关闭。
  • 模块顺序:虽然 Koin 不强制模块加载顺序,但如果 A 模块依赖 B 模块的对象,最好先加载 B 模块。我习惯按「基础 → 业务 → 接口」的顺序排列。
  • 测试隔离:每个测试类最好独立启动和停止 Koin,避免测试间相互影响。JUnit 5 的 @BeforeEach@AfterEach 是理想的选择。

总结一下:Koin 让依赖注入变得简单而优雅。模块定义清晰,作用域管理灵活,测试支持完善。我个人用了三年多,从没后悔过这个选择。如果你还在纠结用哪个 DI 框架,不妨试试 Koin,它不会让你失望。

公众号:蓝海资料掘金营,微信deep3321