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 测试的核心是 checkModules 和 KoinTest:
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 项目中的完整集成路径:
8.7 避坑指南
最后,分享几个我踩过的坑:
- 循环依赖:A 依赖 B,B 又依赖 A。Koin 会在启动时检测到并抛出异常。解决方案是引入接口,或者用懒加载
lazy { get() }。 - 作用域泄漏:创建了作用域但忘记关闭。我建议用
use模式或者 try-finally 确保关闭。 - 模块顺序:虽然 Koin 不强制模块加载顺序,但如果 A 模块依赖 B 模块的对象,最好先加载 B 模块。我习惯按「基础 → 业务 → 接口」的顺序排列。
- 测试隔离:每个测试类最好独立启动和停止 Koin,避免测试间相互影响。JUnit 5 的
@BeforeEach和@AfterEach是理想的选择。
总结一下:Koin 让依赖注入变得简单而优雅。模块定义清晰,作用域管理灵活,测试支持完善。我个人用了三年多,从没后悔过这个选择。如果你还在纠结用哪个 DI 框架,不妨试试 Koin,它不会让你失望。