本篇文章写于2020年10月09日,从公众号|掘金|CSDN手工同步过来(博客搬家),本篇为原创文章。
由原来的四篇文章合并为一篇:
- 《编写一个IDEA插件之:开发环境准备那些坑》
- 《编写一个IDEA插件之:使用PSI分析Java代码》
- 《编写一个IDEA插件之:自动生成Java代码》
- 《编写一个IDEA插件之:事件监听》
不得不说,IDEA
确实强大,不知不觉已经成为我们Java
、Android
程序员的首选IDE
工具,不知不觉中,我们已经淡忘了Eclipse
。
IDEA
有着极强的扩展功能,它提供插件扩展支持,让开发者能够参与到IDEA
生态建设中,为更多开发者提供便利、提高开发效率。
我们常用的插件有Lombok
、Mybatis
插件,这些插件都大大提高了我们的开发效率。
即便IDEA
功能已经很强大,并且也已有很多的插件,但也不可能面面俱到,有时候我们需要自给自足。
如何准备插件开发环境
在开发插件之前,你可能需要先搭建环境。
你可能需要下载一个社区版本的IDEA
和源码,社区版IDEA
可以到官网下载,而源码可从gitee
克隆(从github
克隆太慢)。
从gitee
克隆:
git clone --depth 1 https://gitee.com/mirrors/intellij-community.git
因为源码项目太大,clone
到一半时可能会出错,需要使用--depth 1
指定克隆深度,1
表示只克隆最近一次commit
。
虽然--depth 1
只会把默认分支clone
下来,其他远程分支并不在本地,但我们不需要用到其它的分支,如果有需要,可以先git fetch
指定分支再切换到指定分支。
$ git remote set-branches origin 'remote_branch_name'
$ git fetch --depth 1 origin remote_branch_name
$ git checkout remote_branch_name
安装社区版后,我们在社区版的IDEA
开发插件。
需要先搭建环境,安装插件DevKit
,以及创建IntelliJ Platform SDK
。下图为官方文档中《搭建开发环境》部分的截图。
在新建插件项目时,选择IntelliJ Platform Plugin
,注意不要勾选Groovy
。勾选Groovy
表示你需要添加一些Groovy
的链接库和框架,此时底部的Use library
就不能为空。
关于环境的准备网上有不少文章,推荐阅读:
- 《从零开始编写
IntelliJ IDEA
插件》:https://juejin.im/post/6844904058625474573 - 官方教程(可使用谷歌浏览器的翻译插件翻译成中文阅读):https://jetbrains.org/intellij/sdk/docs/basics/getting_started.html
使用PSI分析Java代码
PSI
是Program Structure Interface
的缩写,即程序结构接口。
如果我们想要分析源代码文件的内容就离不开PSI
。
我们知道,JVM
在加载类之前,首先需要读取Class
文件,并将Class
文件解析成一个结构体对象,对应的是Class
文件结构。与JVM
解析Class
文件不同的是,IDEA
解析的是Java
源代码,但IDEA
也是将Java
文件解析为一个结构体对象。
请记住一句话,对于任何拥有固定结构的文件或者代码,都可以使用访问者模式。
不仅Java
文件,任何代码文件都会有一定的结构,否则编译器也不能识别,也是因为如此,IDEA
实现的PSI
与Java
字节码操作工具ASM
有非常多的相似之处,除了都是将文件解析成结构外,也都支持使用访问者模式编辑文件,一个大的结构下面包含许多小的结构,小的结构也支持使用访问者模式编辑。
因为很相似,所以我们可以用学习使用ASM
工具分析、创建、或改写Class
文件的思维去学习PSI
。
由于不同的编程语言编写的代码文件有不同的结构,IDEA
将文件结构抽象为接口,叫程序结构接口文件(PSI File
),不同类型的文件解析后生成不同的PsiFile
接口的实现类实例,这也是IDEA
能够扩展支持多语言的基础。
PsiFile
接口
一个文件就是一个PsiFile
,也是一个文件的结构树的根节点,PsiFile
是一个接口,如果文件是一个.java
文件,那么解析生成的PsiFile
就是PsiJavaFile
对象,如果是一个Xml
文件,则解析后生成的是XmlFile
对象。
PsiElement
接口
Class
文件结构包含字段表、属性表、方法表等,每个字段、方法也都有属性表,但在PSI
中,总体上只有PsiFile
和PsiElement
。
Element
即元素,一个PsiFile
(本身也是PsiElement
)由许多的PsiElement
构成,每个PsiElement
也可以由许多的PsiElement
构成。
PsiElement
用于描述源代码的内部结构,不同的结构对应不同的实现类。
对应Java
文件的PsiElement
种类有:PsiClass
、PsiField
、PsiMethod
、PsiCodeBlock
、PsiStatement
、PsiMethodCallExpression
等等。其中,PsiField
、PsiMethod
都是PsiClass
的子元素,PsiCodeBlock
是PsiMethod
的子元素,PsiMethodCallExpression
是PsiCodeBlock
的子元素,正是这种关系构造成了一棵树。
解析一个Java
文件有上百种类型的PsiElement
,对于一个新手,我们如何才能快速的认识对应Java
代码文件中的每行代码都会解析生成呢?好在IDEA
提供了PSI
视图查看器。
如果你正在编写插件,那么IDEA
会自动在“工具”菜单中显示“查看PSI
结构”的选项,否则,我们需要修改IDEA
的配置文件才能在“工具”菜单中看到这个选项。
配置文件在IDEA
安装路径的bin
目录下,找到idea.properties
文件,如下图所示。
我们需要在idea.properties
文件中添加这样一行配置:
idea.is.internal=true
添加配置后重启IDEA
就能看到tools
菜单下新加了两个选择,如下图所示。
其中View PSI Structure of Current File
是将当前查看的文件解析为结构树,选中选项后弹出如下图所示的窗口。
Show PSI structure for
:选择PsiFile
类型;Show PsiWhiteSpace
:去掉勾选后可以隐藏表示连续空格(包括换行符)的元素PsiElement
;
当我们选中源码时,IDEA
会找到对应的PsiElement
标志为选中状态,如上图左侧的PSI Tree
窗口所示。
PsiReference
一个PsiReference
表示代码中某个PsiElement
链接到相应的声明。
简单理解,PsiReference
就是我们选中鼠标右键弹出菜单中Go To
的Declaration or Usages
、或者按住command
键+鼠标点击后能够跳转到相应声明的依据。
我们可以通过调用PsiElement#getReference
方法获取一个PsiElement
的PsiReference
,然后调用PsiReference#resolve
方法取得该PsiElement
链接到(引用)的PsiElement
。
例如,获取一个方法调用表达式PsiMethodCallExpression
链接到声明的PsiElement
可以这样写。
下面是这段代码的一次调试的截图:
如上图所示,此次PsiMethodCallExpression
表示的是payConfigApplicationService.createOrUpdate(dto)
,PsiMethodCallExpression
也是一个PsiElement
,可以调用getReference
获取到该元素的PsiReference
实例,最后调用PsiReference
实例的resolve
方法取得该方法调用表达式元素链接到的声明是一个PsiMethod
,表示createOrUpdate
方法。
我们还可以继续获取该表达式链接到的PsiMethod
所属的类PsiClass
。
通过分析一个元素的PsiReference
,我们可以判断一行代码是否有调用某个类的方法,如果有,则在代码行号处显示一个图标,点击图标跳转到目标方法等。
总之,要想在自定义插件中分析源代码就不得不了解PSI
。
笔者是通过阅读官方文档、通过PSI
查看器学习了解PSI
、并通过分析MybatisX
这个插件的源码,以及自己动手不断试错学习如何编写一个IDEA
插件的。
自动生成Java代码
我很喜欢IDEA
的一键自动生成代码功能,例如自动生成构造方法、字段的Get/Set
方法、ToString
方法等等,除此之外,也有一些插件提供自动生成代码的功能,例如我们所熟悉的GsonFormat
插件,使用该插件可以为我们快速的解析json
字符串生成一个对应的Java
类,这在对接一些第三方API
时很有帮助。
笔者写过一个运行时根据json
自动生成Class
的工具包:json-class-generator,与GsonFormat
不同的是,该工具使用ASM
在运行时解析json
结构树生成类的字节码,而GsonFormat
生成的是Java
源代码。当时写json-class-generator
目的是实现一个第三方API
自动对接框架,由于该框架涉及到业务,所以没有开源。
虽然json-class-generator
与GsonFormat
实现的功能不同,但原理相似。
Java
源代码编译后生成的Class
文件有固定的结构,而在IDEA
中,Java
源代码也同样有固定的结构:PSI
程序结构。与使用ASM
操作字节码修改一个Class
文件一样,我们也可以通过编辑一个Java
源代码的PSI
程序结构的元素修改Java
代码。
自动生成Java源代码
我们模仿IDEA
提供的自动生成代码功能,给右键弹出菜单的Generate...
菜单添加一个子菜单:GeneratedInvokePayMethod
,在插件使用者点击该菜单时自动生成一串代码,并且生成的代码插入到当前光标所在位置。
首先需要编写一个对应GeneratedInvokePayMethod
菜单的Action
,并实现actionPerformed
方法,代码如下。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
}
}
actionPerformed
方法在菜单被点击时调用,该方法只有一个参数:
* event
:这个参数封装了很多有用的信息,比如我们可以从该参数获取当前文件的PsiFile
实例、获取当前光标落在的PsiElement
等。
其次,我们需要注册Action
,将Action
放到右键弹出菜单的GenerateGroup
。需要在plugin.xml
文件添加如下配置信息:
<actions>
<action id="xxx.action.GeneratedInvokePayMethodAction" class="com.xxx.plugin.action.GeneratedInvokePayMethodAction"
text="GeneratedInvokePayMethod">
<!-- 将action放在哪 -->
<add-to-group group-id="GenerateGroup" anchor="first"/>
</action>
</actions>
效果如下图所示。
现在我们继续完成GeneratedInvokePayMethodAction
的actionPerformed
方法。
由于Intellij Platform
不允许插件在主线程中进行实时的文件写入,只能通过异步任务来完成写入,因此,我们需要通过WriteCommandAction.runWriteCommandAction
来执行一个后台写入操作,如下代码所示。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
// 立即执行一个后台任务
WriteCommandAction.runWriteCommandAction(editor.getProject(), () -> {
// do ...
});
}
}
想要在当前光标所在的位置插入一行代码,那么我们需要做这些事情:
- 1、先判断当前文件是否是一个
Java
文件,借助actionPerformed
的event
参数可取得当前文件的PsiFile
实例,判断PsiFile
实例的类型是否为PsiJavaFile
,如果不是,说明这不是一个Java
代码文件,什么也不需要做(或者可以给出对话框提示);
// AnActionEvent event
PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
- 2、通过第一步获取的
PsiFile
,查找当前光标所在位置的PsiElement
实例;
PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset());
其中editor.getCaretModel().getOffset()
为获取当前光标位置;
另外,可以使用AnActionEvent#getData
方法获取当前光标所在的PsiElement
,代码如下:
// AnActionEvent event
PsiElement psiElement = event.getData(LangDataKeys.PSI_ELEMENT);
但这种方式不适用于当前场景,如果将光标放在一行代码的;
后面,那么该方法就会返回null
值。
- 3、根据光标所在的
PsiElement
,获取该PsiElement
所在方法的PsiCodeBlock
(一个方法只有一个PsiCodeBlock
);
PsiElement codeBlock = element;
while (!(codeBlock instanceof PsiCodeBlock)) {
codeBlock = codeBlock.getParent();
}
- 4、创建新的
PsiElement
,该PsiElement
就是需要自动生成的代码;
例如创建一个表达式元素(PsiExpression
),可使用PsiElementFactory#createExpressionFromText
方法创建,代码如下。
PsiElement newElement = PsiElementFactory.getInstance(element.getProject())
.createExpressionFromText("Invocation<Object> invocation = Invocation.<Object>builder()\n" +
" .scope(scope)\n" +
" .service(payType)" +
" .operate(\"" + method + "\")\n" +
" .body(merchantNo)\n" +
" .build()", element.getContext());
PsiElementFactory
使用工厂模式生产PsiElement
,提供了大量的API
,例如创建字段的createField
、创建方法的createMethod
、创建类的createClass
,创建关键字的createKeyword
。
- 5、最后,将新创建的
PsiElement
添加到光标所在PsiElement
的后面;
// 参数1:新增的PsiElement
// 参数2:位置参照的PsiElement
codeBlock.addAfter(newElement, element);
完整示例代码如下。
public class GeneratedInvokePayMethodAction extends AnAction {
@Override
public void actionPerformed(@NotNull AnActionEvent event) {
WriteCommandAction.runWriteCommandAction(editor.getProject(), () -> {
PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
// 查找当前光标停留在的元素
PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset());
// 获取当前方法的PsiCodeBlock元素
PsiElement codeBlock = element;
while (!(codeBlock instanceof PsiCodeBlock)) {
codeBlock = codeBlock.getParent();
}
// 使用PsiElementFactory创建表达式元素
PsiElement newElement = PsiElementFactory.getInstance(element.getProject())
.createExpressionFromText("Invocation<Object> invocation = Invocation.<Object>builder()\n" +
" .scope(scope)\n" +
" .service(payType)" +
" .operate(\"" + method + "\")\n" +
" .body(merchantNo)\n" +
" .build()", element.getContext());
// 将新创建的表达式元素插入到光标停留在的元素的后面
codeBlock.addAfter(newElement, element);
});
}
}
实际要实现一个插件可能没有那么简单,例如本篇没有介绍到的UI
部分,笔者省略了一些步骤:当点击菜单时,先弹出一个Dialog
,提供一些选项,在完成选项点击ok
后再生成代码。
编写插件UI
其实与开发Android
应用编辑UI
布局类似,如果你开发过Android
应用,那么也就不难理解。
事件监听
事件监听,我们最熟悉不过的就是开发APP
时,监听按钮点击事件、列表滑动事件、手指触摸及移动事件、网络状态事件等等。事件监听大多通过观察者模式实现,首先API
调用者不需要知道后台是如何检测出网络状态不可用的,而只需要向系统注册一个监听器,当网络状态发生改变时,由系统回调给监听器。
如何监听项目或模块改变事件
首先是项目级别的事件监听。添加一个项目管理事件监听器,我们需要实现ProjectManagerListener
接口,该接口有四个方法,其源码如下。
public interface ProjectManagerListener extends EventListener {
default void projectOpened(@NotNull Project project) {
}
default void projectClosed(@NotNull Project project) {
}
default void projectClosing(@NotNull Project project) {
}
default void projectClosingBeforeSave(@NotNull Project project) {
}
}
- projectOpened:该方法在项目打开时被回调;
- projectClosingBeforeSave:在关闭项目时,开始保存项目之前被回调,或者说是在调用
FileDocumentManager#saveAllDocuments
方法保存所有文件之前被调用; - projectClosing:在
projectClosingBeforeSave
方法之后被回调; - projectClosed:与projectClosing的区别在于,
projectClosed
在项目已经关闭时被回调,在ProjectManagerImpl#closeProject
方法执行到最后一行代码时被调用。
有了项目管理事件监听器之后,我们如何注册该监听器呢?
有两种方法,一种是代码方式注册,一种是在plugin.xml插件配置文件中注册。
代码方式注册可调用ProjectManager.getInstance().addProjectManagerListener();
方法注册,但这种方式注册有一个弊端,就是无法监听到项目打开事件,projectOpened
方法不会被调用,应该在我们能够调用该方法注册监听器时,项目实际已经打开了。
所以注册项目管理监听器我们只能通过修改plugin.xml
配置文件方式注册,配置代码如下:
<applicationListeners>
<listener class="com.msyc.ycpay.plugin.listener.MyProjectManagerListener"
topic="com.intellij.openapi.project.ProjectManagerListener"/>
</applicationListeners>
- topic:填写事件主题,类似于消息中间件中的Topic,只不过这里填写的是事件监听器的接口类名;
- class:添加接口的实现类名;
当我们给IDEA
注册自定义的项目管理事件监听器后,我们就可以通过项目管理事件监听器注册其它的事件监听器,例如注册模块监听事件,这是因为模块的事件触发在项目打开事件触发之后才会触发。因此,在projectOpened
方法中可注册任何其它的事件监听器。
注册模块事件监听器代码如下:
project.getMessageBus().connect()
.subscribe(ProjectTopics.MODULES, new ModuleListener(){});
subscribe
方法需要两个参数:
* topic
:主题,可选值参见ProjectTopics
类的源码,有PROJECT_ROOTS
和MODULES
;
* handler
:事件处理器、监听器,当topic
为MODULES
时,要求传递一个ModuleListener
;
ModuleListener
接口的定义如下:
public interface ModuleListener extends EventListener {
default void moduleAdded(@NotNull Project project, @NotNull Module module) {
}
default void beforeModuleRemoved(@NotNull Project project, @NotNull Module module) {
}
default void moduleRemoved(@NotNull Project project, @NotNull Module module) {
}
default void modulesRenamed(@NotNull Project project, @NotNull List<Module> modules, @NotNull Function<Module, String> oldNameProvider) {
}
}
- moduleAdded:添加模块完成时被调用;
- beforeModuleRemoved:模块被移除之前被调用;
- moduleRemoved:模块被移除时被调用;
- modulesRenamed:模块修改名字时被调用;
如何监听文件编辑事件
通过前面两篇的学习,我们已经了解什么是PSI
,知道一个文件对应一个PsiFile
,一个PsiFile
本身也是一个PsiElement
,由许多的PsiElement
构成,每个PsiElement
也都可以有子PsiElement
。
因此,监听文件改变事件其实就是监听PSI
树的结构改变事件,我们需要通过PsiManager
注册PsiTreeChangeListener
,代码如下。
PsiManager.getInstance(project).addPsiTreeChangeListener(
new PsiTreeChangeListener() {
// .....
}, FILES::clear);
至于注册时机,视情况而定,可以在Service
初始化时注册,可以在AnAction
触发时注册,也可以在projectOpened
事件方法中注册。
PsiTreeChangeListener
接口定义的方法较多,可以分为两类事件,一类是before
事件、一类是after
事件,接口源码如下。
public interface PsiTreeChangeListener extends EventListener {
void beforeChildAddition(@NotNull PsiTreeChangeEvent event);
void beforeChildRemoval(@NotNull PsiTreeChangeEvent event);
void beforeChildReplacement(@NotNull PsiTreeChangeEvent event);
void beforeChildMovement(@NotNull PsiTreeChangeEvent event);
void beforeChildrenChange(@NotNull PsiTreeChangeEvent event);
void beforePropertyChange(@NotNull PsiTreeChangeEvent event);
void childAdded(@NotNull PsiTreeChangeEvent event);
void childRemoved(@NotNull PsiTreeChangeEvent event);
void childReplaced(@NotNull PsiTreeChangeEvent event);
void childrenChanged(@NotNull PsiTreeChangeEvent event);
void childMoved(@NotNull PsiTreeChangeEvent event);
void propertyChanged(@NotNull PsiTreeChangeEvent event);
}
- childrenChanged:子元素内容改变时被调用;
- childReplaced:子元素被替换时被调用,触发
childReplaced
事件也会伴随着childrenChanged
事件; - childAdded:子元素添加时被调用,触发
childAdded
事件时也会伴随着childReplaced
、childrenChanged
或事件; - childRemoved:子元素移除时被调用,触发
childRemoved
事件也会伴随着childReplaced
、childrenChanged
事件; - propertyChanged:属性改变时被调用,例如修改文件名;
参考
intellij-platform-plugin-template
的项目管理监听器注册:https://sourcegraph.com/github.com/JetBrains/intellij-platform-plugin-template@main/-/blob/src/main/resources/META-INF/plugin.xml#L17:55- 接收有关项目结构变更的通知:https://jetbrains.org/intellij/sdk/docs/reference_guide/project_model/project.html?search=projectClosingBeforeSave