Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

Best Practices Ktor

模块化 Ktor:构建可扩缩后端

Read this post in other languages:

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) 容器。

这种技术用途相当广泛,但也有一些缺点:

  1. 它需要一个位于我们服务器作用域之外的共享“key”变量。
  2. 模块现在是时间耦合的,也就是必须按照特定顺序引用。
  3. 对进出映射的内容没有任何跟踪。
  4. 一切都是手动操作,即不支持反射。
  5. 没有办法管理这些服务的生命周期。

总而言之,使用简单的实例映射可以做很多事情,但在处理较大的项目时,它就有些不足了。

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 插件来解决这种心理开销。

强制 XKCD 引用。

Ktor 的依赖项注入

新插件的目标是简化模块间依赖项的管理。 为此,我们着重改善开发者体验并引入深度平台集成。

使用新插件,您可以通过多种选项在模块之间共享实例:

  1. 插件 DSL
  2. 文件配置
  3. 模块形参

下面举例说明这些技术如何运作。

插件 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 注解的更复杂的数据类型。

模块形参

您不仅可以注入提供实例的函数的形参,还可以注入模块函数本身。

对于任意模块,您可以从以下位置导入形参:

  1. 您的文件配置,使用 @Property 注解。
  2. 特殊类型,例如 ApplicationApplicationEnvironmentApplicationConfig
  3. 任何声明的依赖项。

下面是我们的连接工厂示例:

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 中使用模块的基础知识,以及如何利用依赖项注入工具在它们之间共享代码。

总结:

  1. Ktor 模块是 Application 类的可替换扩展函数。
  2. 模块可用于管理不断发展的应用程序的复杂性。
  3. 模块之间可以通过特性或 DI 库间接共享代码。
  4. Ktor 现在包含一个开箱即用的强大 DI 插件。

以下是使用新工具的项目示例:

https://github.com/ktorio/ktor-chat/

如果您对本文中的任何主题有任何想法,可以在这里留言,也可以前往官方 Kotlin Slack 频道联系我们。

本博文英文原作者:

Bruce Hamilton

Bruce Hamilton

image description

Discover more