3K = Kotlin + Ktor + Ktorm,不要記錯(cuò)了哦
在上一篇里鞍匾,我們成功完成了對(duì) Ktorm 框架的引入交洗,并且也留了一個(gè)懸念,即 Ktorm 的實(shí)例可以用 jackson 來(lái)進(jìn)行序列化橡淑。其實(shí)說(shuō)到序列化构拳,我們最常用的場(chǎng)景也就是對(duì)于服務(wù)接口的輸入輸出了,這將引出我們今天想要講的東西梁棠,即 Ktor置森。
Ktor 是由 JetBrains 官方推出的一款服務(wù)端的框架,我們可以輕松的用它來(lái)實(shí)現(xiàn)一個(gè)服務(wù)端的應(yīng)用符糊,一個(gè)最簡(jiǎn)單的 Demo 如下:
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}.start(wait = true)
}
運(yùn)行它并且在 Terminal 輸入 curl 請(qǐng)求凫海,就可以看到這個(gè)接口已經(jīng)正常工作了。
$ curl http://0.0.0.0:8080/
Hello World!
當(dāng)然了男娄,我們不可能如此簡(jiǎn)單的去應(yīng)用一個(gè)框架行贪,但是這段代碼已經(jīng)向我們展示了 Ktor 的一個(gè)重要特性漾稀,就是它的簡(jiǎn)單。
作為 Ktor 本身來(lái)說(shuō)建瘫,它給我們提供了相當(dāng)多的特性崭捍,許多已經(jīng)制作好的插件,來(lái)方便我們做到開箱即用啰脚,這個(gè)所謂的簡(jiǎn)單殷蛇,比 Springboot 來(lái)得更為簡(jiǎn)單。雖然 Springboot 已是開箱即用橄浓,但是頻繁使用注解粒梦,反射的效率不佳,項(xiàng)目啟動(dòng)慢贮配,占用內(nèi)存高谍倦,也始終為我們所詬病。而 Ktor 由于使用 Kotlin 本身的異步函數(shù)式特性作為語(yǔ)言基礎(chǔ)泪勒,就如上面的 Demo 代碼中所寫的那樣昼蛀,一切皆是函數(shù),這就對(duì)“注解”很不友好了圆存。當(dāng)然了叼旋,Ktor 本身也不推薦使用注解,而是推薦使用插件沦辙,這是一種比注解更友好的 AOP 解決方案(當(dāng)然插件的編寫有一定的難度夫植,但是收獲大于付出),同時(shí) Ktor 的內(nèi)存占用比較小油讯,啟動(dòng)也很快详民,我當(dāng)前正在改造的一個(gè)項(xiàng)目,原本是 Springboot 所寫陌兑,啟動(dòng)需要 20 秒沈跨,啟動(dòng)后占用內(nèi)存 340M,而改成 Ktor 后兔综,啟動(dòng)僅需 2 秒饿凛,占用內(nèi)存 97M,只能說(shuō)這是一個(gè)巨大的進(jìn)步了软驰。更何況 Kotlin 還有一個(gè)眾所周知的特點(diǎn)涧窒,那就是寫起來(lái)也快。
下面是正題锭亏,為了順利的使用 Ktor 框架纠吴,來(lái)實(shí)現(xiàn)一些常規(guī)的服務(wù)端應(yīng)用所需要的內(nèi)容,我們還是有必要對(duì)它的插件作出一定的了解慧瘤,以下的官方插件都非常的有用(且易用)呜象。
一膳凝、跨域請(qǐng)求
對(duì)于現(xiàn)代的前后端分離的架構(gòu),支持跨域請(qǐng)求是非常有必要的恭陡,Ktor 里通過(guò) CORS 插件來(lái)實(shí)現(xiàn)這一能力:
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
}
二、用戶會(huì)話
這是用來(lái)實(shí)現(xiàn)一個(gè)有狀態(tài)的服務(wù)所必備的條件上煤,Sessions 插件將會(huì)把用戶的會(huì)話注入在框架里休玩,使得每一個(gè)請(qǐng)求都可以拿到會(huì)話信息。
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)))
}
}
在這里可以發(fā)現(xiàn) Ktor 對(duì)于開發(fā)者友好的地方劫狠,由于明文保存 Cookie 會(huì)有安全上的風(fēng)險(xiǎn)拴疤,因此 Session 插件允許你對(duì) Cookie 進(jìn)行加密,你只需要提供 32 位長(zhǎng)度的字符串作為加密密鑰独泞,再提供一個(gè)大于 8 位長(zhǎng)度的字符串作為簽名密鑰就可以了呐矾,Ktor 采用的是簽名驗(yàn)證的方式來(lái)驗(yàn)證 Cookie。
當(dāng)你使用了 Sessions 插件后懦砂,就可以在接到請(qǐng)求時(shí)獲取用戶的會(huì)話信息了:
get("/sample") {
val user = call.sessions.get<SessionUser>() // 獲取用戶會(huì)話
... ...
call.sessions.set(user) // 設(shè)置用戶會(huì)話
}
當(dāng)然了蜒犯,這里還有一些小技巧,如果你不喜歡每次都用這么復(fù)雜的代碼去獲取或設(shè)置用戶會(huì)話荞膘,你可以增加一個(gè)擴(kuò)展:
inline var PipelineContext<*, ApplicationCall>.user: SessionUser?
get() = context.sessions.get()
set(value) = context.sessions.set(value)
這樣你就可以在接到請(qǐng)求時(shí)使用這樣的代碼來(lái)獲得/設(shè)置用戶會(huì)話了:
get("/sample") {
val u = user // 獲取用戶會(huì)話
... ...
user = u // 設(shè)置用戶會(huì)話
}
在這里還有一個(gè)用戶會(huì)話對(duì)象序列化的問(wèn)題,下面將會(huì)講到。
三椰于、身份認(rèn)證
最常見的場(chǎng)景就是很多情況下梭姓,接口需要用戶登錄后才能請(qǐng)求,這里的登錄就是一種身份認(rèn)證的方式屠升,Ktor 提供了 Authentication 插件來(lái)實(shí)現(xiàn)身份認(rèn)證
install(Authentication) {
session<SessionUser>("auth.session") {
validate {
if (it.userId == -1L) null else it
}
challenge {
call.respond(AjaxResult.error("必須先登錄才能訪問(wèn)此接口"))
}
}
}
四潮改、序列化
序列化作為輸入輸出中最重要的方式,自然也被 Ktor 所支持腹暖,Ktor 目前支持三種序列化框架汇在,分別是 kotlinx.serialization,gson 和 jackson微服。除去官方框架稍有些難用外(需要大量注解以及使用官方的序列化編譯插件)趾疚,另兩個(gè)框架都是行業(yè)里面大量使用并且已得到大量驗(yàn)證的。上面說(shuō)過(guò)以蕴,Ktorm 是支持使用 jackson 來(lái)進(jìn)行序列化的糙麦,因此在 3K 整合的場(chǎng)景下,jackson 就是唯一的選擇丛肮,下面來(lái)看一下如何配置這樣的序列化:
install(ContentNegotiation) {
jackson { }
}
最簡(jiǎn)單的配置赡磅,只需要一句話就可以完成了,但是通常情況下宝与,只有默認(rèn)配置還是不夠的焚廊,當(dāng)我們需要對(duì)一些自定義的類型進(jìn)行序列化時(shí)冶匹,jackson 默認(rèn)的配置就不夠用了,我們有必要自己寫一些配置:
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")
}
}
經(jīng)過(guò)了這樣的配置后咆瘟,就可以用 jackson 來(lái)完成更多類型的序列化了嚼隘。需要注意的是,在 ContentNegotiation 插件內(nèi)配置的序列化工具袒餐,僅對(duì)“請(qǐng)求”的輸入?yún)?shù)飞蛹,以及輸出參數(shù)有效,但是對(duì)用戶會(huì)話的序列化無(wú)效(不知 JetBrains 那群人怎么想的灸眼,這也能區(qū)別對(duì)待卧檐?),所以在需要對(duì)用戶會(huì)話對(duì)象進(jìn)行序列化的場(chǎng)景下焰宣,我們需要對(duì) Sessions 插件里的序列化器進(jìn)行設(shè)置霉囚。
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 應(yīng)該怎么寫呢匕积,我們需要的是一些 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)
}
由于我們?cè)谏舷挛睦镆呀?jīng)帶上了泛型類型為 SessionUser盈罐,因此這里是可以自動(dòng)推定的,無(wú)須再寫一次 T闸天。另外暖呕,在函數(shù)中使得泛型保有其實(shí)際類型的做法也是相當(dāng)有用,它可以使得一個(gè)泛型類型 T 可以被實(shí)例化苞氮,可以在傳遞中不發(fā)生類型丟失湾揽。
還有一些插件,也是我們很常用的笼吟,出于篇幅關(guān)系不詳細(xì)寫了库物,大家可以看個(gè)表:
插件 | 作用 |
---|---|
HttpsRedirect | 允許請(qǐng)求為https時(shí)進(jìn)行重定向 |
Compression | 要求請(qǐng)求/響應(yīng)內(nèi)容進(jìn)行 gzip 壓縮 |
DefaultHeaders | 為響應(yīng)增加默認(rèn)頭部 |
PartialContent | 對(duì)響應(yīng)內(nèi)容進(jìn)行分片(特別是針對(duì)Safari瀏覽器播放視頻流時(shí),必須使用) |
Resources | 允許從服務(wù)端本地加載靜態(tài)頁(yè)面資源 |
WebSockets | 允許使用 WebSocket |
CallLogging | 允許對(duì)請(qǐng)求進(jìn)行鏈路跟蹤 |
下面來(lái)整體做一個(gè)基本整合吧贷帮,把 Ktor 和 Ktorm 整合到一起去使用戚揭。同樣出于篇幅,這里只完成一個(gè)簡(jiǎn)單的用戶登錄撵枢,登出和獲取用戶信息的功能民晒。
首先用官方工程向?qū)Ы?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("必須先登錄才能訪問(wèn)此接口"))
}
}
routing { // 注冊(cè)路由
user()
}
}
好的,目前這代碼里肯定會(huì)有一些劃紅線的地方的沃但,我們一點(diǎn)一點(diǎn)來(lái)填磁滚。
先把用戶會(huì)話類型給填了,這個(gè)類型相當(dāng)?shù)闹匾卿涍^(guò)程也會(huì)用到它:
data class SessionUser(val userId: Long = -1, val userName: String = ""): Principal
當(dāng)然了垂攘,只有這個(gè)類维雇,是無(wú)法完成用戶登錄的,真實(shí)的登錄需要數(shù)據(jù)庫(kù)的支持晒他,這里就要拿出上一篇中講過(guò)的 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,是兩個(gè)不同的類型仪芒,要將 SysUser 轉(zhuǎn)換為 SessionUser唁影,還需要多做一個(gè)步驟:
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)
}
}
}
現(xiàn)在有了用戶會(huì)話類型,也有了數(shù)據(jù)庫(kù)類型掂名,下面把路由的空給填了,也就是要寫那個(gè) 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())
}
}
}
好了哟沫,到這里我們又發(fā)現(xiàn)缺了個(gè) UserMapper饺蔑,在我們以往的經(jīng)驗(yàn)中,Mapper 就是要操作數(shù)據(jù)庫(kù)了嗜诀,在 Ktor 里面也不例外猾警,最后把這個(gè) Mapper 補(bǔ)上:
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 "密碼錯(cuò)誤"
}
return user to ""
}
fun getInfo(userId: Long): SysUser? =
WHDatabase.database.sysUsers.find { it.userId eq userId }
}
這樣的 Mapper 是不是也很簡(jiǎn)單,不需要寫任何的 xml隆敢,也沒有 SQL 注入的風(fēng)險(xiǎn)发皿。
最后,我們來(lái)嘗試一下拂蝎,把項(xiàng)目跑起來(lái)穴墅,然后同樣用 curl 進(jìn)行試驗(yàn)吧:
$ 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" : "必須先登錄才能訪問(wèn)此接口",
"data" : null
}
$ curl http://0.0.0.0:8080/user/info -b cookie
{
"code" : 200,
"msg" : "操作成功",
"data" : {
"userId" : 1,
"userName" : "admin"
}
}
好了,目前我們已經(jīng)完成了 Ktor + Ktorm 的整合了温自,代碼量很少玄货,寫起來(lái)很方便也很直觀,你所需要的東西悼泌,都有約定俗成的實(shí)現(xiàn)方法松捉,也完全不需要關(guān)注序列化相關(guān)的內(nèi)容」堇铮基于上面的這個(gè)案例隘世,你甚至可以直接拿來(lái)擴(kuò)充功能。
從另一方面來(lái)說(shuō)鸠踪,目前這份代碼也著實(shí)有些簡(jiǎn)陋丙者,舉個(gè)例子,假設(shè)我們的用戶擁有不同權(quán)限慢哈,對(duì)于某些接口的訪問(wèn)需要先驗(yàn)證權(quán)限蔓钟,是不是需要每次在接到請(qǐng)求時(shí)都查一次用戶權(quán)限呢?Springboot 有很好用的權(quán)限組件卵贱,Ktor 有類似的東西嗎滥沫?這些問(wèn)題將在下一篇為各位解答侣集。
順便說(shuō)一句,上面的代碼兰绣,使用了封裝得更優(yōu)雅的世分,由指令集自研的 Ktor 庫(kù),我們致力于讓代碼變得更簡(jiǎn)單缀辩,讓開發(fā)的同學(xué)可以更多的心思放在業(yè)務(wù)邏輯上臭埋。如何引用我們的庫(kù),也很簡(jiǎn)單:
implementation("com.github.isyscore:common-ktor:2.2.1")