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 频道联系我们。
本博文英文原作者:
Subscribe to Kotlin Blog updates
Discover more
Modular Ktor: Building Backends for Scale
Ktor offers a lightweight, flexible approach to building web applications that differs from more opinionated all-in-one frameworks. While Ktor’s minimalist design might seem challenging at first, a little experience using our modules can go a long way towards building for scale.
In this article, I’ll show you some techniques for introducing modularity to your Ktor projects.
Why modularity matters
One of the reasons Ktor remains a popular choice for server-side development is the directness and transparency of its implementations. Without the need for meta-programming or a host of configuration files, you can write simple services constrained to just a few Kotlin source files and it’s always clear that you’re working with an HTTP server.
This approach is great for simple applications, but when growing projects, it is important to anticipate future needs and mitigate their impact. The best way to accomplish this is break up your application into small, isolated parts, so that you can replace relevant pieces of functionality without modifying the whole thing. In other words, in order to gracefully scale the complexity of our project, we introduce modularity.
Most application frameworks include some first-class support for baking in modularity — usually by having some central registry for including bits of functionality. The Ktor server framework is no exception to this, and in the next section I’ll introduce the concept of application modules in Ktor.
Modules in Ktor
To start, let’s cover the basics.
1. What is a Ktor module?
To put it simply, it’s one of these:
fun Application.moduleName() {
// Add a little functionality
}
And with Ktor 3.2, you’ll also be able to use suspend functions as modules:
suspend fun Application.moduleName() {
// Add some functionality with coroutines!
}
Either way, a module is just an extension function for an Application that runs as the server starts.
Now, you might be wondering…
2. What is an Application?
This is the container that holds all the hooks for business logic that execute when a request is handled. For example, we can inject some logging at the start of our pipeline, or we can insert some logic for serializing objects in the response.
When the server is restarted, the Application is created anew and all the Ktor modules are reapplied. This also happens after changes are made while running in development mode, with the environment and engine configuration remaining untouched.
3. What can I do with Ktor modules?
You can reference modules directly from the context of the Application from an EmbeddedServer instance:
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!")
}
}
}
Or you can reference modules from your configuration file:
# application.yaml
ktor:
application:
modules:
- com.example.ApplicationKt.module
Referencing modules from our configuration allows us to change the behavior of our server during runtime. For example, we can include some stronger security features only in production, or include some debugging hooks for our test environments. To properly leverage this functionality, it’s best to split your modules up into smaller units.
Granular modules provide greater flexibility, but can present a challenge when many of them rely on some specific piece of code. These common requirements can be duplicated across modules, or we can introduce dependencies between our modules by sharing this data through the Application instance.
Modules will often be related via dependencies, but there’s no obvious way to apply these connections. In the next section, I’ll describe how to hook modules together.
Connecting modules
Reviewing the signature of the module function, it is difficult to see how modules can relate to one another:
fun Application.module(): Unit
There aren’t any arguments or return types, so how can the platform possibly piece them together?
1. Use the stack
The simplest and most natural way to relate modules is by treating them exactly as you would any other function call:
fun main() {
embeddedServer(CIO, port = 8080, host = "0.0.0.0") {
// Instantiate your dependency
val myService = MyService(property<MyServiceConfig>())
// Inject it into your modules as a parameter
routingModule(myService)
schedulingModule(myService)
}.start(wait = true)
}
This approach can work well for applications of any size, with the sole caveat that our modules are now strongly coupled at compile time, so they’re no longer interchangeable at runtime.
When working in a single repository, you’re unlikely to need interchangeable modules, but when scaling out to multiple repositories and teams, using them can be an asset for reducing coordination overhead. To get around this rigidity, we can consider removing inter-module references by passing dependencies through a common container.
2. Application Attributes
Ktor includes a built-in container for passing references between modules and plugins. You can find it in the Application.attributes property, which is really just a Map<String, Any> that retains some extra type information.
Here is an example to show how the attributes map can be used:
val connectionFactoryKey = AttributeKey<ConnectionFactory>("ConnectionFactory")
// Declaring
fun Application.database() {
attributes[connectionFactoryKey] = PostgresConnectionFactory()
}
// Resolving
fun Application.service() {
val connectionFactory = attributes[connectionFactoryKey]
// Use the connection factory in our service
}
With this method of connecting different modules, we create loose coupling by avoiding direct references between our modules. We can achieve this using parameterized attribute keys for sharing implementations through a common map. This qualifies our attributes property as a basic inversion of control (IoC) container.
This technique can be fairly versatile, but it has a few disadvantages:
- It requires a shared “key” variable that lives outside the scope of our server.
- Modules are now temporally coupled, i.e., they must be referenced in a specific order.
- There is no tracking of what goes into or comes out of the map.
- Everything is manual, i.e., there is no support for reflection.
- There is no way to manage the lifecycle of these services.
In summary, you can do quite a bit with a simple map of instances, but it falls short when working with larger projects.
3. External libraries
To address some of these pain points, we can look at some popular Kotlin IoC libraries. In this article, I won’t go into the details of which library solves which problems, but there are plenty of options when working in Kotlin.
Here are some examples of IoC libraries that allow sharing between modules in Ktor using a declarative approach:
Kodein
An open-source library developed by KodeinKoders to provide a simple declarative means for sharing instances.
// Declaring
fun Application.database() {
di {
bind<ConnectionFactory> {
singleton {
PostgresConnectionFactory()
}
}
}
}
// Resolving
fun Application.service() {
val connectionFactory by closestDI().instance<ConnectionFactory>()
// Use the connection factory in our service
}
Koin
A popular and pragmatic open-source framework by Kotzilla that uses a fairly similar approach.
// Declaring
fun Application.database() {
koinModule {
singleOf<ConnectionFactory> {
PostgresConnectionFactory()
}
}
}
// Resolving
fun Application.service() {
val connectionFactory by inject<ConnectionFactory>()
// Use the connection factory in our service
}
Apart from these declarative, multi-platform libraries, there are also several libraries made for injecting classes using annotations and compiler plugins:
At this point, you might feel a little overwhelmed with options. After all, when using all-in-one frameworks, everything you need is built in, and you don’t have to worry about choosing some external means for sharing code. This is why in our latest release, we’ve worked to resolve this mental overhead by introducing a new DI plugin for Ktor server applications.
Ktor’s dependency injection
The goal of the new plugin is to simplify the management of inter-module dependencies. To facilitate this, we focused on improving the developer experience and introducing deep platform integration.
With the new plugin, there are a few options for sharing instances between your modules:
- The plugin DSL
- File configuration
- Module parameters
Here are some examples of how each of these techniques work.
Plugin DSL
If you’re accustomed to the libraries described above, the DSL will probably be most familiar to you.
Here’s the same example from above using the Ktor DI plugin:
// Declaring
fun Application.database() {
dependencies {
provide<ConnectionFactory> { PostgresConnectionFactory() }
}
}
// Resolving
suspend fun Application.service() {
val connectionFactory: ConnectionFactory = dependencies.resolve()
}
We also allow for declaring types from class references. This way, types are created automatically, and their constructor arguments will be populated from other declared types. Any of the class’s covariant types can also be resolved from the registry this way, so you don’t have to worry about the abstraction on the declaration side.
Here’s how to declare your instances from class references:
fun Application.database() {
dependencies {
provide(PostgresConnectionFactory::class)
}
}
Now, we’ll show how to use these references for changing functionality at runtime!
File configuration
As shown earlier in the article, you can modify the behavior of your server by swapping modules in your file configuration. The DI plugin accommodates a similar approach, but for targeting specific types.
Considering our last connection factory example using class references, we can now do the same from our configuration file:
# application.yaml
ktor:
application:
dependencies:
- com.example.PostgresConnectionFactory
You can also reference functions that return the types you’re interested in:
ktor:
application:
dependencies:
- com.example.Postgres#createConnectionFactory
db:
url: postgres://localhost:3456
Where the actual function looks something like:
fun createConnectionFactory(@Property("db.url") url: String): ConnectionFactory {
return PostgresConnectionFactory(url)
}
As you can see in the example function, properties from your configuration can now be imported directly as parameters. You can import basic types like String and Int, or you can import more complex data types marked with the @Serializable annotation.
Module parameters
Not only can you inject the parameters of instance-providing functions, but you can also inject the module functions themselves.
For any module, you can import parameters from:
- Your file configuration, using the
@Propertyannotation. - Special types, like
Application,ApplicationEnvironment, orApplicationConfig - Any declared dependency.
Here’s how our connection factory example looks:
fun Application.service(connectionFactory: ConnectionFactory) {
// Use the connection factory in our service
}
The platform will handle the injection of connectionFactory automatically, and it doesn’t matter what order you reference your modules in. The same injection works with dependencies referenced from your file configuration, so you can chain them together to construct more complex dependency trees.
It’s important to note that, since this injection relies on type reflection, it will only be available for the JVM.
Testing with DI
One major advantage of decoupling your modules is that it simplifies testing.
Using the DI plugin in Ktor 3.2, you can mock out components with ease:
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<Message> = client.get("/messages").body()
assertEquals(listOf(expectedMessage), messageList)
}
This works by providing an alternative conflict policy in the test application context, so initial dependencies are retained. All you need to remember is that your mocks should go in before your production modules.
Scaling with DI
Now that we have a reliable way to share code between modules, we can easily start splitting our projects into small Gradle modules. Instead of keeping all of our server logic in a single project, we can isolate different integration points, providing the opportunity to use domain-centric architectures, like the hexagonal or clean.
If we continue with our previous example, we can consider excluding the postgres connection factory from our compilation classpath entirely.
Let’s say we have a project structure like this:
db ├─ core ├─ postgres └─ mongo server ├─ core ├─ admin └─ banking
Now, we can share some common server modules under server/core and focus more strictly on domain logic in our smaller services.
Under server/banking, for example, our build.gradle.kts file would look like this:
plugins {
id("io.ktor.plugin") version "3.2.0"
}
dependencies {
implementation(":server:core")
implementation(":db:core")
runtimeOnly(":db:postgres")
runtimeOnly(":db:mongo")
}
Then we can defer choosing our storage implementation right up until our application is deployed and reads the configuration file.
For this, we simply edit application.yaml like so:
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
Since the implementations are hidden from our compile classpath, we can avoid polluting our services with implementation details while introducing several options for how our application operates.
Wrapping up
In this article, we’ve covered the basics of working with modules in Ktor and how to leverage dependency injection tools for sharing code between them.
To summarize:
- Ktor modules are replaceable extension functions for the
Applicationclass. - Modules can be used to manage complexity in a growing application.
- Code can be shared between modules indirectly via attributes or a DI library.
- Ktor now includes a robust DI plugin out of the box.
Here is an example of a project that uses the new tooling:
https://github.com/ktorio/ktor-chat/
If you have any thoughts on any of the topics in this article, you can leave a comment here or find us in the official Kotlin Slack channel.
