Kotlin插件化应用踩坑之路
最近我在写一个基于Kotlin+Swing的桌面软件KtMeta,考虑在某些地方添加插件支持,于是就调研了下JVM生态下的插件框架。发现好像也不多,就一些基于OSGi的框架如Apache Felix和pf4j比较有名,star数比较多。但是听闻OSGi极其复杂,说实话我一个桌面软件开发有必要搞那么复杂吗?所以就调研了下有1.2k左右stars的pf4j框架。
坑爹的PF4J
由于我目前主要是使用Kotlin+Gradle,幸好pf4j也有Gradle的demo,所以我就深入分析了下这个demo。
!!!这个demo使用的gradle版本较低,因此语法和我现在用的gradle 6存在差别。
简化的目录树如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| . |-- api | `-- src | `-- main | `-- java | `-- org | `-- pf4j | `-- demo | `-- api |-- app | `-- src | `-- main | |-- java | | `-- org | | `-- pf4j | | `-- demo | `-- resources |-- gradle | `-- wrapper `-- plugins |-- plugin1 | `-- src | `-- main | `-- java | `-- org | `-- pf4j | `-- demo | `-- welcome |-- plugin2 | `-- src | `-- main | `-- java | `-- org | `-- pf4j | `-- demo | `-- hello `-- plugin3 `-- src `-- main `-- kotlin `-- org `-- pf4j `-- demo `-- kotlin
|
这个demo是个Gradle多模块项目。分为三个模块,api,app和plugins,而plugins里又有3个单独的plugin模块。
首先我们分析根目录的build.gradle、gradle.properties和settings.gradle:
1 2 3 4 5 6 7 8 9 10 11 12
| subprojects { apply plugin: 'java' repositories { mavenLocal() mavenCentral() } } ext.pluginsDir = rootProject.buildDir.path + '/plugins' task build(dependsOn: [':app:uberjar'])
|
这个文件很简单,定义了subprojects的插件,设置了repositories,定义了一个pluginsDir的变量,指向demo_gradle根目录下的plugins目录,标记gradle的java plugin的内建命令build依赖于app模块的任务(task)uberjar。uberjar简单地说就是fatjar,把依赖也搞进去了。
1 2
| # PF4J pf4jVersion=3.1.0
|
这个文件也很简单,就是设定了一个可以被gradle使用的变量。
1 2 3 4 5 6 7 8
| include 'api' include 'app' include 'plugins' include 'plugins:plugin1' include 'plugins:plugin2' include 'plugins:plugin3'
|
这个文件表示了这个多模块项目要包含哪些模块,通过”:”来表示路径分隔符。
分析完根目录,就分析api模块。
1 2 3 4 5 6 7 8 9 10
| api |-- build.gradle `-- src `-- main `-- java `-- org `-- pf4j `-- demo `-- api `-- Greeting.java
|
这个模块中只有一个build.gradle文件,内容如下:
1 2 3 4 5 6
| dependencies { compile group: 'org.pf4j', name: 'pf4j', version: "${pf4jVersion}" compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.5' testCompile group: 'junit', name: 'junit', version: '4.+' }
|
上面的文件只配置了api模块所依赖的库,并且标记为compile group。而pf4jVersion就是引用根目录下的gradle.properties的设置。
而api也是一个再简单不过的java代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package org.pf4j.demo.api; import org.pf4j.ExtensionPoint; public interface Greeting extends ExtensionPoint { String getGreeting(); }
|
这个代码没什么难的,就是实现了ExtensionPoint的一个接口而已。
分析完api模块,接着分析plugins:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| plugins |-- build.gradle |-- disabled.txt |-- enabled.txt |-- plugin1 | |-- build.gradle | |-- gradle.properties | `-- src | `-- main | `-- java | `-- org | `-- pf4j | `-- demo | `-- welcome | `-- WelcomePlugin.java |-- plugin2 | |-- build.gradle | |-- gradle.properties | `-- src | `-- main | `-- java | `-- org | `-- pf4j | `-- demo | `-- hello | `-- HelloPlugin.java `-- plugin3 |-- build.gradle |-- gradle.properties `-- src `-- main `-- kotlin `-- org `-- pf4j `-- demo `-- kotlin `-- KotlinPlugin.kt
|
这是一个子模块嵌套子模块,存在三个插件,分别为WelcomePlugin、HelloPlugin和KotlinPlugin。
首先是plugins的enabled.txt和disabled.txt,用于为pf4j分析哪些插件是启用的,下面的是enabled.txt:
1 2 3 4 5 6
| ######################################## # - load only these plugins # - add one plugin id on each line # - put this file in plugins folder ######################################## #welcome-plugin
|
而它的build.gradle文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| subprojects { jar { manifest { attributes 'Plugin-Class': "${pluginClass}", 'Plugin-Id': "${pluginId}", 'Plugin-Version': "${archiveVersion}", 'Plugin-Provider': "${pluginProvider}" } } task plugin(type: Jar) { archiveBaseName = "plugin-${pluginId}" into('classes') { with jar } into('lib') { from configurations.compile } archiveExtension ='zip' } task assemblePlugin(type: Copy) { from plugin into pluginsDir } } task assemblePlugins(type: Copy) { dependsOn subprojects.assemblePlugin } build.dependsOn project.tasks.assemblePlugins
|
这个文件分为三大块,subprojects,task assemblePlugins和build。文件的执行顺序从上到下。
首先分析subprojects,jar是java plugin的内置任务,但这里打包是交由task plugin负责的,jar只负责写入manifest文件。jar的manifest块负责读取子项目中的gradle.properties文件的设置并写入最终打包的zip文件的classes/META-INF/MANIFEST.MF文件内,如plugin3的gradle.properties:
1 2 3 4 5 6
| version=1.0.0 pluginId=KotlinPlugin pluginClass=org.pf4j.demo.kotlin.KotlinPlugin pluginProvider=Anindya Chatterjee pluginDependencies=
|
生成的zip包中对应的classes/META-INF/MANIFEST.MF就是这样(这里的Plugin-Version出了点问题,但是最重要的是Plugin-Class和Id):
1 2 3 4 5
| Manifest-Version: 1.0 Plugin-Id: KotlinPlugin Plugin-Provider: Anindya Chatterjee Plugin-Version: task ':plugins:plugin3:jar' property 'archiveVersion' Plugin-Class: org.pf4j.demo.kotlin.KotlinPlugin
|
在完成了jar块之后,就会进入task plugin,这个task主要是负责把build目录下的classes里面的内容以jar的组织方式打包进目标zip包中,而编译时依赖就会进入zip包的lib目录中。
完成plugin任务后,就会执行assemblePlugin,这个任务的类型是copy,from plugin to pluginsDir意味着把上一步打包出来的zip包复制到demo_gradle/plugins中。然后执行assemblePlugins
最后是表示plugins这个子项目的build任务需要先完成上述的jar,plugin和assemblePlugin和assemblePlugins任务才能执行。
最终打包完成的每个插件(zip包)都是这样的组织方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| |-- META-INF | `-- MANIFEST.MF |-- classes | |-- META-INF | | |-- MANIFEST.MF | | |-- extensions.idx | | `-- plugin3.kotlin_module | `-- org | `-- pf4j | `-- demo | `-- kotlin | |-- KotlinGreeting.class | `-- KotlinPlugin.class |-- lib | |-- annotations-13.0.jar | |-- commons-lang3-3.5.jar | |-- kotlin-stdlib-1.3.50.jar | |-- kotlin-stdlib-common-1.3.50.jar | |-- kotlin-stdlib-jdk7-1.3.50.jar | `-- kotlin-stdlib-jdk8-1.3.50.jar
|
最顶层的MANIFEST.MF只有Manifest-Version信息,而classes中的MANIFEST.MF则包含了插件项目的gradle.properties信息(Plugin-Id、Plugin-Provider、Plugin-Class和Plugin-Version)和Manifest-Version。
分析完plugins,就是app了:
1 2 3 4 5 6 7 8 9 10 11 12
| app |-- build.gradle `-- src `-- main |-- java | `-- org | `-- pf4j | `-- demo | |-- Boot.java | `-- WhazzupGreeting.java `-- resources `-- log4j.properties
|
app的build.gradle如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| apply plugin: 'application' mainClassName = 'org.pf4j.demo.Boot' run { systemProperty 'pf4j.pluginsDir', '../build/plugins' } dependencies { compile project(':api') compile group: 'org.pf4j', name: 'pf4j', version: "${pf4jVersion}" annotationProcessor(group: 'org.pf4j', name: 'pf4j', version: "${pf4jVersion}") compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.5' compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25' testCompile group: 'junit', name: 'junit', version: '4.+' } task uberjar(type: Jar, dependsOn: ['compileJava']) { zip64 true from configurations.runtimeClasspath.asFileTree.files.collect { exclude "META-INF/*.SF" exclude "META-INF/*.DSA" exclude "META-INF/*.RSA" zipTree(it) } from files(sourceSets.main.output.classesDirs) from files(sourceSets.main.resources) manifest { attributes 'Main-Class': mainClassName } archiveBaseName = "${project.name}-plugin-demo" archiveClassifier = "uberjar" }
|
apply plugin ‘application’是gradle的内置插件,用于生成可执行程序,程序的主类在uberjar的manifest的’Main-Class’里设置 。说实话这个文件也没什么好讲的。总而言之,这个项目如果拆分成三个单独的gradle项目,首先要做的是把api打包成jar,然后分别放入app和plugin项目中作为本地jar的依赖,对于app,这个api需要用implementation(在gradle 6被用于中代替compile)标记作为运行时依赖。而对于plugin,主要是作为编译时依赖(gradle 6中用compileOnly来标记)。最终打包出来的plugin中是不含api的jar的,而app需要把api包含在内,运行时调用插件即可。
关于这个app是如何加载和使用插件的网上有例子,这里不再啰嗦,但是这框架的插件脑瘫用法我是忍不了了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public static void main(String[] args) { ... PluginManager pluginManager = new JarPluginManager(); pluginManager.loadPlugins(); pluginManager.startPlugins(); List<Greeting> greetings = pluginManager.getExtensions(Greeting.class); for (Greeting greeting : greetings) { System.out.println(">>> " + greeting.getGreeting()); } pluginManager.stopPlugins(); pluginManager.unloadPlugins(); ... }
|
我用过OpenStack的社区项目stevedore,一个python的插件框架,它的用法是动态加载完驱动就可以把驱动分配给某个变量,然后就可以通过这个变量访问驱动所实现的方法,不需要时就可以卸载驱动。但是你看看这pf4j的傻逼API,我想不懂作者是怎么设计的。
Kotlin的插件框架实现
目前我认为最好的Kotlin插件功能实现是直接使用Java反射来处理。处理方式和使用pf4j时差不多。首先建立一个api项目,提供一个接口并打包成jar。在app中要implementation这个jar,而在要开发的plugin项目中compileOnly这个jar。如果你的plugin依赖其他库,这些依赖需要使用implementation而不是api标记,使用api会导致依赖传递到app中。
我创建了一个测试api:
1 2 3 4 5 6
| package io.github.hochikong.ktmeta.driver interface AbstractDriver{ fun accessDrive(): String fun exitDrive(): Boolean }
|
接着创建了一个测试plugin,其build.gradle内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| plugins { id 'java' id 'org.jetbrains.kotlin.jvm' version '1.3.72' } group 'io.github.hochikong.ktmeta.driver.official' version '1.0-SNAPSHOT' repositories { mavenCentral() } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" compileOnly files("libs/ktmeta-driver-api-1.0-SNAPSHOT.jar") testCompile group: 'junit', name: 'junit', version: '4.12' } compileKotlin { kotlinOptions.jvmTarget = "1.8" } compileTestKotlin { kotlinOptions.jvmTarget = "1.8" } jar { manifest { attributes 'Driver-Name0': 'io.github.hochikong.ktmeta.driver.official.FTPDriver' attributes 'Driver-Name1': 'io.github.hochikong.ktmeta.driver.official.SSHDriver' } from { configurations.runtime.collect { it.isDirectory() ? it : zipTree(it) } } }
|
上面的代码中,对于api使用compileOnly来表示依赖本地的api的jar包。在jar块中,我使用了带不同后缀的两个键Driver-Name来表示这个插件有两个驱动实现:SSHDriver和FTPDriver。这些信息会被写入plugin的MANIFEST.MF中,然后被app所检查以查找需要加载的类的路径。
我写的测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import io.github.hochikong.ktmeta.driver.AbstractDriver import java.lang.reflect.Method import java.net.URL import java.net.URLClassLoader import java.nio.file.Files import java.util.* import java.util.jar.JarFile import kotlin.system.exitProcess fun main() { print("Enter your name: ") val input = Scanner(System.`in`).nextLine() if (input.trim() == "password") { println("You can access the db.") } val path = "plugins/ktmeta-driver-1.0-SNAPSHOT.jar" val absp = "file:C:\\Users\\ckhoi\\IdeaProjects\\ktmeta\\plugins\\ktmeta-driver-1.0-SNAPSHOT.jar" val m = JarFile(path).manifest val ma = m.mainAttributes val classNames = ma.keys.filter { it.toString().startsWith("Driver-Name") }.map { ma[it] }.toList() println(classNames) print("Enter to exit...") if (Scanner(System.`in`).nextLine() is String) { exitProcess(0) } }
|
上面的测试代码充分利用了kotlin与java的无缝对接能力。使用JarFile读取jar包并解析manifest的内容,查找所有以Driver-Name开头的attributes并获取类的加载路径。
然后使用这些代码来加载和调用插件实现的函数:
1 2 3 4 5 6 7
| val urlClassLoader = URLClassLoader(arrayOf(URL(absp))) val driver = urlClassLoader.loadClass(className) val instance = driver.newInstance() println(instance is AbstractDriver) val met: Method = driver.getMethod("accessDrive") println(met.invoke(instance)) urlClassLoader.close()
|
最重要的就是约定jar包的manifest文件要包含何种字段,字段对应的值,然后约定app要扫描哪些内容。插件需要实现驱动接口并自包含依赖最后打包成jar提供给app扫描,并根据需要动态加载这些驱动并执行接口中约定的方法。