天天看点

从lombok到UAST – 浅谈Android Lint的AST Parser(1)

自从ADT 16第一次引入Android Lint(以下简称:Lint)以来,Lint便成为Android平台上最重要的静态代码扫描工具。与早期基于XPath的静态扫描工具不同,Lint基于AST(Abstract Syntax Tree)进行分析,可以用来定制很复杂的扫描规则。

关于Lint的介绍,网上已有很多文章可参考。关于Lint规则的定制,也是个复杂的话题,这里不展开。本文着眼点在于Lint使用的AST Parser,这是整个静态扫描最核心的部分。

Lint从第一个版本就选择了lombok-ast作为自己的AST Parser,并且用了很久。但是Java语言本身在不断更新,Android也在不断迭代出新,lombok-ast慢慢跟不上发展,所以Lint在25.2.0版增加了IntelliJ的PSI(Program Structure Interface)作为新的AST Parser。但是PSI于IntelliJ、于Lint也只是个过渡性方案,事实上IntelliJ早已开始了新一代AST Parser,UAST(Unified AST)的开发,而Lint也将于即将发布的25.4.0版中将PSI更新为UAST。

因为Lint发布时间较久,已有很多研发人员基于lombok-ast开发了大量定制规则,出于兼容目的,Lint一直到现在都还保留着lombok-ast,也就是说,从25.2.0之后,一直是新旧两套AST Parser并存。我们现在就来看一看这个变迁的过程。

1、lombok-ast

说起Project lombok,这也是Java世界里一个很经典的项目了。通过一组强大的annotation,它可以在编译时修改编译器前端生成的AST,最终生成符合JVM规范的字节码。可以简单地理解为,lombok的annotation,实际上创造了一个语法简化版的Java语言,帮助提升开发效率,lombok会在编译阶段将这种简化的语言翻译成真正的Java,保证其兼容性。

lombok从一开始就同时支持两大主流Java编译器,javac和ECJ(Eclipse Compiler for Java)。这两个编译器虽然都可以生成符合JVM规范的字节码,但它们的实现是完全不同的。这使得lombok团队不得不建立了一个子工程lombok-ast,专门用于封装特定编译器的AST实现细节。也正是由于它的编译器无关特性,Lint选择了这个模块。

lombok-ast虽然功能很强,但是到今天它越来越不够用了,具体地说,有这么几个缺点:

1.1、对Java语言只支持到Java 6

lombok-ast的更新维护太慢了,它对Java语言的支持只能完美支持到Java 6 …… 对于Java 7、Java 8的新特性,根本不支持 …… 可是我们都看到,Android从4.4就开始支持Java 7了,从6.0开始支持Java 8,如果你装了Android Studio >= 2.1和SDK >= 24,开发目标版本较低的应用一样可以用某些Java 8特性。眼看Java 9就要发布了,lombok-ast还只支持到Java 6 ……

例如Java 8里一个大家喜闻乐见的新特性,lambda表达式

public void demoThreadUseLambda() {
    new Thread(() -> {
        System.out.println("Thread running ......");
    }).start();
}
           

lombok-ast是不支持的,如果你试图自己写个Lint规则,检测其中的内联类,语法上是根本做不到的。

于是,有人自己扩充了lombok-ast的语法支持,让其能支持到Java 8,这其中做的比较好的例如 android-retrolambda-lombok 。但这样也有问题,Lint官方版不认啊 …… 你只能自己本地改代码。

但是这个还算是可以自己修改的,更不能忍的是下面这点。

1.2、不支持类型解析

现代编译器的特性里,Type Resolver / Declaration Resolver已经是标配,毕竟高级语言普遍支持多态,判断一个接口或抽象类型的实例到底是什么类型是很必要的。

但是lombok-ast却不支持这点 …… 当然说不支持并不完全准确,它里面其实是有一个

lombok.ast.resolve.Resolver

的,但是其能力基本可以让人放弃。只能说,lombok这个项目,从它最初的目的来看,它还真不需要多么高级的类型解析。因为它本来的用途就不是代码静态分析,光是修改AST这点,还真的只需要简单的符号分析就够了。

于是我们在Lint里就看到了Google做的一个有点奇葩的设计,ResolvedNode机制。

package com.android.tools.lint.client.api;

public abstract class JavaParser {
    public abstract ResolvedNode resolve(@NonNull JavaContext context, @NonNull Node node);

    /** A resolved declaration from an AST Node reference */
    public abstract static class ResolvedNode {
        ......
    }

    /** A resolved class declaration (class, interface, enumeration or annotation) */
    public abstract static class ResolvedClass extends ResolvedNode {
        ......
    }

    /** A method or constructor declaration */
    public abstract static class ResolvedMethod extends ResolvedNode {
        ......
    }

    /** A field declaration */
    public abstract static class ResolvedField extends ResolvedNode {
        ......
    }

    /**
     * An annotation <b>reference</b>. Note that this refers to a usage of an annotation,
     * not a declaraton of an annotation. You can call {@link #getClassType()} to
     * find the declaration for the annotation.
     */
    public abstract static class ResolvedAnnotation extends ResolvedNode {
        ......
    }

    /** A package declaration */
    public abstract static class ResolvedPackage extends ResolvedNode {
        ......
    }

    /** A local variable or parameter declaration */
    public abstract static class ResolvedVariable extends ResolvedNode {
        ......
    }
}
           

ResolvedNode机制是对lombok-ast的一个扩展,对于一个lombok-ast封装的AST节点

lombok.ast.Node

,Lint允许你通过

com.android.tools.lint.client.api.JavaParser#resolve()

将其解析成一个

com.android.tools.lint.client.api.JavaParser.ResolvedNode

节点,ResolvedNode就包含了AST节点的类型解析信息。

所以当我们基于lombok-ast定制规则时,只要规则稍复杂点,或者我们需要更精确的实现,我们就不得不面临一个尴尬的局面,就是我们必须在lombok-ast提供的AST节点树和Lint提供的ResolvedNode类型解析树之间来回切换,这两个树的结构是一一对应的,但是却是分离的 …… 想必Google自己也很受不了这点,所以后面才要换成PSI / UAST。

这里多说一句,可能有人感到好奇,Lint又是如何实现类型解析的呢?原因是Lint使用了ECJ作为lombok-ast的编译器实现,而ECJ在编译阶段其实已经为每个AST节点生成了类型解析信息并附加在节点上,也就是ECJ牛逼的binding机制。

package org.eclipse.jdt.internal.compiler;

public class Compiler implements ITypeRequestor, ProblemSeverities {
    protected void internalBeginToCompile(ICompilationUnit[] sourceUnits, int maxUnits) {
        ......
        try {
                ......
                // diet parsing for large collection of units
                CompilationUnitDeclaration parsedUnit;
                ......
                if (this.totalUnits < this.parseThreshold) {
                    parsedUnit = this.parser.parse(sourceUnits[i], unitResult);
                } else {
                    parsedUnit = this.parser.dietParse(sourceUnits[i], unitResult);
                }
                ......
                // initial type binding creation
                this.lookupEnvironment.buildTypeBindings(parsedUnit, null /*no access restriction*/);
                ......
                addCompilationUnit(sourceUnits[i], parsedUnit);
                ......

        // binding resolution
        this.lookupEnvironment.completeTypeBindings();
    }
}
           

在Lint里,前述JavaParser的实现类是EcjParser,在其中读取lombok-ast提供的ECJ AST节点(也即lombok-ast所谓的Native Node),获取其binding信息,完成了最终的类型解析。

package com.android.tools.lint;

public class EcjParser extends JavaParser {
    public ResolvedNode resolve(@NonNull JavaContext context, @NonNull Node node) {
        Object nativeNode = getNativeNode(node);
        if (nativeNode == null) {
            return null;
        }

        if (nativeNode instanceof NameReference) {
            return resolve(((NameReference) nativeNode).binding);
            ......
    }

    private ResolvedNode resolve(@Nullable Binding binding) {
        if (binding == null || binding instanceof ProblemBinding) {
            return null;
        }

        if (binding instanceof TypeBinding) {
            TypeBinding tb = (TypeBinding) binding;
            return new EcjResolvedClass(tb);
            ......
    }
}