Kotlin插件化应用实践

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:

  • build.gradle
1
2
3
4
5
6
7
8
9
10
11
12
subprojects {
apply plugin: 'java'
repositories {
mavenLocal()
mavenCentral()
}
}
// plugin location
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,把依赖也搞进去了。

  • gradle.properties
1
2
# PF4J
pf4jVersion=3.1.0

这个文件也很简单,就是设定了一个可以被gradle使用的变量。

  • settings.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
/*
* Copyright (C) 2012-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pf4j.demo.api;
import org.pf4j.ExtensionPoint;
/**
* @author Decebal Suiu
*/
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) {
...
// create the plugin manager
PluginManager pluginManager = new JarPluginManager(); // or "new ZipPluginManager() / new DefaultPluginManager()"
// start and load all plugins of application
pluginManager.loadPlugins();
pluginManager.startPlugins();
// retrieve all extensions for "Greeting" extension point
List<Greeting> greetings = pluginManager.getExtensions(Greeting.class);
for (Greeting greeting : greetings) {
System.out.println(">>> " + greeting.getGreeting());
}
// stop and unload all plugins
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
/**
* Test only
* */
fun main() {
print("Enter your name: ")
val input = Scanner(System.`in`).nextLine()
if (input.trim() == "password") {
println("You can access the db.")
}
// path used for reading manifest
val path = "plugins/ktmeta-driver-1.0-SNAPSHOT.jar"
// absp used for load classes
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)
// 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()
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扫描,并根据需要动态加载这些驱动并执行接口中约定的方法。

Powered by Hexo and Hexo-theme-hiker

Copyright © 2017 - 2020 HOCHIKONG's WAPORIZer All Rights Reserved.

访客数 : | 访问量 :