前言
最近找寻能快速开发的web框架,比如对py的 fastapi,go的gin以及c#的.netcore与php的laveral做了研究,php被排除的原因只是因为环境安装过于麻烦而且还容易出错(如果是php大佬勿喷),go和py其实也挺不错的,go的问题是生态与自身的问题导致开发效率并不优秀,py确实不错可惜不太懂fastapi的异常机制,最终选择使用ktor,ktor总体还可以唯一缺点是没办法对接api文档,当然如果有大佬可以完善这块欢迎私信我,目前对于api文档我采用的方案是在线api文档工具,至于使用哪个看个人喜好,这里就不提议了避免打广告的嫌疑。
一、Ktor是什么?
Ktor 是一个使用 Kotlin 以最小的成本快速创建 Web 应用程序的框架。
二、使用步骤
1.项目目录结构
├── kotlin
│ └── org
│ └── example
│ ├── Application.kt - 入口文件
│ ├── handle - 项目逻辑
│ │ └── demo.kt
│ ├── plugins - 框架插件
│ │ ├── CallLogging.kt
│ │ ├── GlobalException.kt
│ │ ├── Jwt.kt
│ │ ├── MySqlDatabase.kt
│ │ ├── Routing.kt
│ │ └── Serialization.kt
│ └── utils - 项目相关工具或拓展
│ ├── AuthUtils.kt
│ ├── ext
│ │ ├── ApplicationCallConf.kt
│ │ ├── ApplicationCallLogExt.kt
│ │ └── ApplicationCallResponseExt.kt
│ └── TimeUtils.kt
└── resources - 项目配置文件
├── application.conf
└── logback.xml
2.配置文件
- application.conf
ktor {
environment = dev
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ org.example.ApplicationKt.module ]
}
}
jwt {
realm = Authentication
subject = Authentication
issuer = "http://0.0.0.0:8080"
sign = "**x6g3F92c#*_"
}
- logback.xml
<configuration>
<!-- 定义变量 -->
<property name="logback.logDir" value="web/logs"/>
<property name="logback.appName" value="fyc"/>
<!--输出到控制台-->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss} [%-5level] %logger{36} [%file : %line] - %msg%n</pattern>
</encoder>
</appender>
<appender name="fileRolling" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!--如果只是想要 Error 级别的日志,那么需要过滤一下,默认是 info 级别的,ThresholdFilter-->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>error</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 每天一归档 -->
<fileNamePattern>${logback.logDir}/error.${logback.appName}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<!-- 单个日志文件最多 100MB, 60天的日志周期,最大不能超过20GB,窗口大小是1到3,当保存了3个归档文件后,将覆盖最早的日志 -->
<maxFileSize>200MB</maxFileSize>
<maxHistory>15</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
<!-- <minIndex>1</minIndex>-->
<!-- <maxIndex>3</maxIndex>-->
</rollingPolicy>
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss} [%-5level] %logger{36} [%file : %line] - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="console"/>
<appender-ref ref="fileRolling"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
3.build.gradle.kts配置
val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project
val jwt_version: String by project
plugins {
application
kotlin("jvm") version "1.6.21"
id("org.jetbrains.kotlin.plugin.serialization") version "1.6.21"
id("com.github.johnrengelman.shadow") version "7.1.2" // 打包jar依赖与运行需要的配置
}
group = "org.example"
version = "0.0.1"
application {
// mainClass.set("org.example.ApplicationKt")
mainClass.set("io.ktor.server.netty.EngineMain") // 如果使用 EngineMain ,则需要将其配置为主类
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
repositories {
mavenCentral()
maven { url = uri("https://maven.aliyun.com/repository/public") }
maven { url = uri("https://repo1.maven.org/maven2") }
maven { url = uri("https://maven.pkg.jetbrains.space/public/p/ktor/eap") }
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version")
// implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version")
implementation("io.ktor:ktor-serialization-jackson:$ktor_version")
implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
implementation("ch.qos.logback:logback-classic:$logback_version")
testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
implementation("io.ktor:ktor-server-call-logging:$ktor_version")
implementation("io.ktor:ktor-server-method-override:$ktor_version") // 用于解决客户端不支持put或delete请求,X-Http-Method-Override: DELETE会将请求自动转发
implementation("io.ktor:ktor-server-status-pages:$ktor_version")
implementation("io.jsonwebtoken:jjwt-api:$jwt_version")
runtimeOnly("io.jsonwebtoken:jjwt-impl:$jwt_version")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jwt_version")
}
- gradle.properties
ktor_version=2.0.2
kotlin_version=1.6.21
logback_version=1.2.11
kotlin.code.style=official
jwt_version=0.11.5
4.项目相关代码
- Application.kt
package org.example
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.methodoverride.*
import org.example.plugins.*
import org.example.utils.ext.getConfString
fun main(args: Array<String>): Unit = EngineMain.main(args)
// 全局中间件在此处注册
fun Application.module() {
// 用于解决客户端不支持put和delete请求,在请求头中使用X-Http-Method-Override标记当前真实请求
install(XHttpMethodOverride)
configureLogging()
configureSerialization()
configureException()
configureRouting()
}
- handle/demo.kt
package org.example.handle
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.*
import org.example.plugins.JwtParameter
import org.example.plugins.JwtPlugin
import org.example.utils.AuthUtils
import org.example.utils.ext.getConfString
import org.example.utils.ext.info
import org.example.utils.ext.jsonOk
data class UserPwdDto(val name: String, val pwd: String)
fun Route.demoRoute() {
get("/y1") {
call.jsonOk<String>("success")
}
post("/login") {
val userDto = call.receive<UserPwdDto>()
val token = AuthUtils.instance.makeToken(call.getConfString("jwt.issuer"))
.claim(JwtParameter.myKey, userDto.name)
.compact()
call.jsonOk("success", mapOf("ass_token" to token))
}
// 表示路由组,如: /demo/t1、/demo/all 等
route("/demo") {
install(JwtPlugin)
get("t1") {
call.jsonOk("success", "success")
}
get("/all") {
call.respond(mapOf("hello2" to "world"))
}
get("/z2") {
val token = call.attributes[JwtParameter.jwtKey].toString()
call.info(token)
call.jsonOk("success", "hello world")
}
}
}
- plugins/CallLogging.kt
package org.example.plugins
import io.ktor.server.application.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.request.*
fun Application.configureLogging() {
install(CallLogging) {
format { call ->
val path = call.request.path()
val httpMethod = call.request.httpMethod.value
val userAgent = call.request.headers["User-Agent"]
"Path: $path, HTTPMethod: $httpMethod, UserAgent: $userAgent"
}
}
}
- plugins/GlobalException.kt
package org.example.plugins
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import org.example.utils.ext.error
import org.example.utils.ext.json404
import org.example.utils.ext.json405
import org.example.utils.ext.json500
fun Application.configureException() {
// 处理http 404 或 500等请求
install(StatusPages) {
// 配置500
exception<Throwable> { call, err ->
call.error(getMessage(err))
call.json500(getMessage(err))
}
// 处理http 404
status(HttpStatusCode.NotFound) { call, _ ->
call.json404()
}
// 处理http 405
status(HttpStatusCode.MethodNotAllowed) { call, _ ->
call.json405()
}
}
}
// 获取异常全部信息
private fun getMessage(err: Throwable):String {
val strBuffer = StringBuffer("${err.message}\n")
for (se in err.stackTrace) {
strBuffer.append("\tat ${se.className}(${se.fileName}:${se.lineNumber})\n")
}
strBuffer.deleteCharAt(strBuffer.length -1)
strBuffer.append("}")
return strBuffer.toString()
}
- plugins/Jwt.kt
package org.example.plugins
import io.jsonwebtoken.JwtException
import io.ktor.server.application.*
import io.ktor.server.application.hooks.*
import io.ktor.util.*
import org.example.utils.AuthUtils
import org.example.utils.ext.getConfString
import org.example.utils.ext.json401
import org.example.utils.ext.json403
import org.example.utils.ext.warn
class JwtParameter {
companion object {
const val myKey = "myKey"
val jwtKey = AttributeKey<String>(myKey)
}
}
// token验证插件
val JwtPlugin = createRouteScopedPlugin(name = "JwtPlugin") {
on(CallSetup) { call ->
val authentication = call.request.headers["Authentication"]
if (authentication.isNullOrEmpty()) {
// 401
call.json401("请检查是否提交验证数据")
return@on
}
val authenticationArray = authentication.split(" ")
if (authenticationArray.size != 2 || authenticationArray[0] != "Basic") {
// 401
call.json401("非法请求禁止访问")
return@on
}
val token = authenticationArray[1]
// 解析token信息
try {
val claims = AuthUtils.instance.tokenAnalysis(token)
val iss = call.getConfString("jwt.issuer")
// 判断发行人是否一致
if (claims.issuer != iss) {
call.json403("令牌不合法,禁止访问!", 10003)
return@on
}
call.attributes.put(JwtParameter.jwtKey, claims[JwtParameter.myKey] as String)
} catch (e: JwtException) {
// 验证失败, 可能原因为jwt过期
call.json403("token验证失败,请重新获取后尝试", 10001)
return@on
} catch (e: Exception) {
call.json403("未找到token参数", 10002)
return@on
}
}
}
- plugins/MySqlDatabase.kt
package org.example.plugins
import io.ktor.server.application.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext
@OptIn(DelicateCoroutinesApi::class)
val mySqlDBPlugin = createApplicationPlugin(name = "MySqlDBPlugin") {
val databaseContext = newSingleThreadContext("MySqlDatabaseThread")
onCall {
withContext(databaseContext) {
// 数据库连接逻辑,此处需要参考官方文档
// 链接: https://ktor.io/docs/custom-plugins.html#databases
}
}
}
- plugins/Routing.kt
package org.example.plugins
import io.ktor.server.routing.*
import io.ktor.server.application.*
import io.ktor.server.http.content.*
import org.example.handle.demoRoute
import java.io.File
const val BaseAPI = "/api/v1"
fun Application.configureRouting() {
routing {
// 配置全局静态变量
static("$BaseAPI/") {
staticRootFolder = File("web")
// 这里可以设定多个 static("x"),只要想对外访问的目录统一在当前作用域下配置
static("static") {
files("static")
resources(".") // 这行表示可以访问 static/x.jpg,不加没办法访问到static下的文件
}
}
// 此处配置所有路由 此处路由会添加前缀 /api/v1/...
route(BaseAPI) {
demoRoute()
}
}
}
- plugins/Serialization.kt
package org.example.plugins
import com.fasterxml.jackson.core.util.DefaultIndenter
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter
import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.serialization.jackson.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.application.*
fun Application.configureSerialization() {
install(ContentNegotiation) {
// 配置json解析方式, 不要采用kotlinx的json解析,kotlinx兼容性极差
jackson {
configure(SerializationFeature.INDENT_OUTPUT, false)
setDefaultPrettyPrinter(DefaultPrettyPrinter().apply {
indentArraysWith(DefaultPrettyPrinter.FixedSpaceIndenter.instance)
indentObjectsWith(DefaultIndenter(" ", "\n"))
})
// registerModule(JavaTimeModule()) // support java.time.* types,此处会报错,官方文档给出的配置如果需要自行查阅
}
}
}
- utils/AuthUtils.kt
package org.example.utils
import io.jsonwebtoken.Claims
import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
class AuthUtils private constructor() {
private object AuthUtilsHolder {
val holder= AuthUtils()
}
/** 使用静态内部类实现 */
companion object {
val instance = AuthUtilsHolder.holder
}
private val key = Keys.secretKeyFor(SignatureAlgorithm.HS512)
// 生成token
fun makeToken(iss: String): JwtBuilder {
return Jwts.builder()
.setSubject("Authentication") // 主题
.setIssuer(iss) // 发行者
.setIssuedAt(getTimes()) // 签名生效时间
.setExpiration(getTimes(30 * 60 * 1000)) // 过期时间半个小时
.signWith(key)
// .compact() // 这里注释掉是为了灵活让外部去调用生成token字符串
}
// 解析token
fun tokenAnalysis(token: String): Claims {
val signingKey = Jwts.parserBuilder().setSigningKey(key).build()
return signingKey.parseClaimsJws(token).body
}
}
- utils/TimeUtils.kt
package org.example.utils
import java.util.*
// 获取对应处理的时间 - token
fun getTimes(validityInMs: Long = 0) = Date(System.currentTimeMillis() + validityInMs)
- utils/ext/ApplicationCallConf.kt
package org.example.utils.ext
import io.ktor.server.application.*
// 读取配置文件 - string
fun ApplicationCall.getConfString(key: String, def: String = ""): String {
return this.application.getConfString(key, def)
}
fun Application.getConfString(key: String, def: String = ""): String {
return this.environment.config.propertyOrNull(key)?.getString() ?: def
}
- utils/ext/ApplicationCallLogExt.kt
/**
* 只为 ApplicationCall 拓展日志简化访问
*/
package org.example.utils.ext
import io.ktor.server.application.*
import io.ktor.server.request.*
private fun ApplicationCall.logStr(): String {
val path = this.request.path()
val httpMethod = this.request.httpMethod.value
val userAgent = this.request.headers["User-Agent"]
return "Path: $path, HTTPMethod: $httpMethod, UserAgent: $userAgent \n"
}
fun ApplicationCall.info(msg: String) = this.application.environment.log.info(msg)
fun ApplicationCall.info(format: String, vararg arguments: Any) = this.application.environment.log.info(format, *arguments)
fun ApplicationCall.info(msg: String, t: Throwable) = this.application.environment.log.info(msg, t)
fun ApplicationCall.debug(msg: String) = this.application.environment.log.debug(msg)
fun ApplicationCall.debug(format: String, vararg arguments: Any) = this.application.environment.log.debug(format, *arguments)
fun ApplicationCall.debug(msg: String, t: Throwable) = this.application.environment.log.debug(msg, t)
fun ApplicationCall.warn(msg: String) = this.application.environment.log.warn(msg)
fun ApplicationCall.warn(format: String, vararg arguments: Any) = this.application.environment.log.warn(format, *arguments)
fun ApplicationCall.warn(msg: String, t: Throwable) = this.application.environment.log.warn(msg, t)
fun ApplicationCall.error(msg: String) {
this.application.environment.log.error(msg)
this.application.environment.log.error(this.logStr())
}
fun ApplicationCall.error(format: String, vararg arguments: Any) = this.application.environment.log.error(format, *arguments)
fun ApplicationCall.error(msg: String, t: Throwable) = this.application.environment.log.error(msg, t)
fun Application.info(msg: String) = this.environment.log.info(msg)
fun Application.info(format: String, vararg arguments: Any) = this.environment.log.info(format, *arguments)
fun Application.info(msg: String, t: Throwable) = this.environment.log.info(msg, t)
fun Application.debug(msg: String) = this.environment.log.debug(msg)
fun Application.debug(format: String, vararg arguments: Any) = this.environment.log.debug(format, *arguments)
fun Application.debug(msg: String, t: Throwable) = this.environment.log.debug(msg, t)
fun Application.warn(msg: String) = this.environment.log.warn(msg)
fun Application.warn(format: String, vararg arguments: Any) = this.environment.log.warn(format, *arguments)
fun Application.warn(msg: String, t: Throwable) = this.environment.log.warn(msg, t)
fun Application.error(msg: String) = this.environment.log.error(msg)
fun Application.error(format: String, vararg arguments: Any) = this.environment.log.error(format, *arguments)
fun Application.error(msg: String, t: Throwable) = this.environment.log.error(msg, t)
- utils/ext/ApplicationCallResponseExt.kt
// 拓展 ApplicationCall json返回
package org.example.utils.ext
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
// 统一异常返回
data class ResponseErrorResult(val code: Int, val msg: String, val errMsg: String? = null, val errCode: Int = 0)
// 200 成功 | 失败
data class Response200Result<T: Any>(val code: Int = 200, val msg: String = "success", val errCode: Int = 0, val state: Boolean = false, val data: T? = null)
// 所有异常返回模板
suspend fun ApplicationCall.jsonError(code: HttpStatusCode, msg: String, errMsg: String? = null, errCode: Int = 0){
respond(code, ResponseErrorResult(
code.value,
msg,
errMsg,
errCode
))
}
// 200 - success
suspend fun <T: Any>ApplicationCall.jsonOk(msg: String, data: T? = null) {
respond(Response200Result(200, msg, 0, true, data=data))
}
// 200 - error
suspend fun ApplicationCall.jsonErr(msg: String, errCode: Int = 0){
respond(Response200Result(200, msg, errCode, false, data=null))
}
// 500
suspend fun ApplicationCall.json500(errMsg: String? = null, errCode: Int = 0) {
jsonError(HttpStatusCode.InternalServerError, "服务器内部错误", errMsg, errCode)
}
// 404
suspend fun ApplicationCall.json404(errCode: Int = 0) {
jsonError(HttpStatusCode.NotFound, "找不到的路径", "访问的路径被删除或者不存在!", errCode)
}
// 405
suspend fun ApplicationCall.json405(errCode: Int = 0) {
jsonError(HttpStatusCode.NotFound, "资源被禁止", "不允许使用请求行中所指定的方法", errCode)
}
// 403
suspend fun ApplicationCall.json403(msg: String, errCode: Int = 0) {
jsonError(HttpStatusCode.Forbidden, "验证失败禁止访问", msg, errCode)
}
// 401
suspend fun ApplicationCall.json401(msg: String, errCode: Int = 0) {
jsonError(HttpStatusCode.Unauthorized, "当前请求需要验证", msg, errCode)
}
总结
该示例已经实现 ktor + log + jwt后续拓展自己添加,相比较spring boot而言简单一些,可以使用idea编译成jar包运行需要使用shandowjar的方式打包,由于已经导入shandowjar插件直接在idea中打包即可,目前ktor相关文章较少希望大家有能力多去研究有意思的框架。