Spring现在支持使用Kotlin来开发,可以充分利用Kotlin语言的简洁性,以及在使用Gradle插件时,也可以使用Kotlin来替代Groovy编写配置文件。

JetBrains推出的Ktor也已经到了1.4.0版本,充分利用了Kotlin语言中的协程特性和函数式编程模型。我们可以再加上R2DBC的使用,便可以构建出异步、非阻塞、反应式的整套API开发流程。

Ktor中的返回请求响应数据就是一个suspend函数,如:

suspend inline fun ApplicationCall.respond(message: Any) {
    response.pipeline.execute(this, message)
}

下面就以Ktor官网的一个HTTP API教程为例子,实战一下API的编写和打包发布到Docker容器中。

使用Ktor创建API

使用IntelliJ IDEA (Ultimate版本)安装Ktor插件后,新建项目,配置 Project SDK 为 JDK 8、使用 Gradle 做依赖管理、使用 Netty 及最新版的 Ktor。

生成的项目里,src/Application.kt 文件。你会看到 Ktor 项目模板已经帮你配置好 Jackson 并生成 2 个 Route。点击 main 左边的绿色播放键将 Ktor 运行起来,打开浏览器,输入 127.0.0.1:8080,可以看到显示 HELLO WORLD! 字符串。若是开启 127.0.0.1:8080/json/jackson 则可以看到显示 key 为 hello、value 为 world 的 JSON 格式。

路由

Spring 5支持的函数式编程模型,使用RouterFunction来声明如何将请求路由到处理器代码中,在Ktor中使用routing{ } ,这是Ktor创建的DSL,进一步简化我们的编写,下面看一下对比:

  @Bean
   public RouterFunction<?> routerFunction() {
     return route(GET("/design/taco"),this::recents)
          .andRoute(POST("/design"),this::postTaco); 
   }

使用Ktor中的路由DSL,返回一个自定义的数据类型:

data class Snippet(val text: String)

val snippets = Collections.synchronizedList(mutableListOf(
    Snippet("hello"),
    Snippet("world")
))

fun Application.module() {
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT) // 美化输出 JSON
        }
    }
    routing {
        get("/snippets") {
            call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
        }
    }
}

关于序列化的内容,除了上面的Jakson之外,建议使用 Kotlinx.serialization,在Kotlin 1.4 版本已经正式放出,

可以参考:JSON support using kotlinx.serialization

组合多个HTTP方法

data class PostSnippet(val snippet: PostSnippet.Text) {
    data class Text(val text: String)
}

// ……

routing {
    get("/snippets") {
        call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
    }
    post("/snippets") {
        val post = call.receive<PostSnippet>()
        snippets += Snippet(post.snippet.text)
        call.respond(mapOf("OK" to true))
    }
}

我们可以使用 route(path) { } 块将具有相同前缀的路由分组。对于每个 HTTP 方法,都有一个不带路由路径参数的重载,可以用于路由的叶子节点:

routing {
    route("/snippets") {
        get {
            call.respond(mapOf("snippets" to synchronized(snippets) { snippets.toList() }))
        }
        post {
            val post = call.receive<PostSnippet>()
            snippets += Snippet(post.snippet.text)
            call.respond(mapOf("OK" to true))
        }
    }
}

测试API

除了可以在浏览器直接输入地址端口和路径参数,还可以使用像是Postman、PostWoman或者curl。

但是IntelliJ IDEA Ultimate包含一个很好的基于编辑器的 Rest 客户端

首先创建一个HTTP Request文件,然后在文件内创建get或者post请求,可以使用上面的一些快捷菜单生成代码。

然后就可以点击方法左边的按钮运行请求,会输出请求内容和响应内容

JWT认证

gradle添加JWT:

implementation "io.ktor:ktor-auth-jwt:$ktor_version"

添加认证类:Auth

object Auth {
    private const val SECRET_KEY = "secret"
    private val algorithm = Algorithm.HMAC512(SECRET_KEY)
    private const val issuer = "ktor.io"
    private const val validityInMs = 3600*1000 * 10 // 10 hours

    fun makeJwtVerifier(): JWTVerifier = JWT
        .require(algorithm)
        .withIssuer(issuer)
        .build()

    fun sign(name: String): Map<String, String> {
        return mapOf("token" to makeToken(name))
    }

    private fun makeToken(name: String): String = JWT.create()
        .withSubject("Authentication")
        .withIssuer(issuer)
        .withClaim("name", name)
        .withExpiresAt(getExpiration())
        .sign(algorithm)

    private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)

}

在启动类中启用jwt

private val verifier = Auth.makeJwtVerifier()
install(Authentication) {
        jwt {
            verifier(verifier)
            validate {
                UserIdPrincipal(it.payload.getClaim("name").asString())
            }
        }
    }

登陆时进行校验,校验成功后返回token

routing {
    post("login") {
        val user = call.receive<User>()
        //TODO:校验用户、密码有效性的代码自己写
        call.respond(Auth.sign(user.name))
    }
}

对需要认证后才能访问的接口添加认证关键字authenticate(下面样例是用于访问secret页面的)

routing {
    authenticate {
        route("secret") {
            get {
                val user = call.authentication.principal<UserIdPrincipal>()
                call.respondText("hi ${user?.name}, you are authenticated.", contentType = ContentType.Text.Plain)
            }
        }
    }
}

API打包发布到Docker容器

使用 Gradle 打包应用

我们会使用 Gradle shadow 插件。 它将所有输出的类、资源以及所有必需的依赖项打包到一个 JAR 文件中, 并追加一个清单文件来告诉 Java 哪个是包含 main 方法的入口点主类

首先,需要在 build.gradle 文件中添加 shadow 插件依赖:

buildscript {
    ...
    repositories {
        ...
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        ...
        classpath "com.github.jengelman.gradle.plugins:shadow:2.0.1"
    }
}

之后,必须应用它以及 application 插件:

apply plugin: "com.github.johnrengelman.shadow"
apply plugin: 'application'

然后指定主类,这样它才知道当在 Docker 内部运行该 JAR 包时要运行什么:

mainClassName = 'org.sample.ApplicationKt'

该字符串是包含 main 函数的类的完整限定名。 当 main 函数是文件中的顶层函数时,类名是带有 Kt 后缀的文件名。 在上述示例中,main 函数在 org.sample 包中的 Application.kt 文件中。

最后,必须配置 shadow 插件:

shadowJar {
    baseName = 'my-application'
    classifier = null
    version = null
}

现在可以运行 ./gradlew build 来构建并打包应用了。 应该可以在 build/libs 文件夹中获取 my-application.jar

关于配置这个插件的更多信息,请参见该插件的文档

因此一个完整的 build.gradle 文件会如下所示:

buildscript {
    repositories {
        jcenter()
    }
  
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "com.github.jengelman.gradle.plugins:shadow:6.0.0"
    }
}

apply plugin: 'kotlin'
apply plugin: "com.github.johnrengelman.shadow"
apply plugin: 'application'

group 'com.hellocyc'
version '0.0.1'
mainClassName = "io.ktor.server.netty.EngineMain"

sourceSets {
    main.kotlin.srcDirs = main.java.srcDirs = ['src']
    test.kotlin.srcDirs = test.java.srcDirs = ['test']
    main.resources.srcDirs = ['resources']
    test.resources.srcDirs = ['testresources']
}

repositories {
    mavenLocal()
    jcenter()
    maven { url 'https://kotlin.bintray.com/ktor' }
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "io.ktor:ktor-server-netty:$ktor_version"
    implementation "ch.qos.logback:logback-classic:$logback_version"
    implementation "io.ktor:ktor-server-core:$ktor_version"
    implementation "io.ktor:ktor-jackson:$ktor_version"
    implementation "io.ktor:ktor-auth-jwt:$ktor_version"

    implementation "io.ktor:ktor-html-builder:$ktor_version"
    testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}

shadowJar {
    baseName = 'my-application'
    classifier = null
    version = null
}

准备 Docker 镜像

在项目的根文件夹中创建一个名为 Dockerfile 的文件,其内容如下:

FROM openjdk:8-jre-alpine

ENV APPLICATION_USER ktor
RUN adduser -D -g '' $APPLICATION_USER

RUN mkdir /app
RUN chown -R $APPLICATION_USER /app

USER $APPLICATION_USER

COPY ./build/libs/my-application.jar /app/my-application.jar
WORKDIR /app

CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseCGroupMemoryLimitForHeap", "-XX:InitialRAMFraction=2", "-XX:MinRAMFraction=2", "-XX:MaxRAMFraction=2", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "my-application.jar"]

我们来看下都是什么:

FROM openjdk:8-jre-alpine

这行告诉 Docker 使用以 Alpine Linux 预构建的镜像作为基础镜像。也可以使用 OpenJDK registry 中的其他镜像。Alpine Linux 的好处是镜像非常小。 我们选择的也是只有 JRE 的镜像,因为我们并不需要在镜像中编译代码,只需要运行预编译的类。

RUN mkdir /app
COPY ./build/libs/my-application.jar /app/my-application.jar
WORKDIR /app

这几行将已打包的应用复制到 Docker 镜像中,并将工作目录设置为复制后的位置。

CMD ["java", "-server", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseCGroupMemoryLimitForHeap", "-XX:InitialRAMFraction=2", "-XX:MinRAMFraction=2", "-XX:MaxRAMFraction=2", "-XX:+UseG1GC", "-XX:MaxGCPauseMillis=100", "-XX:+UseStringDeduplication", "-jar", "my-application.jar"]

最后一行指示 Docker 使用 G1 GC、4G 内存以及已打包的应用来运行 java

构建并运行 Docker 镜像

构建应用包:

./gradlew build

构建并标记镜像:

docker build -t my-application .

启动镜像:

docker run -m512M --cpus 2 -it -p 8080:8080 --rm my-application

通过这个命令,我们启动 Docker 进入前台模式。 它会等待服务器退出, 也会响应 Ctrl+C 来停止服务。 -it 指示 Docker 分配一个终端(tty)来管理标准输出(stdout) 并响应中断键序列。

由于我们的服务器现在是在一个隔离的容器中运行,因此我们应该告诉 Docker 暴露一个端口,以便我们可以实际访问到服务器端口。 参数 -p 8080:8080 告诉 Docker 将容器内部的 8080 端口发布为本机的 8080 端口。 因此,当告诉浏览器访问 localhost:8080 时,它会首先连向 Docker,然后为应用将其桥接到内部端口 8080

可以通过 -m512M 调整内存并通过 --cpus 2 调整所暴露 cpu 数。

默认情况下,容器的文件系统在容器退出后仍然存在,所以我们提供 --rm 选项以防垃圾堆积。

关于运行 docker 镜像的更多信息,请参考 docker run 文档。

上传 docker 镜像

一旦应用能在本地成功运行,那么可能就到部署它的时候了:

docker tag my-application hub.example.com/docker/registry/tag
docker push hub.example.com/docker/registry/tag

这两条命令会为 registry 创建应用的标记并将镜像上传。 当然,需要用你的 registry 的实际 URL 来替换 hub.example.com/docker/registry/tag

我们这里不会详细展开,因为你的配置可能需要身份认证、特殊配置甚至特殊工具。请咨询你的组织或者云平台,或者查阅 docker push 文档。

关于返回值的异步生成

在上面的Ktor应用代码中,返回数据是直接生成的,官方有一个例子:RX

在获取返回的数据时,使用Rx代码如下:

package io.ktor.samples.rx

import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.reactivex.*
import kotlinx.coroutines.*
import kotlinx.coroutines.reactive.*
import java.util.concurrent.*

/**
 * Documentation: https://github.com/Kotlin/kotlinx.coroutines/tree/master/reactive/kotlinx-coroutines-rx2
 */
fun main(args: Array<String>) {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/") {
                val result = Flowable.range(1, 10)
                    .map { it * it }
                    .delay(300L, TimeUnit.MILLISECONDS)
                    .awaitLast()

                call.respondText("LAST ITEM: $result")
            }
            get("/iter") {
                call.respondTextWriter(ContentType.Text.Plain) {
                    val writer = this
                    Flowable.range(1, 10)
                        .map { it * it }
                        .delay(300L, TimeUnit.MILLISECONDS)
                        .collect {
                            writer.write("$it,")
                            writer.flush()
                            delay(100L)
                        }
                }
            }
        }
    }.start(wait = true)
}

但是推荐直接使用协程API,官方也给出了使用异步的例子:

Example of Asynchronous Responses

package io.ktor.samples.async

import kotlinx.html.*
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.html.*
import io.ktor.routing.*
import kotlinx.coroutines.*
import java.util.*
import kotlin.system.*

/**
 * A dedicated context for sample "compute-intensive" tasks.
 */
val compute = newFixedThreadPoolContext(4, "compute")

/**
 * An alias to simplify a suspending functional type.
 */
typealias DelayProvider = suspend (ms: Int) -> Unit

/**
 * The main entry point. We use @JvmOverloads so there is a signature with `Application.main()` that will be located
 * via reflection. This function is referenced by the application.conf file.
 *
 * We pass several arguments with services and providers so we can provide mocks when doing integration tests.
 *
 * For more information about this file: https://ktor.io/servers/configuration.html#hocon-file
 */
@JvmOverloads
fun Application.main(random: Random = Random(), delayProvider: DelayProvider = { delay(it.toLong()) }) {
    // This adds automatically Date and Server headers to each response, and would allow you to configure
    // additional headers served to each response.
    install(DefaultHeaders)
    // This uses use the logger to log every call (request/response)
    install(CallLogging)

    // Registers routes
    routing {
        // Tabbed browsers can wait for first request to complete in one tab before making a request in another tab.
        // Presumably they assume second request will hit 304 Not Modified and save on data transfer.
        // If you want to verify simultaneous connections, either use "curl" or use different URLs in different tabs
        // Like localhost:8080/1, localhost:8080/2, localhost:8080/3, etc
        get("/{...}") {
            val startTime = System.currentTimeMillis()
            call.respondHandlingLongCalculation(random, delayProvider, startTime)
        }
    }
}

/**
 * Function that will perform a long computation in a threadpool generating random numbers
 * and then will respond with the result.
 */
private suspend fun ApplicationCall.respondHandlingLongCalculation(random: Random, delayProvider: DelayProvider, startTime: Long) {
    val queueTime = System.currentTimeMillis() - startTime
    var number = 0
    val computeTime = measureTimeMillis {
        // We specify a coroutine context, that will use a thread pool for long computing operations.
        // In this case it is not necessary since we are "delaying", not sleeping the thread.
        // But serves as an example of what to do if we want to perform slow non-asynchronous operations
        // that would block threads.
        withContext(compute) {
            for (index in 0 until 300) {
                delayProvider(10)
                number += random.nextInt(100)
            }
        }
    }

    // Responds with an HTML file, generated with the kotlinx.html DSL.
    // More information about this DSL: https://github.com/Kotlin/kotlinx.html
    respondHtml {
        head {
            title { +"Ktor: async" }
        }
        body {
            p {
                +"Hello from Ktor Async sample application"
            }
            p {
                +"We calculated number $number in $computeTime ms of compute time, spending $queueTime ms in queue."
            }
        }
    }
}

总结

上面还未设计到R2DBC数据库的使用,后续有时间再研究补充。

对比WebFlux来编写API,个人倾向于使用函数式编程风格,减少注解的使用。Ktor的内容还有很多,本篇只是介绍了基本使用,添加HTTPS支持,以及更多的涉及请求Header、请求日志、Session等内容,仍需要研究官方博客。

虽然Ktor的学习成本高了不少(使用Kotlin语言、协程、函数式编程等),但是个人感觉这在未来会成为主流的API开发方向,毕竟Spring❤️ Kotlin and JetBrains❤️ Ktor

参考:

如果觉得我的文章对你有用,请随意赞赏