教你如何编写一个IDEA插件,并掌握核心知识点PSI

原创 吴就业 235 1 2020-10-09

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/c23582aac9784f369dce1c816abdde0a

作者:吴就业
链接:https://wujiuye.com/article/c23582aac9784f369dce1c816abdde0a
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

本篇文章写于2020年10月09日,从公众号|掘金|CSDN手工同步过来(博客搬家),本篇为原创文章。

由原来的四篇文章合并为一篇:

  • 《编写一个IDEA插件之:开发环境准备那些坑》
  • 《编写一个IDEA插件之:使用PSI分析Java代码》
  • 《编写一个IDEA插件之:自动生成Java代码》
  • 《编写一个IDEA插件之:事件监听》

不得不说,IDEA确实强大,不知不觉已经成为我们JavaAndroid程序员的首选IDE工具,不知不觉中,我们已经淡忘了Eclipse

IDEA有着极强的扩展功能,它提供插件扩展支持,让开发者能够参与到IDEA生态建设中,为更多开发者提供便利、提高开发效率。

我们常用的插件有LombokMybatis插件,这些插件都大大提高了我们的开发效率。

即便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就不能为空。

关于环境的准备网上有不少文章,推荐阅读:

使用PSI分析Java代码

PSIProgram Structure Interface的缩写,即程序结构接口。

如果我们想要分析源代码文件的内容就离不开PSI

我们知道,JVM在加载类之前,首先需要读取Class文件,并将Class文件解析成一个结构体对象,对应的是Class文件结构。与JVM解析Class文件不同的是,IDEA解析的是Java源代码,但IDEA也是将Java文件解析为一个结构体对象。

请记住一句话,对于任何拥有固定结构的文件或者代码,都可以使用访问者模式。

不仅Java文件,任何代码文件都会有一定的结构,否则编译器也不能识别,也是因为如此,IDEA实现的PSIJava字节码操作工具ASM有非常多的相似之处,除了都是将文件解析成结构外,也都支持使用访问者模式编辑文件,一个大的结构下面包含许多小的结构,小的结构也支持使用访问者模式编辑。

因为很相似,所以我们可以用学习使用ASM工具分析、创建、或改写Class文件的思维去学习PSI

由于不同的编程语言编写的代码文件有不同的结构,IDEA将文件结构抽象为接口,叫程序结构接口文件(PSI File),不同类型的文件解析后生成不同的PsiFile接口的实现类实例,这也是IDEA能够扩展支持多语言的基础。

PsiFile接口

一个文件就是一个PsiFile,也是一个文件的结构树的根节点,PsiFile是一个接口,如果文件是一个.java文件,那么解析生成的PsiFile就是PsiJavaFile对象,如果是一个Xml文件,则解析后生成的是XmlFile对象。

PsiElement接口

Class文件结构包含字段表、属性表、方法表等,每个字段、方法也都有属性表,但在PSI中,总体上只有PsiFilePsiElement

Element即元素,一个PsiFile(本身也是PsiElement)由许多的PsiElement构成,每个PsiElement也可以由许多的PsiElement构成。

PsiElement用于描述源代码的内部结构,不同的结构对应不同的实现类。

对应Java文件的PsiElement种类有:PsiClassPsiFieldPsiMethodPsiCodeBlockPsiStatementPsiMethodCallExpression等等。其中,PsiFieldPsiMethod都是PsiClass的子元素,PsiCodeBlockPsiMethod的子元素,PsiMethodCallExpressionPsiCodeBlock的子元素,正是这种关系构造成了一棵树。

解析一个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是将当前查看的文件解析为结构树,选中选项后弹出如下图所示的窗口。

当我们选中源码时,IDEA会找到对应的PsiElement标志为选中状态,如上图左侧的PSI Tree窗口所示。

PsiReference

一个PsiReference表示代码中某个PsiElement链接到相应的声明。

简单理解,PsiReference就是我们选中鼠标右键弹出菜单中Go ToDeclaration or Usages、或者按住command键+鼠标点击后能够跳转到相应声明的依据。

我们可以通过调用PsiElement#getReference方法获取一个PsiElementPsiReference,然后调用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-generatorGsonFormat实现的功能不同,但原理相似。

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>

效果如下图所示。

现在我们继续完成GeneratedInvokePayMethodActionactionPerformed方法。

由于Intellij Platform不允许插件在主线程中进行实时的文件写入,只能通过异步任务来完成写入,因此,我们需要通过WriteCommandAction.runWriteCommandAction来执行一个后台写入操作,如下代码所示。

public class GeneratedInvokePayMethodAction extends AnAction {

    @Override
    public void actionPerformed(@NotNull AnActionEvent event) {
      // 立即执行一个后台任务
      WriteCommandAction.runWriteCommandAction(editor.getProject(), () -> {
           // do ...
      });
    }
}

想要在当前光标所在的位置插入一行代码,那么我们需要做这些事情:

// AnActionEvent event
PsiFile psiFile = event.getData(LangDataKeys.PSI_FILE);
PsiElement element = psiFile.findElementAt(editor.getCaretModel().getOffset());

其中editor.getCaretModel().getOffset()为获取当前光标位置;

另外,可以使用AnActionEvent#getData方法获取当前光标所在的PsiElement,代码如下:

// AnActionEvent event
PsiElement psiElement = event.getData(LangDataKeys.PSI_ELEMENT);

但这种方式不适用于当前场景,如果将光标放在一行代码的;后面,那么该方法就会返回null值。

PsiElement codeBlock = element;
while (!(codeBlock instanceof PsiCodeBlock)) {
     codeBlock = codeBlock.getParent();
}

例如创建一个表达式元素(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

// 参数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) {
  }
}

有了项目管理事件监听器之后,我们如何注册该监听器呢?

有两种方法,一种是代码方式注册,一种是在plugin.xml插件配置文件中注册。

代码方式注册可调用ProjectManager.getInstance().addProjectManagerListener();方法注册,但这种方式注册有一个弊端,就是无法监听到项目打开事件,projectOpened方法不会被调用,应该在我们能够调用该方法注册监听器时,项目实际已经打开了。

所以注册项目管理监听器我们只能通过修改plugin.xml配置文件方式注册,配置代码如下:

<applicationListeners>
    <listener class="com.msyc.ycpay.plugin.listener.MyProjectManagerListener"
              topic="com.intellij.openapi.project.ProjectManagerListener"/>
</applicationListeners>

当我们给IDEA注册自定义的项目管理事件监听器后,我们就可以通过项目管理事件监听器注册其它的事件监听器,例如注册模块监听事件,这是因为模块的事件触发在项目打开事件触发之后才会触发。因此,在projectOpened方法中可注册任何其它的事件监听器。

注册模块事件监听器代码如下:

project.getMessageBus().connect()
.subscribe(ProjectTopics.MODULES, new ModuleListener(){});

subscribe方法需要两个参数: * topic:主题,可选值参见ProjectTopics类的源码,有PROJECT_ROOTSMODULES; * handler:事件处理器、监听器,当topicMODULES时,要求传递一个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) {
  }
}

如何监听文件编辑事件

通过前面两篇的学习,我们已经了解什么是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);
}

参考

#后端

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

如何使用Kafka订阅数据库的实时Binlog

订阅Binlog的目的在于,实现实时的缓存更新、处理复杂逻辑数据实时同步到Elasticsearch或其它库-表等业务场景,本篇介绍如何使用Kafka订阅数据库的实时Binlog。

Spring Data R2DBC快速上手指南

本篇内容介绍如何使用r2dbc-mysql驱动程序包与mysql数据库建立连接、使用r2dbc-pool获取数据库连接、Spring-Data-R2DBC增删改查API、事务的使用,以及R2DBC Repository。

使用Spring WebFlux + R2DBC搭建消息推送服务

消息推送服务主要是处理同步给用户推送短信通知或是异步推送短信通知、微信模板消息通知等。本篇介绍如何使用Spring WebFlux + R2DBC搭建消息推送服务。

02-为什么需要服务降级以及常见的几种降级方式

上一篇笔者跟大家分享了一个真实的服务雪崩的故事,也分析了造成服务雪崩的真正原因,那么,如何才能避免服务雪崩的出现呢?

01-分享一次服务雪崩问题排查经历

笔者想跟大家分享笔者经历的一次服务雪崩事故,分析导致此次服务雪崩事故的原因。或许大多数读者都有过这样的经历,这是项目给我们上的一次非常宝贵的实战课程。

序言:为什么写这个专栏

随着微服务的流行,很多公司也在逐渐的将单体架构项目重构为微服务项目,单体架构微服务化后也将面临更多的挑战。服务的调用错综复杂,如何保护自身不被其它服务打垮也是项目微服务化后重点需要考虑的问题。