眾所周知的,我們的服務(wù)器不可能因為一點小的改動就要編譯整個 jar 包,也不可能直接就進行完整的部署流程。正常的做法是把功能模塊做成插件,然后動態(tài)的去進行加載,這樣就不需要重啟服務(wù)了。
那么在 Ktor 里如何實現(xiàn)呢,其實也是很簡單的。Ktor 是基于 JVM 的,自然擁有 JVM 上可以做的各類騷操作,下面我們先來寫一個簡單的插件。
插件(1) 改造 Ktor 項目模板
經(jīng)過改造后的項目模板如下所示,可以看到它不具備 Application,也沒有 main 函數(shù),因為插件并不需要這些,它只需要確保自己可以被 Routing 找到即可。
SamplePlugin
|-- src
| |-- PluginRouting.kt
|-- build.gradle
|-- gradle.properties
|-- settings.gradle
對于 PluginRouting.kt 而言,內(nèi)容很簡單,只要寫出要響應(yīng)的請求等即可:
fun Routing.pluginRouting() {
get("/plug") {
call.respondText { """{"result":0,"from":"plugin"}""" }
}
}
然后其他的可以什么都不修改,使用 Ktor 項目的標(biāo)準(zhǔn)模板即可。
插件(2) 編譯
通常我們使用 gradle build 來編譯一個 Ktor 項目,而對于插件則要使用 gradle jar 來編譯。
編譯完成后我們可以得到一個名為 SamplePlugin-1.0.0.jar 的文件,這即是插件了。
需要說明的是,對于 Kotlin 代碼中的擴展,其編譯后的代碼是靜態(tài)方法,并且傳入被擴展對象作為參數(shù),如上述代碼在編譯后生成的代碼是:
public class PluginRoutingKt {
public static final void pluginRouting(Routing $routing$this);
}
到此,插件已經(jīng)開發(fā)完畢了,非常的簡單,它提供了一個 /plug 的請求響應(yīng)。
宿主(1) 編寫加載插件的代碼
既然有了插件,那就得用宿主去加載它,利用 JVM 內(nèi)的方法就可以輕松實現(xiàn)了,如上面的插件,我們可以這樣加載:
fun loadPlugin(r: Routing) {
val jar = File("SamplePlugin-1.0.0.jar").toURI().toURL()
val loader = URLClassLoader(arrayOf(jar))
val clz = loader.loadClass("com.rarnu.sample.plugin.PluginRoutingKt")
val m = clz.getDeclaredMethod("pluginRouting", Routing::class.java)
m.invoke(null, r)
}
當(dāng)然你可以把代碼寫得更靈活一些,比如說從配置文件讀取要加載的路由:
fun loadPlugin(r: Routing, filePath: String, clsName: String, routingName: String) =
URLClassLoader(arrayOf(File(filePath).toURI().toURL()))
.loadClass(clsName)
.getDeclaredMethod(routingName, Routing::class.java)
.invoke(null, r)
然后就可以寫一個 Routing 來實現(xiàn)加載了:
get("/load/{file}/{cls}/{routing}") {
val f = call.parameters["file"] ?: ""
val cls = call.parameters["cls"] ?: ""
val routing = call.parameters["routing"] ?: ""
loadPlugin(this@sampleRouting, f, cls, routing)
call.respondText { "OK" }
}
宿主(2) 請求動態(tài)加載
跑起項目后,就可以在瀏覽器內(nèi)訪問以下 URL 以實現(xiàn)動態(tài)加載插件了
http://0.0.0.0/load/SamplePlugin-1.0.0.jar/com.rarnu.sample.plugin.PluginRoutingKt/pluginRouting
成功后即可在瀏覽器內(nèi)訪問以下 URL 來驗證結(jié)果:
http://0.0.0.0/plug
宿主(3) 動態(tài)卸載插件
如果只是用來測試或者要更新插件,那就必須先把插件卸載,否則會造成一個協(xié)程崩潰的異常:
io.ktor.server.engine.BaseApplicationResponse$ResponseAlreadySentException: Response has already been sent
其根本原因是一個 request 只能有一個 response,如果有兩個同名的路由,則會發(fā)送兩次 response 引起該錯誤。
Ktor 并沒有提供動態(tài)卸載路由的能力,只能研究源碼了,所幸的是這部分源碼非常容易找到:
@ContextDsl
open class Route(val parent: Route?, val selector: RouteSelector) : ApplicationCallPipeline() {
val children: List<Route> get() = childList
private val childList: MutableList<Route> = ArrayList()
@Volatile private var cachedPipeline: ApplicationCallPipeline? = null
internal val handlers = ArrayList<PipelineInterceptor<Unit, ApplicationCall>>()
fun createChild(selector: RouteSelector): Route {
val existingEntry = childList.firstOrNull { it.selector == selector }
if (existingEntry == null) {
val entry = Route(this, selector)
childList.add(entry)
return entry
}
return existingEntry
}
... ...
}
可以清楚的看到,添加路由的過程,其實就是調(diào)用了 createChild 并且將新增的內(nèi)容放到 childList 內(nèi),所以刪去 childList 里的內(nèi)容,就可以實現(xiàn)卸載路由。由此也可以輕松的寫下代碼了:
fun unloadRoute(r: Routing, routes: Array<String>) {
val list = r.javaClass.superclass.getDeclaredField("childList").apply { isAccessible = true }.get(r) as MutableList<*>
list.removeIf { "$it" in routes }
}
同樣的,現(xiàn)在可以寫一個路由來完成卸載:
get("/unload/{routing}") {
val routing = call.parameters["routing"] ?: ""
unloadRoute(this@sampleRouting, arrayOf(routing))
call.respondText { "OK" }
}
好了,就寫到這吧,Ktor 動態(tài)化的能力已經(jīng)具備了,如果要投入實用,還需要加入一定的配置文件與讀寫的機制。不需要使用路由請求的方式來加載,而是在監(jiān)視到文件變化時自動實現(xiàn)加載,才是最好的解決方案。