3K = Kotlin + Ktor + Ktorm,不要记错了哦
在上一篇里,我们成功完成了对 Ktorm 框架的引入,并且也留了一个悬念,即 Ktorm 的实例可以用 jackson 来进行序列化。其实说到序列化,我们最常用的场景也就是对于服务接口的输入输出了,这将引出我们今天想要讲的东西,即 Ktor。
Ktor 是由 JetBrains 官方推出的一款服务端的框架,我们可以轻松的用它来实现一个服务端的应用,一个最简单的 Demo 如下:
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}.start(wait = true)
}
运行它并且在 Terminal 输入 curl 请求,就可以看到这个接口已经正常工作了。
$ curl http://0.0.0.0:8080/
Hello World!
当然了,我们不可能如此简单的去应用一个框架,但是这段代码已经向我们展示了 Ktor 的一个重要特性,就是它的简单。
作为 Ktor 本身来说,它给我们提供了相当多的特性,许多已经制作好的插件,来方便我们做到开箱即用,这个所谓的简单,比 Springboot 来得更为简单。虽然 Springboot 已是开箱即用,但是频繁使用注解,反射的效率不佳,项目启动慢,占用内存高,也始终为我们所诟病。而 Ktor 由于使用 Kotlin 本身的异步函数式特性作为语言基础,就如上面的 Demo 代码中所写的那样,一切皆是函数,这就对“注解”很不友好了。当然了,Ktor 本身也不推荐使用注解,而是推荐使用插件,这是一种比注解更友好的 AOP 解决方案(当然插件的编写有一定的难度,但是收获大于付出),同时 Ktor 的内存占用比较小,启动也很快,我当前正在改造的一个项目,原本是 Springboot 所写,启动需要 20 秒,启动后占用内存 340M,而改成 Ktor 后,启动仅需 2 秒,占用内存 97M,只能说这是一个巨大的进步了。更何况 Kotlin 还有一个众所周知的特点,那就是写起来也快。
下面是正题,为了顺利的使用 Ktor 框架,来实现一些常规的服务端应用所需要的内容,我们还是有必要对它的插件作出一定的了解,以下的官方插件都非常的有用(且易用)。
一、跨域请求
对于现代的前后端分离的架构,支持跨域请求是非常有必要的,Ktor 里通过 CORS 插件来实现这一能力:
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Head)
allowMethod(HttpMethod.Options)
allowHeader(HttpHeaders.Authorization)
anyHost()
allowCredentials = true
allowNonSimpleContentTypes = true
maxAgeInSeconds = 1000L * 60 * 60 * 24
}
二、用户会话
这是用来实现一个有状态的服务所必备的条件,Sessions 插件将会把用户的会话注入在框架里,使得每一个请求都可以拿到会话信息。
data class SessionUser(val userId: Long = -1, val userName: String = "") : Principal
install(Sessions) {
cookie<SessionUser>("Session") {
cookie.extensions["SameSite"] = "lax"
cookie.httpOnly = true
cookie.path = "/"
transform(SessionTransportTransformerEncrypt(hex(secretEncryptKey), hex(secretSignKey)))
}
}
在这里可以发现 Ktor 对于开发者友好的地方,由于明文保存 Cookie 会有安全上的风险,因此 Session 插件允许你对 Cookie 进行加密,你只需要提供 32 位长度的字符串作为加密密钥,再提供一个大于 8 位长度的字符串作为签名密钥就可以了,Ktor 采用的是签名验证的方式来验证 Cookie。
当你使用了 Sessions 插件后,就可以在接到请求时获取用户的会话信息了:
get("/sample") {
val user = call.sessions.get<SessionUser>() // 获取用户会话
... ...
call.sessions.set(user) // 设置用户会话
}
当然了,这里还有一些小技巧,如果你不喜欢每次都用这么复杂的代码去获取或设置用户会话,你可以增加一个扩展:
inline var PipelineContext<*, ApplicationCall>.user: SessionUser?
get() = context.sessions.get()
set(value) = context.sessions.set(value)
这样你就可以在接到请求时使用这样的代码来获得/设置用户会话了:
get("/sample") {
val u = user // 获取用户会话
... ...
user = u // 设置用户会话
}
在这里还有一个用户会话对象序列化的问题,下面将会讲到。
三、身份认证
最常见的场景就是很多情况下,接口需要用户登录后才能请求,这里的登录就是一种身份认证的方式,Ktor 提供了 Authentication 插件来实现身份认证
install(Authentication) {
session<SessionUser>("auth.session") {
validate {
if (it.userId == -1L) null else it
}
challenge {
call.respond(AjaxResult.error("必须先登录才能访问此接口"))
}
}
}
四、序列化
序列化作为输入输出中最重要的方式,自然也被 Ktor 所支持,Ktor 目前支持三种序列化框架,分别是 kotlinx.serialization,gson 和 jackson。除去官方框架稍有些难用外(需要大量注解以及使用官方的序列化编译插件),另两个框架都是行业里面大量使用并且已得到大量验证的。上面说过,Ktorm 是支持使用 jackson 来进行序列化的,因此在 3K 整合的场景下,jackson 就是唯一的选择,下面来看一下如何配置这样的序列化:
install(ContentNegotiation) {
jackson { }
}
最简单的配置,只需要一句话就可以完成了,但是通常情况下,只有默认配置还是不够的,当我们需要对一些自定义的类型进行序列化时,jackson 默认的配置就不够用了,我们有必要自己写一些配置:
fun ObjectMapper.config(localDatePattern: String, localTimePattern: String, localDateTimePattern: String): ObjectMapper {
registerModule(KtormModule())
registerModule(JavaTimeModule().apply {
addDeserializer(LocalDate::class.java, LocalDateDeserializer(DateTimeFormatter.ofPattern(localDatePattern)))
addSerializer(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ofPattern(localDatePattern)))
addDeserializer(LocalTime::class.java, LocalTimeDeserializer(DateTimeFormatter.ofPattern(localTimePattern)))
addSerializer(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ofPattern(localTimePattern)))
addDeserializer(LocalDateTime::class.java, LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(localDateTimePattern)))
addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ofPattern(localDateTimePattern)))
})
configure(SerializationFeature.INDENT_OUTPUT, true)
setDefaultLeniency(true)
setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
indentObjectsWith(DefaultIndenter(" ", "\n"))
})
return this
}
install(ContentNegotiation) {
jackson {
config("yyyy-MM-dd", "hh:mm:ss", "yyyy-MM-dd hh:mm:ss")
}
}
经过了这样的配置后,就可以用 jackson 来完成更多类型的序列化了。需要注意的是,在 ContentNegotiation 插件内配置的序列化工具,仅对“请求”的输入参数,以及输出参数有效,但是对用户会话的序列化无效(不知 JetBrains 那群人怎么想的,这也能区别对待?),所以在需要对用户会话对象进行序列化的场景下,我们需要对 Sessions 插件里的序列化器进行设置。
install(Sessions) {
cookie<SessionUser>(sessionIdentifier) {
cookie.extensions["SameSite"] = "lax"
cookie.httpOnly = true
cookie.path = "/"
serializer = generateSerializer(localDatePattern, localTimePattern, localDateTimePattern)
transform(SessionTransportTransformerEncrypt(hex(secretEncryptKey), hex(secretSignKey)))
}
}
好了,那么这里的 generateSerializer 应该怎么写呢,我们需要的是一些 Kotlin 的小技巧:
inline fun <reified T : Any> generateSerializer(
localDatePattern: String, localTimePattern: String, localDateTimePattern: String
): SessionSerializer<T> = object : SessionSerializer<T> {
private val om = ObjectMapper().config(localDatePattern, localTimePattern, localDateTimePattern)
override fun deserialize(text: String): T = om.readValue(text, T::class.java)
override fun serialize(session: T): String = om.writeValueAsString(session)
}
由于我们在上下文里已经带上了泛型类型为 SessionUser,因此这里是可以自动推定的,无须再写一次 T。另外,在函数中使得泛型保有其实际类型的做法也是相当有用,它可以使得一个泛型类型 T 可以被实例化,可以在传递中不发生类型丢失。
还有一些插件,也是我们很常用的,出于篇幅关系不详细写了,大家可以看个表:
插件 | 作用 |
---|---|
HttpsRedirect | 允许请求为https时进行重定向 |
Compression | 要求请求/响应内容进行 gzip 压缩 |
DefaultHeaders | 为响应增加默认头部 |
PartialContent | 对响应内容进行分片(特别是针对Safari浏览器播放视频流时,必须使用) |
Resources | 允许从服务端本地加载静态页面资源 |
WebSockets | 允许使用 WebSocket |
CallLogging | 允许对请求进行链路跟踪 |
下面来整体做一个基本整合吧,把 Ktor 和 Ktorm 整合到一起去使用。同样出于篇幅,这里只完成一个简单的用户登录,登出和获取用户信息的功能。
首先用官方工程向导建立 Ktor 工程,删掉除了 Main.kt 和配置文件以外的文件,那些东西我们都不需要。然后编写 Main.kt 的代码:
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
val dbCfg = loadDatabaseConfig()
WHDatabase.initDatabase(dbCfg)
pluginCORS()
pluginSession<SessionUser>(
isSecret = true,
secretEncryptKey = "00112233445566778899aabbccddeeff",
secretSignKey = "12345678"
)
pluginContentNegotiation()
pluginAuthSession<SessionUser>(AUTH_SESSION) {
validate { it }
challenge {
call.respond(AjaxResult.error("必须先登录才能访问此接口"))
}
}
routing { // 注册路由
user()
}
}
好的,目前这代码里肯定会有一些划红线的地方的,我们一点一点来填。
先把用户会话类型给填了,这个类型相当的重要,登录过程也会用到它:
data class SessionUser(val userId: Long = -1, val userName: String = ""): Principal
当然了,只有这个类,是无法完成用户登录的,真实的登录需要数据库的支持,这里就要拿出上一篇中讲过的 Ktorm 了:
interface SysUser : Entity<SysUser> {
companion object : Entity.Factory<SysUser>()
var userId: Long
var userName: String
@get:JsonIgnore
val password: String
}
object SysUsers : Table<SysUser>("sys_user") {
var userId = long("user_id").primaryKey().bindTo { it.userId }
var userName = varchar("user_name").bindTo { it.userName }
var password = varchar("password").bindTo { it.password }
}
好了,我们可以看到 SessionUser 和 SysUser,是两个不同的类型,要将 SysUser 转换为 SessionUser,还需要多做一个步骤:
data class SessionUser(val userId: Long = -1, val userName: String = ""): Principal {
companion object {
fun fromSysUser(u: SysUser?): SessionUser = if (u == null) {
SessionUser()
} else {
SessionUser(u.userId, u.userName)
}
}
}
现在有了用户会话类型,也有了数据库类型,下面把路由的空给填了,也就是要写那个 routing 下面的 user() 方法:
data class ReqLogin(val userName: String, val password: String)
data class AjaxResult <T>(val code: Int, val msg: String, val data: T?) {
companion object {
fun success(): AjaxResult<*> = AjaxResult(200, "操作成功", null)
fun success(msg: String): AjaxResult<*> = AjaxResult(200, msg, null)
fun<T> success(obj: T?): AjaxResult<T> = AjaxResult(200, "操作成功", obj)
fun<T> success(msg: String, obj: T?): AjaxResult<T> = AjaxResult(200, msg, obj)
fun error(): AjaxResult<*> = AjaxResult(500, "操作失败", null)
fun error(msg: String): AjaxResult<*> = AjaxResult(500, msg, null)
fun<T> error(obj: T?): AjaxResult<T> = AjaxResult(500, "操作失败", obj)
fun<T> error(msg: String, obj: T?): AjaxResult<T> = AjaxResult(500, msg, obj)
}
}
fun Routing.user() = route("/user") {
post<ReqLogin>("/login") {
val (u, err) = UserMapper.login(it)
if (u == null) {
call.respond(AjaxResult.error(err))
return@post
}
val su = SessionUser.fromSysUser(u)
call.sessions.set(su)
call.respond(AjaxResult.success(su))
}
authenticate(AUTH_SESSION) {
get("/info") {
val u = UserMapper.getInfo(user?.userId ?: -1)
call.respond(AjaxResult.success(u))
}
get("/logout") {
call.sessions.set(null)
call.respond(AjaxResult.success())
}
}
}
好了,到这里我们又发现缺了个 UserMapper,在我们以往的经验中,Mapper 就是要操作数据库了,在 Ktor 里面也不例外,最后把这个 Mapper 补上:
object UserMapper {
fun login(req: ReqLogin): Pair<SysUser?, String> {
val user = WHDatabase.database.sysUsers.find { it.userName eq req.userName } ?: return null to "用户不存在"
val match = BCryptPasswordEncoder().matches(req.password, user.password)
if (!match) {
return null to "密码错误"
}
return user to ""
}
fun getInfo(userId: Long): SysUser? =
WHDatabase.database.sysUsers.find { it.userId eq userId }
}
这样的 Mapper 是不是也很简单,不需要写任何的 xml,也没有 SQL 注入的风险。
最后,我们来尝试一下,把项目跑起来,然后同样用 curl 进行试验吧:
$ curl -d '{"userName": "admin", "password": "12345678"}' http://0.0.0.0:8080/user/login -c cookie
{
"code" : 200,
"msg" : "操作成功",
"data" : {
"userId" : 1,
"userName" : "admin"
}
}
$ curl http://0.0.0.0:8080/user/info
{
"code" : 500,
"msg" : "必须先登录才能访问此接口",
"data" : null
}
$ curl http://0.0.0.0:8080/user/info -b cookie
{
"code" : 200,
"msg" : "操作成功",
"data" : {
"userId" : 1,
"userName" : "admin"
}
}
好了,目前我们已经完成了 Ktor + Ktorm 的整合了,代码量很少,写起来很方便也很直观,你所需要的东西,都有约定俗成的实现方法,也完全不需要关注序列化相关的内容。基于上面的这个案例,你甚至可以直接拿来扩充功能。
从另一方面来说,目前这份代码也着实有些简陋,举个例子,假设我们的用户拥有不同权限,对于某些接口的访问需要先验证权限,是不是需要每次在接到请求时都查一次用户权限呢?Springboot 有很好用的权限组件,Ktor 有类似的东西吗?这些问题将在下一篇为各位解答。
顺便说一句,上面的代码,使用了封装得更优雅的,由指令集自研的 Ktor 库,我们致力于让代码变得更简单,让开发的同学可以更多的心思放在业务逻辑上。如何引用我们的库,也很简单:
implementation("com.github.isyscore:common-ktor:2.2.1")