IDEA插件开发-Find Usage功能增强

9,340 阅读5分钟

IDEA插件开发-Find Usage功能增强

在平时的开发中,我们会有以下需求:

Temp.java
AClass a = new AClass();
a.setName(b.getName());
...
    
Temp2.java
BClass a = new BClass();
b.setName(c.getName());
...
    
Temp3.java
CClass a = new CClass();
DClass a = getDClass();
c.setName(d.getName());   
....

业务场景

业务场景是这样的,我们是一个对外交互的系统,每次交互需要调用多个上游系统获取不同的字段,将上游系统API返回的DTO通过Get,Set的方式的转换为自己系统的数据传输对象。

比如上图:假设我们系统的AClass的name字段需要赋值,DClass是外部系统,代码中通过BClass,CClass,最终从d的getName方法取到值。

经常会有业务人员或者测试人员甚至包括开发人员会询问某个字段取自上游的哪个系统的哪个字段?

我往往只熟悉自己系统的字段,所以我在IDEA里通过AClass的name字段右键使用Find Usage功能,去看a.setName方法在哪里被赋值找到b.getName,然后在找b.setName在哪里被调用赋值,一层一层找,最终找到d.getName。然后给业务人员说:我找到了,是D系统的name字段!!

但是如果只是少数几个字段,开发人员尚可维护,但如果出行字段有上百种,每个字段都这样查会非常费时,合理的方式是维护一份文档,别人问的时候直接查就行。但是我们都知道,人力维护文档最终随着代码变动,会导致文档与代码不一致。所以我们开发了这款IDEA插件,该插件可以从代码层面进行分析,将分析结果生成一份准确的Excel文档,后续取值代码有修改的时候,重新生成文档即可。

设计思路

设计思路很简单,就是把人工操作的步骤用IDEA插件替代:先找set方法,在看set方法内部的get方法来源,递归的寻找,直到找到最后的字段没有set方法调用就停止。

如上文:先找a.name 的引用列表,然后遍历引用列表找到a.setName(b.getName());这条语句,再分析出b.name的引用列表,找出b.setName(c.getName());以此类推,最终找到c.setName(d.getName()) ;语句,此时再分析d.name,可以发现在项目里没有d.setName方法了,我们可以认为d.name就是最终的目标

插件演示

先找一个类模拟我说的场景:

image-20221127193548911

这个插件有两个功能:

第一个功能,使用Find Usage窗口提示:在class的属性上按alt+enter(IDEA的Intention功能),点击find usage plus选项

image-20221127202652891

之后会弹出:

image-20221127193439775

第二个功能,生成Excel:在class的上按alt+enter,会以类名+日期的格式生成这个类下所有字段的追踪的Excel文件(生成在项目跟目录下),如果有多处赋值,就有多条记录。

image-20221127203857756

输出的内容包括字段名,最终找到类也就是上文说的DClass,调用链,和输出信息,如果是基本类型赋值的话会标明出来。

限制:也有一些小的限制,就是只能分析GetSet方式的赋值,如果赋值地方大于5处也不会统计。

有需要的可以下载项目后自己编译插件,项目地址:gitee.com/kagami1/fin…

我在仓库下也放了一个编译好的插件:gitee.com/kagami1/fin…

下面的文章开始讲解项目。

知识点

设计这款插件不难,难的是IDEA插件API的使用,IDEA插件的资料比较少,很多用法我需要查IDEA插件论坛才能知道。所以写一篇文章记录一下。

怎么实现Intention功能

Intention功能,也就是alt+enter弹出的框。只需要继承com.intellij.codeInsight.intention.PsiElementBaseIntentionAction并在plugin.xml里配置一下就可以了。继承PsiElementBaseIntentionAction有两个方法需要注意isAvailable负责intention是否显示,invoke负责执行intention逻辑。

怎么获取引用列表

通过ReferencesSearch方法,第一个参数是一个PsiElement元素,意思是要找的引用,也就是上文中的AClass的name字段。

Query<PsiReference> search = ReferencesSearch.search(setterForField, GlobalSearchScope.projectScope(project), false);

关于PSI的知识需要看下官网:plugins.jetbrains.com/docs/intell… 我的理解是IDEA把一个文件(不只是java的)解析成一个一个PSI元素用于编辑器处理。

怎么获取setName(b.getName())中的b相关的元素

这里涉及到一个工具类的使用PsiTreeUtil.getParentOfType;

for (PsiReference psiReference : psiReferences) {

    System.out.println("开始处理引用:" + psiReference.getCanonicalText());
    PsiCall psiCall = PsiTreeUtil.getParentOfType(psiReference.getElement(), PsiCall.class);
    PsiExpressionList argumentList = psiCall.getArgumentList();
    if (argumentList == null || argumentList.getExpressions().length == 0) {
        System.out.println("set方法的入参为空,不统计这种情况 " + psiCall.getText());
        continue;
    }
    PsiExpression[] psiExpressions = argumentList.getExpressions();
    if (psiExpressions.length > 1) {
        System.out.println("不支持大于两个入参的Set方法" + psiCall.getText());
        continue;
    }
    PsiExpression psiExpression = psiExpressions[0];//b.getName()
    ....
}

IDEA中把java文件中的元素解析成一颗语法树,所以通过这颗树可以获取调用链中的各种信息。

比如这里:我在AClass的name上进行PsiReference搜索,这里我们找到了a.setName(b.getName());的引用。但仅凭psiReference是无法获取b的信息的。而这句话PsiTreeUtil.getParentOfType(psiReference.getElement(), PsiCall.class);的意思是在PSI树上找有没有PsiCall。而a.setName正是调用语句,所以能找到。之后就简单了,从call中获取argument就可以找到b.getName的信息了,再从b找到BClass。

PsiTreeUtil简单来说是一个操作PSI树的工具类我们甚至可以从name一直往上找,可以找到name字段的类,属于哪个文件,属于哪个模块,哪个项目。

怎么将结果展示到Find Usage窗口中

这段代码找了好久终于找到能用的了

UsageViewManager usageViewManager = UsageViewManager.getInstance(project);
UsageViewPresentation usageViewPresentation = new UsageViewPresentation();
usageViewPresentation.setTargetsNodeText("涉及到的类");
usageViewPresentation.setCodeUsagesString("追踪到的引用");
usageViewPresentation.setTabText("Find Usage Plus");

UsageTarget[] usageTargets = {new PsiElement2UsageTargetAdapter(resultBeans.get(0).getField().getContainingClass())};

int count = (int) resultBeans.stream().filter(p -> p.getPsiElement() != null).count();
PsiElement[] primaryElements = new PsiElement[count];
UsageInfo[] usageInfo = new UsageInfo[count];
for (int i = 0; i < resultBeans.size(); i++) {
    ResultCollector.ResultBean resultBean = resultBeans.get(i);
    if (resultBean.getPsiElement() == null) {
        continue;
    }
    primaryElements[i] = resultBean.getPsiElement();
    usageInfo[i] = new UsageInfo(resultBean.getPsiElement());
}
if (primaryElements.length == 0) {
    UsageViewManager.getInstance(project).showUsages(UsageTarget.EMPTY_ARRAY, new Usage[]{}, usageViewPresentation);
} else {
    Usage[] convert = UsageInfoToUsageConverter.convert(primaryElements, usageInfo);
    usageViewManager.showUsages(usageTargets, convert, usageViewPresentation);
}

没看错,这么大一篇代码就是为了组装usageViewManager.showUsages的三个参数,第一个参数是指窗口中"涉及到的类"所指的地方,第二个参数是指窗口中"追踪到的引用"的地方,点击以后IDEA自动帮你关联到代码上。

其他细节

除了上面说的之外,剩下的就只有一些代码上的细节了。

  • 约定psiReferences大于5处的时候不统计
if (psiReferences.size() > 5) {
     System.out.println("set调用处大于5处,跳过");
     resultCollector.collect(psiField, ResultCollector.BIGGER_5, null);
     return;
 }
  • 不支持大于两个入参的Set方法
if (psiExpressions.length > 1) {
    System.out.println("不支持大于两个入参的Set方法" + psiCall.getText());
    continue;
}
  • 因为采用了递归的形式,遇到
a.setName(b.getName());
b.setName(a.getName());

的情况的时候会无限递归,所以需要用一个集合进行循环判定。