如何让Java编译器帮你写代码(2)

上一篇 / 下一篇  2022-08-02 09:38:02

  插桩实现原理
  简单来讲,插桩是在编译期基于 JSR 269的注解处理器中操作AST的方式操纵语法节点,最终编译到class文件中。要做好插桩理解相关的底层原理是必要的。大多数读者对编译器相关内容比较陌生,这里会用较大的篇幅做个相对系统的介绍。
  Java编译器是将源码翻译成 class 字节码的工具,Java编译器有多种实现:Open JDK的javac、Eclipse的ecj和ajc、IBM的jikes等,javac是公司内主要的编译器,本文是基于Open JDK 1.8 讲解。
  作为一款工业级编译器内部实现比较复杂,其涵盖的内容足够写一本书了。结合本人对javac源码的理解,尝试通俗易懂的讲清楚插桩涉及到的知识,有不尽之处欢迎指正。有兴趣进一步研究的读者建议阅读 javac源码[6]。下面将讲解编译器执行流程,相关javac源码导航,以及注解处理器如何运作。
  01编译器执行流程
  根据官网资料[3]javac 处理流程可以粗略的分为 3个部分:Parse and Enter、Annotation Processing、Analyse and Generate,如下图:
  Parse and EnterParse
  阶段主要通过词法分析器(Scanner)读取源码生产 token 流,被语法分析器(JavacParser)消费构造出AST,Java代码都可以通过AST表达出来,读者可以通过JCTree查看相关的实现。为了让读者能更直观的理解AST,本人做了一个源码解析成AST后的图形化展示:
  示例源码:
  token流:[ package ] <- [ com ] <- [ . ] <- …... <- [ } ]解析成AST后如下:
  Enter阶段主要是根据AST填充符号表,此处为插桩之后的流程,因此不再展开。
  Annotation Processing
  注解处理阶段,此处会调用基于 JSR269 规范的注解处理器,是javac对外的扩展。通过注解处理器让开发者(指非javac开发者,下同)具备自定义执行逻辑的能力,这就是插桩的关键。在这个阶段,可以获取到前一阶段生成的AST,从而进行操作。
  Analyse and Generate
  分析AST并生成class字节码,此处为插桩之后的流程,不再展开。
  02相关javac源码导航
  javac触发入口类路径是:com.sun.tools.javac.Main,代码如下:
  经验证Maven 执行构建调的是此类中的main方法。其他构建工具未做验证,猜测类似的。在JDK内部也提供了javax.tools.ToolProvider#getSystemJavaCompiler的入口,实际上内部实现也是调的这个类里的compile方法。
  经过一系列的命令参数解析和初始化操作,最终调到真正的核心入口,方法是com.sun.tools.javac.main.JavaCompiler#compile,如下图:
  这里有3个关键调用:
  852行:初始化注解处理器,通过Main入口的调用是通过JDK SPI的方式收集。
  855 – 858行:对应前面流程图里的Parse and Enter和Annotation Processing两个阶段的流程,其中方法processAnnotations便是执行注解处理器的触发入口。
  860行:对应Analyse and Generate阶段的流程。
  03注解处理器
  Java从JDK 1.6 开始,引入了基于JSR 269 规范的注解处理器,允许开发者在编译期间执行自己的代码逻辑。如本文讲的UMP监控埋点插桩组件一样,由此衍生出了很多优秀的技术组件,如前面提到的Lombok、Mapstruct等。注解处理器使用比较简单,后面示例代码有注解处理器简单实现也可以参考。这里重点讲一**解处理器整体执行原理:
  编译开始的时候,会执行方法initProcessAnnotations (compile的截图852行),以SPI的方式收集到所有的注解处理器,SPI对应接口:javax.annotation.processing.Processor。
  在方法processAnnotations中执行注解处理器调用方法JavacProcessingEnvironment#doProcessing。
  所有的注解处理器处理完毕一次,称为一轮(round),每轮开始会执行一次Processor#init方法以便开发者自定义初始化信息,如缓存上下文等。初始化完成后,javac会根据注解、版本等条件过滤出符合条件的注解处理器,并调用其接口方法Processor#process,即开发者自定义的实现。
  在开发者自定义的注解处理器里,实现AST操作的逻辑。
  一轮执行完成后,发现新的Java源文件或者class文件,则开启新的一轮。直到不再产生Java或者class文件为止。有的开源项目实现注解处理器时,为了保证自身可以继续执行,会通过这个机制创建一个空白的Java文件达到目的,其实这也是理解原理的好处。
  如果在一轮中未发现新的Java源文件和class文件产生则执行最后一轮(lastRound)。最后一轮执行完毕后,如果有新的Java源文件生成,则进行Parse and Enter 流程处理。到这里,整个注解处理器的流程就结束了。
  进入Analyse and Generate阶段,最终生成class,完成整体编译。
  接下来将通过UMP监控埋点功能来展示怎么在注解处理器中操作AST。
  源码示例
  关于AST 操作的探索,早在2008年就有相关资料了[4],Lombok、Mapstruct都是开源的工具,也可以用来参考学习。这里简单讲一个示例,展示如何插桩。
  注解处理器使用框架
  上图展示了注解处理器具体的基本使用框架,init、process是注解处理器的核心方法,前者是初始化注解处理器的入口,后者是操作AST的入口。javac还提供了一些有用的工具类,比如:
  ·TreeMaker:创建AST的工厂类,所有的节点都是继承自JCTree,并通过TreeMaker完成创建。
  · JavacElements:操作Element的工具类,可以用来定位具体AST。
  向类中织入一个import节点
  这里举一个简单场景,向类中织入一个import节点:
  为方便理解对代码实现做了简化,可以配合注释查看如何织入:
  总的来说,织入逻辑是通过TreeMaker创建AST 节点,并操作现有AST织入创建的节点,从而达到了织入代码的目的。
  反思与总结
  到这里,讲了埋点组件的使用、技术选型、以及插桩相关的内容,最终开发出来的组件在工作中也起到了很好的效果。但是在这个过程中有一些反思。
  插桩门槛高
  通过前面的内容不难得出一个事实,要实现一个小小的功能,需要开发者花费大量的精力去学习理解编译器底层的一些原理。从ROI角度看,投入和产出是严重不成正比的。为了能提供可靠的实现,个人花费了大量业余时间去做技术选型分析和编译器相关知识,可以说是纯靠个人的兴趣和一股倔劲一点点搭建起来的,细节是魔鬼,这个踩坑的过程比较枯燥。实际上插桩机制有很多通用的场景可以探索,之所以一直很少见到此类机制的应用。主要是其门槛较高,对大多数开发者来说比较陌生。因此降低开发者使用门槛才能让一些想法变成现实。做一把好用的锤子,比砸入一个钉子要更有价值。在监控埋点插桩组件真正落地时,在项目内做了一定抽象,并支持了一些开关、自定义链路跟踪等功能。但从作用范围来讲是不够的,所以下一步计划做一个插桩方面的技术框架,从易用性、可维护性等方面做好进一步的抽象,同时做好可测试性相关工作,包含验证各版本JDK的支持、各种Java语法的覆盖等。
  插桩是把双刃剑
  javac官方对修改AST的方式持保守态度,也存在一些争议。然而时间是最好的验证工具,从Lombok 等组件的发展看出,插桩机制是能经住长久考验的。如何合理利用这种能力是非常重要的,合理使用可使系统简洁优雅,使用不当就等于在代码里下毒了。所以要有节制的修改AST,要懂前后运行机制,围绕通用的场景使用,避免滥用。
  认识当前上下文环境的局限性
  遇到问题时,如果在当前的上下文环境里找不到合适的解决方案,从这个环境跳出来换个维度也许能看到不同的风景。就像物理机到虚拟机再到现在的容器,都是打破了原来的规则逐步发展出新的技术生态。大多数的开发工作都是基于一个高层次的封装上面进行,而突破往往都是从底层开始的,适当的时候也可以向下做一些探索,可能会产生一些有价值的东西。

TAG: 软件开发 Java

 

评分:0

我来说两句

Open Toolbar