用一些歪门邪道自动化构建 intellij idea 插件

code

前言:请停止使用过时技术构建项目

官方文档中的使用devkit创建插件的部分已经不见了,如果要开新项目推荐大家一步到位直接使用gradle,这个文章的内容应该是2022年6月份左右做的,所以已经过时了,主要是记录一下之前偿还的技术债。

idea文档

之前我们的插件都是手动在idea工程里右键deploy打包的,但是这样打包过于不方便,并且有可能会因为各个人的idea版本,java版本不同导致打包出现问题,所以自动化打包让流水线来做这个事是很有必要的。

相关背景调查

Idea官方的建议是通过Gradle构建插件工程,并通过官方提供的gradle插件来进行打包发布等操作,如果使用官方文档上推荐的操作,自动化打包应该是一件比较容易的事情,只需要执行gradle命令就好了

参考 https://plugins.jetbrains.com/docs/intellij/deployment.html

和官方gradle插件的 buildPlugin 命令 https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#buildplugin-task

但是在国内的网络环境下使用gradle是有一点困难的,相关的依赖也需要添加在公司的制品库中,总结一句就是使用起来相当不便,所以我们的插件工程是使用的devkit的模式https://plugins.jetbrains.com/docs/intellij/using-dev-kit.html

打包的话是通过idea的菜单,Build | Prepare Plugin Module $MODULE_NAME$ for Deployment. 来进行打包。

但是这个打包的操作是在界面上完成的,我们不可能说在流水线上安装一个完整的idea客户端,然后通过界面进行打包,所以我们需要知道这个菜单背后的实现逻辑。

原理分析

jar包分析

idea的插件包一般来说都是以一个zip包,这个zip包里面包含了一个代表插件本身工程编译出来的jar包和一些工程内部的依赖包,例如github-colpilot插件,解压之后得到的结构基本上如下图

插件zip包结构

图中的github-copilot-intellij-1.5.0.5148.jar就是插件工程编译而出来的jar包。其他的jar包都是引入的依赖。

github-copilot-intellij-1.5.0.5148.jar就和普通的java工程一样,里面装了class文件,其中有一个特殊的就是META-INF下面有idea插件的描述性文件plugin.xml。

自此我们知道了idea插件打包出来的结构,我们再来看一下Idea的 Prepare Plugin Module $MODULE_NAME$ for Deployment菜单背后的逻辑

从idea源码入手

这部分对应的源码在 https://github.com/JetBrains/intellij-community/blob/master/plugins/devkit/devkit-core/src/build/PrepareToDeployAction.java

入口是 actionPerformed方法

核心的逻辑为每个模块都会调用的doPrepare方法:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
public static boolean doPrepare(Module module, List<String> errorMessages, List<String> successMessages) {
String pluginName = module.getName();
String defaultPath = new File(module.getModuleFilePath()).getParent() + File.separator + pluginName;
// 收集所有运行时模块(modules), 依赖(libs)
Set<Module> modules = new HashSet<>();
PluginBuildUtil.getDependencies(module, modules);
modules.add(module);
Set<Library> libs = new HashSet<>();
for (Module dep : modules) {
PluginBuildUtil.getLibraries(dep, libs);
}

Map<Module, String> jpsModules = collectJpsPluginModules(module);
modules.removeAll(jpsModules.keySet());

// 如果没有更多的依赖和模块则为 jar 包如果有则为 zip 包
boolean isZip = !libs.isEmpty() || !jpsModules.isEmpty();
String oldPath = defaultPath + (isZip ? JAR_EXTENSION : ZIP_EXTENSION);
File oldFile = new File(oldPath);
if (oldFile.exists()) {
String message = DevKitBundle.message("suggest.to.delete", oldPath), title = DevKitBundle.message("info.message");
if (Messages.showYesNoDialog(module.getProject(), message, title, Messages.getInformationIcon()) == Messages.YES) {
FileUtil.delete(oldFile);
}
}

String dstPath = defaultPath + (isZip ? ZIP_EXTENSION : JAR_EXTENSION);
File dstFile = new File(dstPath);
return clearReadOnly(module.getProject(), dstFile) && ProgressManager.getInstance().runProcessWithProgressSynchronously(() -> {
ProgressIndicator progressIndicator = ProgressManager.getInstance().getProgressIndicator();
if (progressIndicator != null) {
progressIndicator.setText(DevKitBundle.message("prepare.for.deployment.task.progress"));
progressIndicator.setIndeterminate(true);
}
try {
// 编译插件jar包
File jarFile = preparePluginsJar(module, modules);
if (isZip) {
try {
// 合并所有的模块,插件,依赖
processLibrariesAndJpsPlugins(jarFile, dstFile, pluginName, libs, jpsModules);
}
finally {
FileUtil.delete(jarFile);
}
}
else {
FileUtil.rename(jarFile, dstFile);
}
LocalFileSystem.getInstance().refreshIoFiles(Collections.singleton(dstFile), true, false, null);
successMessages.add(DevKitBundle.message("saved.message", isZip ? 1 : 2, pluginName, dstPath));
}
catch (IOException e) {
errorMessages.add(e.getMessage() + "\n(" + dstPath + ")");
}
}, DevKitBundle.message("prepare.for.deployment.task", pluginName), true, module.getProject());
}

可以看到在这个方法里面,首先收集了所有的模块和依赖,如果没有只有单个工程,没有模块和依赖,那么最终的输出结果就是一个单独的jar包,如果有模块和依赖,则将编译后的代码输出为一个jar包,并将所有的依赖jar包和这个依赖合并为一个zip包,最终组成插件的安装包。

而在 preparePluginsJar 方法中,则是创建jar包,并将每个模块编译出来的 class 文件和插件的配置文件 plugin.xml 复制进jar包中

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
private static File preparePluginsJar(Module module, Set<Module> modules) throws IOException {
PluginBuildConfiguration configuration = PluginBuildConfiguration.getInstance(module);
Manifest manifest = createOrFindManifest(configuration);
return jarModulesOutput(modules, manifest, configuration != null ? configuration.getPluginXmlPath() : null);
}

private static File jarModulesOutput(Set<Module> modules, @Nullable Manifest manifest, @Nullable String pluginXmlPath) throws IOException {
File tempFile = FileUtil.createTempFile(TEMP_PREFIX, JAR_EXTENSION);

try (Compressor.Jar jar = new Compressor.Jar(tempFile)) {
FileTypeManager manager = FileTypeManager.getInstance();
Set<String> uniqueEntries = new HashSet<>();
jar.filter((entryName, file) -> !manager.isFileIgnored(PathUtil.getFileName(entryName)) && uniqueEntries.add(entryName));

if (manifest != null) {
jar.addManifest(manifest);
}

for (Module module : modules) {
// 编译每个模块,并将代码 class 文件添加进 jar 包中
CompilerModuleExtension extension = CompilerModuleExtension.getInstance(module);
if (extension != null) {
VirtualFile outputPath = extension.getCompilerOutputPath();
if (outputPath != null) {
// pre-condition: output dirs for all modules are up-to-date
jar.addDirectory(new File(outputPath.getPath()));
}
}
}
// 复制 plugin.xml
if (pluginXmlPath != null) {
jar.addFile(PluginDescriptorConstants.PLUGIN_XML_PATH, new File(pluginXmlPath));
}
}

return tempFile;
}

如果是需要输出为zip包的化则执行 processLibrariesAndJpsPlugins 方法

理解了这个逻辑,我们就可以写自己的自动化脚本了。我们的工程结构比较奇葩,由于官方不支持maven的结构创建插件工程,所以我们创建了一个插件工程,然后创建了一堆maven模块来使用需要的依赖包,这一堆的maven模块是通过idea的Project Structure手动进行管理的(所以配置环境很容易爆炸)。我们还在sdk配置中添加了一些idea官方的扩展包,

这些包并不在普通的java运行环境中,所以我们在编译的时候需要把这些包添加classpath中,但是在插件运行时,这些jar包实际上是idea环境自带的,我们只是在打包的时候需要,并不需要添加到最终的class里面。

根据之前的逻辑,我们需要做的事情有以下几点

  1. 将 maven 中的依赖和 idea sdk 中的 jar 包一起联合编译我们的 java 文件为 class
  2. 将编译好的 class 还有 resources 里面的静态文件打包成插件本身的 Jar 包
  3. 完成以上步骤之后,将插件本身的 Jar 包和 Maven 依赖的 Jar 包一起打包成最终的插件包 (zip)

我们使用ant作为工具来构建我们的插件

首先我们需要准备好idea的SDK,maven的依赖,然后将这些依赖添加进 classpath 中,编译我们的 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<taskdef uri="antlib:org.apache.maven.resolver.ant" resource="org/apache/maven/resolver/ant/antlib.xml">
<classpath>
<fileset dir="." includes="maven-resolver-ant-tasks-*uber.jar"/>
</classpath>
</taskdef>

<target name="compile" depends="clean">
<resolver:resolve>
<resolver:dependencies pomRef="pom"/>
<resolver:path refid="cp.compile.main" classpath="compile"/>
</resolver:resolve>

<fileset id="download.jar" dir="${build.temp.dir}/local-repo">
<include name="**/*.jar"/>
</fileset>

<path id="runtime-classpath">
<fileset id="idea.lib1" dir="${idea.dir}/lib">
<include name="**/*"/>
</fileset>
<fileset id="idea.lib2" dir="${idea.dir}/plugins">
<include name="**/*.jar"/>
</fileset>
<fileset refid="download.jar"/>
<fileset dir="lib"/>
</path>

<manifestclasspath property="manifest.classpath" maxparentlevels="10"
jarfile="${classpath-compile.jar}">
<classpath refid="runtime-classpath" />
</manifestclasspath>

<jar destfile="${classpath-compile.jar}">
<manifest>
<attribute name="Class-Path" value="${manifest.classpath}"/>
</manifest>
</jar>

<javac srcdir="src/main/java" destdir="${build.temp.dir}/classes"
includeAntRuntime="false" source="8" target="8" encoding="UTF-8" fork="true">
<classpath>
<pathelement location="${classpath-compile.jar}" />
</classpath>
</javac>
</target>

然后将所有的静态资源,依赖的 Jar 包复制到对应的目录中

1
2
3
4
5
6
7
8
9
<copy todir="${build.plugin.dir}">
<fileset dir="lib"/>
<fileset refid="download.jar"/>
<mapper type="flatten"/>
</copy>
<copy todir="${build.temp.dir}/classes">
<fileset dir="src/main/resources"/>
<fileset dir="../resources"/>
</copy>

把编译好的文件和静态资源打包成 Jar 包,并最终和依赖一同打包成插件的 zip 包

1
2
3
4
5
6
7
<target name="package" depends="compile">
<jar destfile="${build.plugin.dir}/plugin.jar" basedir="${build.temp.dir}/classes"/>
<zip destfile="../plugin.zip" basedir="${build.plugin.zip}"/>
<resolver:artifacts id="output">
<artifact file="${build.plugin.dir}/plugin.jar"/>
</resolver:artifacts>
</target>

相关代码参考

https://github.com/JetBrains/intellij-community/blob/master/plugins/devkit/devkit-core/src/build/PrepareAllToDeployAction.java

https://github.com/JetBrains/intellij-community/blob/master/plugins/devkit/devkit-core/src/build/PrepareToDeployAction.java

本文作者:Keshane

本文链接: https://keshane.moe/2024/03/24/auto-build-intellij-plugin-without-gradle/

评论

您所在的地区可能无法访问 Disqus 评论系统,请切换网络环境再尝试。