Kotlin 静态代码扫描和IDE实时提醒的工具实践

Kotlin 静态代码扫描和IDE实时提醒的工具实践

最近公司准备做代码质量优化监控,静态代码检查工具,忽然想到在前东家做过类似功能和分享过,现在弄到掘金上方便以后查阅

引言

自2017年Google I/O 大会上,谷歌宣布Android 平台正式支持Kotlin之后,Kotlin发展神速,近两年,kotlin已跻身Stack Overflow上最受喜爱的语言之一,也是GitHub上贡献者数量增长最快的语言之一,现在Kotlin编程语言是Android应用程序开发人员的首选语言,并且Google许多新增的Jetpack API和功能也优先提供Kotlin版本。在使用Kotlin语言编程的时候,代码规范相信每个开发人员或多或少都会遇到和思考的一个问题,而本文主要介绍的是一款基于IDE对Kotlin语言进行实时代码扫描的插件。

背景

在2018年中开始,珍爱网相关新生项目和旧项目的新生业务都开始使用kotlin语言进行开发了,众所周知,Kotlin有着空指针安全,方法扩展,支持函数式编程等诸多特性,这使得Kotlin比Java更加简洁优雅,代码可读性更高,这也大大提高了我们的开发效率,但是在使用中也会发现,使用不当也会存在一定的性能的开销,加上大部分开发人员都是由Java转Kotlin开发的,所以更加容易犯一些低级的错误,例如下面的伴生对象:

class Test {   
    companion object {       
        val A = "Hello"   
    }
}
public class Client {  
    public void say(){    
        LogUtils.d(Test.Companion.getA());   
    }
}
复制代码

虽然上面的代码简洁明了,但是编译成如下Java代码之后就不是那么简洁了

public final class Test {  
@NotNull   private static final String A = "Hello";  
public static final Test.Companion Companion = new Test.Companion((DefaultConstructorMarker)null);
@Metadata(  
     mv = {1, 1, 15},   
     bv = {1, 0, 3},    
     k = 1,   
     d1 = {"\u0000\u0014\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0002\b\u0003\b\u0086\u0003\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002R\u0014\u0010\u0003\u001a\u00020\u0004X\u0086D¢\u0006\b\n\u0000\u001a\u0004\b\u0005\u0010\u0006¨\u0006\u0007"},  
     d2 = {"Lcom/za/consultation/Test$Companion;", "", "()V", "A", "", "getA", "()Ljava/lang/String;", "app"} 
 )  
 public static final class Companion {  
     @NotNull    
     public final String getA() {  
         return Test.A;   
     }     
     private Companion() { 
     }      
     // $FF: synthetic method   
     public Companion(DefaultConstructorMarker $constructor_marker) {    
     this();   
      }  
      }
 }
复制代码

如上面Java代码Client类 调用Test类的常量A的时候,是先通过Test类静态常量Companion,然后再调用Companion实例的getA方法获取Test类常量A,然后返回。看看这个调用流程是不是比较绕。除此之外,代码编写规范和Code Review在珍爱网是一个很重要的开发流程,虽然上面的问题可以通过每次版本需求提测之后,在Code Review 流程卡住这些问题,然后不断分享和强化代码Review来缓解,但仍然面临着流程复杂,成本大和后续人员加入后需要不断补充分享等问题
image.png
其实在珍爱网,安卓开发已有一套基于sonar的静态扫描平台,如上图,但是发现代码不规范都是在开发人员编码之后,然后通过Sonar扫码之后才能告知开发人员,而在IDE插件上没有一个能够在开发人员编码的时候就实时提醒开发人员进行代码规范编写的工具,这样必然会导致开发人员修改不及时等问题,以前使用Java代码进行Android开发的时候还好,因为外部有很多针对Java语言进行静态代码扫描实时提醒的IDE插件,例如:阿里Java代码规范的P3C,PMD,CheckStyle等都有IDE插件,而Kotlin的话目前业界目前没有很好的开源IDE插件,同时我们有内部也有自己特定的代码规范规则,为了能够在开发人员在coding的时候能够及时提醒开发人员遵守珍爱相关代码规范,然后快速修复,这使得我们需要一款能够针对Kotlin语言进行实时扫描实时提醒的IDE插件。这个IDE插件主要有以下功能:

1.方便开发人员的安装和使用

2.支持Kotlin语言文件的静态代码解析

3.结合IDE实时提醒

4.方便自定义规则的编写

代码扫描工具探索

据我们了解,目前外面开源的对Kotlin进行静态代码分析的工具主要有以下:

1.Android Lint:

Android Lint是Google出品的,针对Android项目进行代码进行扫描的工具,功能非常强大,不仅仅能够对Kotlin语言的支持,还能对Java,静态资源文件,Xml文件等进行扫描,由于我们这个主要是针对Kotlin,而且想做成一个单独的IDE插件,前期也试过使用Android Lint,但是发现做成的IDE包很大,和里面Lint使用了很多IDE Core的包,不利于自己改造,所以放弃了这个方案

2.Detekt:

DeteKt是Github上一个很火的,针对Kotlin进行代码检查的插件,而且已经内嵌很多实用的规则,这边也尝试集成过,但是发现这个库的自定义规则比较麻烦和只支持控制台输出,不方便阅读修改,同时开发人员对错误修复之后再去扫描没有实时生效,最终也就放弃了对其改造

3.KLint

KLint更多是对Kotlin代码风格检查的工具,不是很适合我们这个需求定位,需要大量改造才能使用

基于以上现有的工具和插件的局限,为了以后的拓展,所以我们决定自主开发一款具有实时检查Kotlin代码规范并且实时提醒的IDE插件。

知识准备

要实现一个款结合IDE对Kotlin代码进行实时提醒的插件需要掌握下面的知识:

  • IDE插件开发基本流程
  • AST,PSI等知识
  • Kotlin代码语法树的解析

当然本文主要是讲如何对Kotlin文件进行代码分析,所以相关的IDE插件开发的基本流程在这里不多说了,大家可以网上学习一下IDE插件开发的流程(参考该教程[http://https://blog.csdn.net/qq_36838191/article/details/82978693]),下面主要讲一下 AST,PSI和Kotlin代码语法树的解析

1.AST

AST(Abstract Syntax Tree)即为”抽象语法树”,是编辑器对代码的第一步加工之后的结果,以一个树的形式表示源代码,例如下面的一段java代 码转成AST是这样的

image.png

如上图右边是Java代码解析成AST之后的树形结构,其实上面提到的静态代码扫描工具Android Lint 第一次引入的时候也是基于Lombok-Ast作为自 己的AST Parser,但是后来由于Lombok-Ast缓慢跟不上发展,所以在Lint 25.2.0版增加了IntelliJ的PSI作为新的AST Parser

2.PSI

为什么这里要介绍一下PSI呢,因为在后面插件内部很多地方都是通过PSI进行转换的,上面Kotlin代码解析提到是通过Kotlin-Compiler- Embeddable这个库把Kotlin文件解析成PSI的,那么PSI是什么呢?PSI(Program Structure Interface) 文件是表示一个文件的内容作为在一个特定的编程语言元素的层次结构的结构的根。在IDE上我们可以通过安装PsiViewer来看看Kotlin对应的Psi是什么样的。

(1) AndroidStudio上PsiViewer安装

image.png

(2) PsiViewer对Kotlin文件解析

image.png
从上图我们可以看到,左边1部分是原始的Kotlin文件,右上角2部分是Kotlin文件通过PsiViewer转换成PSI后的效果,从图中可以看出,转成的PSI的层次结构是一个很有规则的树状结构,这让我们想到来抽象语法树,而我们这个IDE kotlin静态代码实时提醒正是先把Kotlin文件专场PSI然后再通过遍历PSI专场自己定义好的语法树Tree的过程。

Kotlin 代码解析

我们都知道,Java的静态代码扫描原理是通过把Java文件生产抽象 语法树(AST),然后遍历语法树进行代码规则检查,同样的道理,Kotlin也有自己的抽象语法树,只可惜现在目前还没有一个单独解析kotlin成抽象语法树的库,本插件用的是Jetbrains提供的Kotlin-Compiler-Embeddable库,通过这个库先将Kotlin文件解释成PSI,然后再转换成自己的定义的树状结构(AST),然后去适配自己的规则。所以整个解析流程如下图:

image.png

Kotlin Lint插件开发和实现

1.插件整个工作流程

在具体介绍插件之前,先来了解一下整个插件的大致的工作流

image.png
插件安装后重启IDE,此时会加载本插件,插件进行启动,插件启动后会加载上图的ZhenaiLocalInspectionToolProvider,ZhenaiLocalInspectionToolProvider主要负责以下功能:

(1)加载启用的规则Rules

(2)注册对应规则的实例DelegateKotlinInspection给IDE,注册 成功之后你会在IDE面板上看到下图的界面的话代表已经成功注册:

image.png

DelegateKotlinInspection注册成功之后,那么当Kotlin文件触发之后IDE就会执行注册的DelegateKotlinInspection,

然后DelegateKotlinInspection调用ZhenaiKotlinInspection,

ZhenaiKotlinInspection再调用ZhenaiKotlinInspectionInvoker进行Kotlin文件检查,

ZhenaiKotlinInspectionInvoker会通过调用KotlinConverter将Kotlin文件转成对应的抽象语法树AST,然后调用ChecksVisitor进行树节点遍历去匹配相对于的规则,最后返回错误结果Problems

2.插件架构设计

image.png

如上图所示,为了以后扩展和维护,对整个插件进行以下分层:

(1) Plugin层

image.png
Plugin层是提供给开发人员进行代码扫描的入口,方便不同编辑器定制不同的操作界面和展示界面,上图是IDE的相关界面:1是代码扫描的操作界面,2是代码检测实时提醒界面,3是代码扫描后结果的展示界面,目前的话只开发了支持Android Studio的插件

(2) Client层

Client层是为Plugin提供相关Api的调用,里面包含了静态代码规则(ChecksVisitor)扫描的执行和错误结果(Problems)的获取,,并将结果给Plugin层展示

(3) Checks Rule Set 层

这里主要是规则相关的开发,规则集主要包含以下几大类:

  • 注释类规则集

例如:UndocumentedInterfaceOrAbstractFunction

完善的文档注释能够让使用者知道某个类或者某个方法的用户,尤其接口方法和抽象方法都是供外部继承或者调用的,那么接口方法或者抽象方法就应该添加注释文档,说明用途,方便外部调用了解

    不合规范的写法:

interface TestClass{  
    fun test()
}
复制代码

合规范的写法:

interface TestClass{   
    /**     
     * 测试方法     
     */  
    fun test()
}
复制代码
  • 复杂性规则集

例如:LongParameterList

方法参数过多不仅仅影响代码可读性,同时增加了该方法调用的难度,复杂度,所以对于参数过多的方法应该组装成一个对象,让外部调用

threshold(默认值:6)

不合规范的写法:

class TestImpl {    
    fun sayHello(age:String,name:String,weight:Int,height:Int,salary:Float,sex:Int{
    }
}
复制代码

合规范的写法:

class TestImpl {   
    fun sayHello(personal:Personal){    
    }
}
复制代码
  • 空块规则集

EmptyFunctionBlock

空代码块没有任何的具体实现,这样的方法是无用的,应该及时清理

不合规范的写法:

fun sayHello(){   
//is empty,remove 
}
复制代码
  • 性能规则集

companion object常量和变量的定义

伴生对象内部常量使用不当会伴随着很多get的方法诞生,编 译成java代码后调用一个常量需要调用很多步才能真正获取得到

不合规范的写法:

companion object { 
    var a = 1   
    val b = "2"    
    val c = 3f
}
复制代码

合规范的写法:

companion object {     
    @JvmField     
    var a = 1     
    @JvmStatic     
    val b = "2"     
    const val c = 3f 
}
复制代码
  • API调用规范集

Log日志打印

相信每个公司都有自己封装好的通用的库供应到不同的业务统一调用。而日志在平时开发中占着一个很重要的位置,Log日志打印规范能够很好帮助开发人员对问题排查显得更加容易方便,本公司也有自己的日志打印库,所以平时涉及相关日志打印不建议使用Android原生的。

不合规范的写法:

fun  doPay() {   
    Log.d(TAG,"doPay------------") 
}
复制代码

合规范的写法:

fun doPay() {    
    LogUtils.d(TAG,"doPlay--------------") 
}
复制代码

4. core 层

core层时整个插件的核心,里面包含了几个很重要的类,下面介绍一下:

(1) 接口Tree

前面有提到,此插件首先是通过kotlin-compiler-embeddable将kotlin文件解析成PSI然后再转成自定义好的语法树(tree),而Tree是所有树节点的父类,后续相关方法树(FunTree,CompanionObjectTree等)都是实现来Tree。

(2) KotlinConverter 类

KotlinConverter类核心功能是调用kotlin-compiler-embeddable包将kotlin语言文件解析成的Psi

/** 
 * 将kotlin 文件转成PSI 
 * @return 
 */
private static PsiFileFactory psiFileFactory() {    
    CoreFileTypeRegistry fileTypeRegistry = new CoreFileTypeRegistry();   
    fileTypeRegistry.registerFileType(KotlinFileType.INSTANCE, "kt");    
    FileTypeRegistry.ourInstanceGetter = new StaticGetter<>(fileTypeRegistry);    
    Disposable disposable = Disposer.newDisposable();     
    MockApplication application = new MockApplication(disposable);    
    FileDocumentManager fileDocMgr = new MockFileDocumentManagerImpl(DocumentImpl::new, null); 
    
    application.registerService(FileDocumentManager.class, fileDocMgr);    
    PsiBuilderFactoryImpl psiBuilderFactory = new PsiBuilderFactoryImpl();    
    application.registerService(PsiBuilderFactory.class, psiBuilderFactory);    
    application.registerService(ProgressManager.class, new CoreProgressManager());    ApplicationManager.setApplication(application, FileTypeRegistry.ourInstanceGetter, disposable);      
    Extensions.getArea(null).registerExtensionPoint(MetaLanguage.EP_NAME.getName(), MetaLanguage.class.getName(),     
    ExtensionPoint.Kind.INTERFACE);    
    Extensions.registerAreaClass("IDEA_PROJECT", null);     
    MockProject project = new MockProject(null, disposable);    project.registerService(ScriptDefinitionProvider.class, CliScriptDefinitionProvider.class);      LanguageParserDefinitions.INSTANCE.addExplicitExtension(KotlinLanguage.INSTANCE, new KotlinParserDefinition());    
    CoreASTFactory astFactory = new CoreASTFactory();    
    LanguageASTFactory.INSTANCE.addExplicitExtension(KotlinLanguage.INSTANCE, astFactory);   
    LanguageASTFactory.INSTANCE.addExplicitExtension(Language.ANY, astFactory);      PsiManager psiManager = new PsiManagerImpl(project, fileDocMgr, psiBuilderFactory, null, null, null);   
    return new PsiFileFactoryImpl(psiManager);
}
复制代码

(3) KotlinTreeVisitor 类
KotlinTreeVisitor是对转换后的PSI转成定义好AST对应的相关树节点

/** * 将PSI 转成 Tree 
  * 
  * @param element 
  * @param metaData 
  * @return 
  */
  private Tree convertElementToAST(PsiElement element, TreeMetaData metaData) { 
  int psiType = getElementType(element);    
  switch (psiType) {        
      case PsiElementType.TYPE_KTOPERATIONEXPRESSION:            
      return createOperationExpression(metaData, (KtOperationExpression) element); 
      case PsiElementType.TYPE_KTNAMEREFERENCEEXPRESSION:           
      return createIdentifierTree(metaData, element.getText());       
      case PsiElementType.TYPE_KTBLOCKEXPRESSION:            
          List<Tree> statementOrExpressions = list(((KtBlockExpression) element).getStatements().stream());           
          return new BlockTreeImpl(metaData, statementOrExpressions);      
      ......       
      ......        
      case PsiElementType.TYPE_KTRETURNEXPRESSION:            
          return createReturnTree(metaData, (KtReturnExpression) element);       
      case PsiElementType.TYPE_KTTHROWEXPRESSION:            
          return createThrowTree(metaData, (KtThrowExpression) element);        
      case PsiElementType.TYPE_KTOBJECTDECLARATION:            
          return createObjectDeclarationTree(metaData, (KtObjectDeclaration) element);      
      default:            
          return convertElementToNative(element, metaData);        
      }
   }
复制代码

5. 文件解析层

这里是使用Jetbrains提供的kotlin-compiler-embeddable包,将 Kotlin文件转成PSI文件结构

自定义编写KotlinLint规则

1. 为什么要自定义KotlinLint规则

本插件内置的Kotlin规则虽然已经涵盖了一些比较普遍的一些代码检查,但是在我们实际开发中可能还会存在以下问题:

(1)在不同的团队或者不同的公司肯定存在一些个性化需求,而本插件内置的一些大部分都是一些公共的问题,需要定制化的话就需要能够实现自定义规则的编写

(2)本插件开启的一些规则可能也不适合别的团队或者公司 基于以上原因,在项目中经常需要用到规则的自定义

2. 自定义KotlinLint规则的步骤

那么自定义KotlinLint规则需要哪些步骤呢,其实在做本插件的时候已经考虑到了后面的规则扩展和自定义,所以提供了一套方便外部实现的接口,总结一下大概流程(如下图)

1.编写规则相关信息

2.编写规则检查的实际逻辑

3.规则的配置与使用

image.png
下面来看看具体的代码实现

/** 
 * 方法参数过多规则 
 */
 class TooManyParametersCheck : ICheck {  
     /**   
      * 最大参数个数  
      */ 
     private val DEFAULT_MAX = 7 
     var max = DEFAULT_MAX //编写规则信息 
     private val sIssue = SIssue.SIssueBuilder()                     
                            .name("方法参数过多检查")//规则名称        
                            .issueId("TooManyParametersCheck")//规则ID     
                            .des("方法参数过多,超过" + DEFAULT_MAX + "个")
                            .build()//规则描述
     override fun initialize(init: InitContext) {        
         //注册监听的FunctionDeclarationTree树         
         init.register(FunctionDeclarationTree::class.java, BiConsumer { ctx, tree ->            
             if (!tree.isConstructor && !isOverrideMethod(tree) && tree.formalParameters().size > max) {               
                 //匹配到进行错误结果的上报              
                 if (tree.name() == null) {               
                     ctx.reportIssue(tree, sIssue)               
                 } else {              
                     ctx.reportIssue(tree.name()!!, sIssue)                 
                     }            
                 }       
             })      
         }
     override fun getSIssue(): SIssue {        
         return sIssue 
      }
      /**
       * 是否是重载方法 
       * 
       * @param tree 
       * @return 
       */
       private fun isOverrideMethod(tree:  FunctionDeclarationTree): Boolean {   
           return tree.modifiers().stream().anyMatch { mod ->          
               if (mod !is ModifierTree) {             
                   return@anyMatch false          
               }      
               mod.kind() == ModifierTree.Kind.OVERRIDE            
           }         
        }   
 }
复制代码

如上,添加一个规则先要继承ICheck类:

/** 
 * 规则的统一接口 
 */
 interface ICheck {
   /**    
    * 初始化    
    *    
    * @param init    
    */   
    void initialize(InitContext init);
       /**    
        * 获取错误规则描述    
        *    
        * @return    
        */   
        SIssue getSIssue();
   }
复制代码

实现initialize方法,然后通过init注册自己需要监听的Tree类型,上面的规则是方法参数过多的问题,所以注册的FunctionDeclarationTree方法树,然后获取方法参数的个数,匹配到就report错误

插件的安装和使用

1. 插件安装

  通过Jetbrains官方仓库安装

  打开Settings >> Plugins >> Marketplace

image.png
在搜索框输入Zhenai 便可以看到Zhenai Android Coding Guidelines插件了,然后点击安装重启后生效 注意:因为插件zip包托管在 Jetbrains官方CDN上,所以是从国外的服务器进行下载,可能会出现超时的情况。

通过下载安装包进行安装

image.png

image.png

2. 插件的使用

扫描单个文件

image.png
扫描整个项目

image.png
代码提交及时提醒

image.png
代码如上图,勾选before commit中的Zhenai Code Guidelines之后,提交的时候    如果发现提交的代码有不符合代码规范的会弹出提醒。

最终流程

image.png
添加IDE实时提醒插件之后,最终形成了上图的一套完整的静态代码扫描流程:

  • 开发人员通过安装在IDE上的实时提醒插件使得开发人员在编码阶段就能够轻松发现自己不符合规范的代码并且实时修改
  • Git扫描是在开发人员提交代码前进行一次扫描提醒,防止提交不符合规范的代码
  • Git Push代码到远程仓库之后,通过CI平台定时进行Sonar代码扫描,然后把结果进一步展示在Sonar Web平台,开发人员也可以通过Sonar平台查看哪些不规范的代码,直到最后代码规范后进行版本的发布

以上是Kotlin代码规范实时提醒IDE插件在珍爱网的实践,欢迎大家提出建议,一起沟通交流,最后代码地址github.com/dengqu/Kotl…

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享