Kotlin插件化应用实践

1,324 阅读7分钟

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存在差别。接下来的内容主要从gradle配置入手分析pf4j这个多模块项目。

简化的目录树如下:

.
|-- 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
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
# PF4J
pf4jVersion=3.1.0

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

  • settings.gradle
include 'api'
include 'app'

include 'plugins'

include 'plugins:plugin1'
include 'plugins:plugin2'
include 'plugins:plugin3'

这个文件表示了这个多模块项目要包含哪些模块,通过":"来表示路径分隔符。

分析完根目录,就分析api模块。

api
|-- build.gradle
`-- src
    `-- main
        `-- java
            `-- org
                `-- pf4j
                    `-- demo
                        `-- api
                            `-- Greeting.java

这个模块中只有一个build.gradle文件,内容如下:

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代码:

/*
 * 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:

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:

########################################
# - load only these plugins
# - add one plugin id on each line
# - put this file in plugins folder
########################################
#welcome-plugin

而它的build.gradle文件如下:

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:

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):

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包)都是这样的组织方式:

|-- 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了:

app
|-- build.gradle
`-- src
    `-- main
        |-- java
        |   `-- org
        |       `-- pf4j
        |           `-- demo
        |               |-- Boot.java
        |               `-- WhazzupGreeting.java
        `-- resources
            `-- log4j.properties

app的build.gradle如下:

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是如何加载和使用插件的网上有例子,这里不再啰嗦,但是这框架的插件脑瘫用法我是忍不了了。

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:

package io.github.hochikong.ktmeta.driver

interface AbstractDriver{
    fun accessDrive(): String
    fun exitDrive(): Boolean
}

接着创建了一个测试plugin,其build.gradle内容如下:

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所检查以查找需要加载的类的路径。

我写的测试代码:

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并获取类的加载路径。

然后使用这些代码来加载和调用插件实现的函数:

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扫描,并根据需要动态加载这些驱动并执行接口中约定的方法。