3K整合系列(二) Ktor + Ktorm

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")
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容