Kotlin
A concise multiplatform language developed by JetBrains
模块化 Ktor:构建可扩缩后端
Ktor 提供了一种轻量级的灵活方式来构建 Web 应用程序,不同于更固执的一体化框架。 虽然 Ktor 的简约设计初看起来可能很有挑战性,但只要有一点使用我们模块的经验,就会对可扩缩构建大有帮助。
在本文中,我会介绍一些将模块化引入 Ktor 项目的技术。
模块化为什么重要
Ktor 仍然是服务器端开发的热门选择,原因之一是其实现的直接性和透明度。 无需元编程或大量配置文件,您可以编写仅限于几个 Kotlin 源文件的简单服务,还能够始终清楚地知道自己正在使用 HTTP 服务器。
这种方式非常适合简单的应用程序,但在发展项目时有必要预测未来需求并减轻其影响。 为此,最好的方式是将应用程序分割成小的独立部分,从而可以在不修改整体的情况下替换相关的功能片段。 换句话说,为了从容扩缩项目的复杂性,我们引入了模块化。
大多数应用程序框架都包含一些对模块化的一流支持 – 通常是通过一些中央注册表来包含一些功能。 Ktor 服务器框架也不例外,下一节我将介绍 Ktor 中应用程序模块的概念。
Ktor 中的模块
首先,我们来介绍一下基础知识。
1. 什么是 Ktor 模块?
简单来说,就像这样:
fun Application.moduleName() { // Add a little functionality }
使用 Ktor 3.2,您还可以将 suspend 函数用作模块:
suspend fun Application.moduleName() { // Add some functionality with coroutines! }
无论哪种方式,模块都只是服务器启动时运行的 Application
的扩展函数。
现在,您可能想知道…
2. 什么是 Application?
这是一个容器,容纳处理请求时执行的所有业务逻辑挂钩。 例如,我们可以在管道开始时注入一些日志记录,或者在响应中插入一些用于序列化对象的逻辑。
服务器重启时,Application
会重新创建,并且所有 Ktor 模块都会重新应用。 在开发模式下运行时进行更改后也会发生这种情况,环境和引擎配置保持不变。
3. 我可以用 Ktor 模块做什么?
您可以直接从 EmbeddedServer
实例的 Application
上下文引用模块:
fun main() { embeddedServer(CIO, port = 8080, host = "0.0.0.0", module = Application::module) .start(wait = true) } fun Application.module() { routing { get("/") { call.respond("Hello, World!") } } }
或者,您也可以从配置文件中引用模块:
# application.yaml ktor: application: modules: - com.example.ApplicationKt.module
通过从配置引用模块,我们可以在运行时更改服务器的行为。 例如,我们可以仅在生产中包含一些更强大的安全功能,或者为测试环境包含一些调试挂钩。 为了正确利用这一功能,最好将模块拆分成更小的单元。
精细模块提供了更大的灵活性,但如果许多模块依赖于一段特定代码,则可能会带来挑战。 这些共同要求可以在模块之间重复,或者,我们可以通过 Application
实例共享这些数据,在模块间引入依赖关系。
模块通常通过依赖关系相互关联,但没有明显的方式来应用这些关联。 下一节,我会说明如何将模块挂接在一起。
关联模块
通过模块函数的签名,很难看出模块之间有什么联系:
fun Application.module(): Unit
既没有实参,也没有返回值类型,平台怎么可能把它们凑到一起?
1. 使用堆栈
将模块联系起来的最简单、最自然的方式就是像对待其他函数调用一样对待模块:
fun main() { embeddedServer(CIO, port = 8080, host = "0.0.0.0") { // Instantiate your dependency val myService = MyService(property()) // Inject it into your modules as a parameter routingModule(myService) schedulingModule(myService) }.start(wait = true) }
这种方式适用于任何规模的应用程序,唯一需要注意的是,现在我们的模块在编译时强耦合,因此它们在运行时不再可互换。
在单个仓库中工作时,您不太可能需要可互换模块,但是扩展到多个仓库和团队时,使用它们可以减少协调开销。 为了避免这种僵化,我们可以通过通用容器传递依赖项,消除模块间引用。
2. Application 特性
Ktor 包含一个内置容器,用于在模块和插件之间传递引用。 它位于 Application.attributes
属性中,实际上只是保留了一些额外类型信息的 Map
。
以下示例展示了如何使用特性映射:
val connectionFactoryKey = AttributeKey("ConnectionFactory") // Declaring fun Application.database() { attributes[connectionFactoryKey] = PostgresConnectionFactory() } // Resolving fun Application.service() { val connectionFactory = attributes[connectionFactoryKey] // Use the connection factory in our service }
借助这种关联不同模块的方式,我们可以避免模块之间的直接引用,从而创建松散的耦合。 为此,我们可以使用形参化特性键通过通用映射共享实现。 这就使我们的特性属性成为基本的控制反转 (IoC) 容器。
这种技术用途相当广泛,但也有一些缺点:
- 它需要一个位于我们服务器作用域之外的共享“key”变量。
- 模块现在是时间耦合的,也就是必须按照特定顺序引用。
- 对进出映射的内容没有任何跟踪。
- 一切都是手动操作,即不支持反射。
- 没有办法管理这些服务的生命周期。
总而言之,使用简单的实例映射可以做很多事情,但在处理较大的项目时,它就有些不足了。
3. 外部库
为了解决这些痛点,我们可以看向一些流行的 Kotlin IoC 库。 本文不会详细介绍哪个库解决了哪些问题,但在使用 Kotlin 时有很多选择。
下面是一些 IoC 库的示例,它们允许使用声明式方式在 Ktor 中的模块之间进行共享:
Kodein
KodeinKoders 开发的开源库,为共享实例提供了一种简单的声明式方式。
// Declaring fun Application.database() { di { bind { singleton { PostgresConnectionFactory() } } } } // Resolving fun Application.service() { val connectionFactory by closestDI().instance() // Use the connection factory in our service }
Koin
Kotzilla 开发的流行且实用的开源框架,采用了相当类似的方式。
// Declaring fun Application.database() { koinModule { singleOf { PostgresConnectionFactory() } } } // Resolving fun Application.service() { val connectionFactory by inject() // Use the connection factory in our service }
除了这些声明式多平台库之外,还有几个使用注解和编译器插件注入类的库:
现在,您可能会感觉选项有点太多了。 毕竟,使用一体化框架时,一切都已内置,您不必考虑选择外部手段来共享代码。 因此,我们在最新版本中为 Ktor 服务器应用程序引入了新的 DI 插件来解决这种心理开销。
Ktor 的依赖项注入
新插件的目标是简化模块间依赖项的管理。 为此,我们着重改善开发者体验并引入深度平台集成。
使用新插件,您可以通过多种选项在模块之间共享实例:
- 插件 DSL
- 文件配置
- 模块形参
下面举例说明这些技术如何运作。
插件 DSL
如果您习惯使用上述库,那么 DSL 可能是您最熟悉的。
下面是上述示例使用 Ktor DI 插件:
// Declaring fun Application.database() { dependencies { provide { PostgresConnectionFactory() } } } // Resolving suspend fun Application.service() { val connectionFactory: ConnectionFactory = dependencies.resolve() }
我们还允许从类引用声明类型。 这样,类型就会自动创建,其构造函数实参将从其他声明的类型中填充。 任何类的协变类型也可以通过这种方式从注册表中解析,因此您不必担心声明方面的抽象。
以下是如何从类引用声明实例:
fun Application.database() { dependencies { provide(PostgresConnectionFactory::class) } }
现在,我们将展示如何使用这些引用在运行时更改功能!
文件配置
如前文所示,您可以通过交换文件配置中的模块来修改服务器的行为。 DI 插件采用了类似的方式,但针对的是特定类型。
考虑到上一个使用类引用的连接工厂示例,我们现在可以从配置文件中执行相同的操作:
# application.yaml ktor: application: dependencies: - com.example.PostgresConnectionFactory
您还可以引用返回您感兴趣的类型的函数:
ktor: application: dependencies: - com.example.Postgres#createConnectionFactory db: url: postgres://localhost:3456
实际函数类似于:
fun createConnectionFactory(@Property("db.url") url: String): ConnectionFactory { return PostgresConnectionFactory(url) }
如示例函数所示,配置中的属性现在可以直接作为形参导入。 您可以导入 String 和 Int 等基本类型,也可以导入标有 @Serializable
注解的更复杂的数据类型。
模块形参
您不仅可以注入提供实例的函数的形参,还可以注入模块函数本身。
对于任意模块,您可以从以下位置导入形参:
- 您的文件配置,使用
@Property
注解。 - 特殊类型,例如
Application
、ApplicationEnvironment
或ApplicationConfig
- 任何声明的依赖项。
下面是我们的连接工厂示例:
fun Application.service(connectionFactory: ConnectionFactory) { // Use the connection factory in our service }
平台将自动处理 connectionFactory
的注入,您引用模块的顺序并不重要。 相同的注入适用于从文件配置引用的依赖项,因此您可以将它们串联在一起,构建更复杂的依赖项树。
值得注意的是,由于这种注入依赖于类型反射,因此仅适用于 JVM。
使用 DI 测试
分离模块的一大优势是它可以简化测试。
使用 Ktor 3.2 中的 DI 插件,您可以轻松模拟组件:
fun `can fetch messages`() = testApplication { // replace the production implementation with a mock dependencies { provide { MockConnectionFactory() } } // load modules from your default application.conf configure() val messageList: List = client.get("/messages").body() assertEquals(listOf(expectedMessage), messageList) }
它的运作方式是在测试应用程序上下文中提供另一种冲突策略,从而保留初始依赖项。 需要记住的是,模拟应该在生产模块之前进入。
使用 DI 扩展
有了在模块间共享代码的可靠方式,我们可以轻松地将项目拆分成小的 Gradle 模块。 我们不必将所有服务器逻辑都放在一个项目中,而是可以隔离不同的集成点,从而有机会使用以域为中心的架构,如六边形架构或简洁架构。
如果继续前面的示例,我们可以考虑将 postgres
连接工厂完全从编译类路径中排除。
假设我们有一个这样的项目结构:
db ├─ core ├─ postgres └─ mongo server ├─ core ├─ admin └─ banking
现在,我们可以在 server/core
下共享一些通用服务器模块,并更加严格地关注较小服务中的域逻辑。
例如,在 server/banking
下,我们的 build.gradle.kts
文件将如下所示:
plugins { id("io.ktor.plugin") version "3.2.0" } dependencies { implementation(":server:core") implementation(":db:core") runtimeOnly(":db:postgres") runtimeOnly(":db:mongo") }
然后,我们可以推迟选择存储实现,直到应用程序部署,并读取配置文件。
为此,我们只需这样编辑 application.yaml
:
ktor: application: dependencies: # Taken from modules db/* - com.example.MockConnectionFactory # - com.example.MongoConnectionFactory # - com.example.PostgresConnectionFactory modules: # Taken from server/core - com.example.Authentication.configureAuth # My domain service - com.example.Banking.configureBankRoutes
由于实现在我们的编译类路径中是隐藏的,我们可以避免用实现细节污染我们的服务,同时为应用程序的运行方式引入多种选择。
总结
在本文中,我们介绍了在 Ktor 中使用模块的基础知识,以及如何利用依赖项注入工具在它们之间共享代码。
总结:
- Ktor 模块是
Application
类的可替换扩展函数。 - 模块可用于管理不断发展的应用程序的复杂性。
- 模块之间可以通过特性或 DI 库间接共享代码。
- Ktor 现在包含一个开箱即用的强大 DI 插件。
以下是使用新工具的项目示例:
https://github.com/ktorio/ktor-chat/
如果您对本文中的任何主题有任何想法,可以在这里留言,也可以前往官方 Kotlin Slack 频道联系我们。
本博文英文原作者: