作者:京东批发 刘世杰

导读

本文联合京东监控埋点场景,对解决样板代码的技术选型计划进行剖析,给出最终解决方案后,联合实践和实际进一步开展。通过关注文中的技术剖析过程和技术场景,读者可播种一种样板代码思维过程和解决思路,并对Java编译器底层有初步理解。

一、背景

监控是服务端利用须要具备的一个十分重要的能力,通过监控能够直观的看到外围业务指标、服务运行品质等,而要做到可监控就须要进行相应的监控埋点。大家在埋点过程中常常会编写大量反复代码,虽能实现基本功能,但耗时耗力,不够优雅。依据“DRY(Don't Repeat Yourself)"准则,这是代码中的“坏滋味”,对有代码洁癖的人来讲,这种反复是不可承受的。

那有什么办法解决这种“反复”吗?通过综合调研,基于前端编译器插桩技术,实现了一个埋点组件,通过织入埋点逻辑,让Java 编译器帮咱们写代码。通过一直打磨,曾经被包含京东APP主站服务端在内的很多团队宽泛应用。

本文次要是联合监控埋点这个场景分享一种解决样板化代码的思路,心愿能起到抛砖引玉的作用。上面将从组件介绍技术选型过程实现原理局部源码实现逐渐开展解说。

二、组件介绍

京东外部监控零碎叫UMP,与所有的监控零碎一样,外围局部有埋点、上报、剖析整合、报警、看板等等,本文讲的组件次要是为对监控埋点原生能力的加强,提供一种更优雅简洁的实现。

上面先来看下传统硬编码的埋点形式,次要分为创立埋点对象、可用率记录、提交埋点 3 个步骤:

通过上图能够看到,真正的逻辑只有红框中的范畴,为了实现埋点要把这段代码都围绕起来,代码层级变深,可读性差,所有埋点都是这样的样板代码。

上面来看下应用组件后的埋点形式:

通过比照很容易看到,应用组件后的形式只有在办法上加一个注解就能够了,代码可读性有显著的晋升。

组件由埋点封装API和AST操作处理器 2 局部组成。

埋点API封装:在运行时被调用,对原生埋点做了封装和形象,不便使用者进行监控KEY的扩大。

AST操作处理器:在编译期调用,它将依据注解@UMP把埋点封装API依照规定织入办法体内。

(注:联合京东理论业务场景,组件实现了fallback、自定义可用率、重名办法辨别、配套的IDE插件、监控key自定义生成规定等细节性能,因为本文次要是解说底层实现原理,具体性能不在此赘述)

三、技术选型过程

通过下面的示例代码,置信很多人感觉这个性能很简略,用 Spring AOP 很快就能搞定了。确实很多团队也是这么做的,不过这个计划并不是那么完满,上面的选型剖析中会有相干的解释,请急躁往下看。如下图,从软件的开发周期来看,可织入埋点的机会次要有 3 个阶段:编译期、编译后和运行期。

3.1 编译前

这里的编译期指将Java源文件编译为class字节码的过程。Java编译器提供了基于 JSR 269 标准[1]的注解处理器机制,通过操作AST (形象语法树,Abstract Syntax Tree,下同)实现逻辑的织入。业内有不少基于此机制的利用,比方Lombok 、MapStruct 、JPA 等;此机制的长处是因为在编译期执行,能够将问题前置,没有多余依赖,因而做进去的工具应用起来比拟不便。毛病也很显著,要纯熟操作 AST并不是想的那么简略,不了解前后关联的流程写进去的代码不够稳固,因而要花大量工夫相熟编译器底层原理。当然这个过程对使用者来讲是没有感知的。

3.2 编译后

编译后是指编译成 class 字节码之后,通过字节码进行加强的过程。此阶段插桩须要适配不同的构建工具:Maven、Gradle、Ant、Ivy等,也须要应用方减少额定的构建配置,因而存在开发量大和应用不够不便的问题,首先要排除掉此选项。可能只有极少数场景下才会须要在此阶段插桩。

3.3 运行期

运行期是指在程序启动后,在运行时进行加强的过程,这个阶段有 3 种形式能够织入逻辑,依照启动程序,能够分为:动态 Agent、AOP 和动静 Agent。

3.3-1 动态 Agent

JVM 启动时应用 -javaagent 载入指定 jar 包,调用 MANIFEST.MF 文件里的 Premain-Class 类的 premain 办法触发织入逻辑。是技术中间件最常应用的形式,借助字节码工具实现相干工作。利用此机制的中间件有很多,比方:京东外部的链路监控 pfinder、内部开源的 skywalking 的探针、阿里的 TTL 等等。这种形式长处是整体比拟成熟,毛病次要是兼容性问题,要测试不同的 JDK 版本代价较大,呈现问题只能在线上发现。同时如果不是业余的中间件团队,还是存在肯定的技术门槛,保护老本比拟高;

3.3-2 Spring AOP

Spring AOP大家都不生疏,通过 Spring 代理机制,能够在办法调用前后织入逻辑。AOP 最大的长处是应用简略,同样存在不少毛病:

1) 同一类内办法A调用办法B时,是无奈走到切面的,这是Spring 官网文档的解释[2] “However, once the call has finally reached the target object (the SimplePojo reference in this case), any method calls that it may make on itself, such as this.bar() or this.foo(), are going to be invoked against the this reference, and not the proxy”。这个问题会导致外部办法调用的逻辑执行不到。在监控埋点这个场景下就会呈现丢数据的状况;

2) AOP只能盘绕办法,办法体外部的逻辑没有方法干涉。靠捕获异样判断逻辑是不够的,有些场景须要是通过返回值状态来判断逻辑是否失常,应用介绍外面的示例代码就是此种状况,这在 RPC 调用解析里是很平时的操作。

3) 公有办法、静态方法、final class和办法等场景无奈走切面

3.3-3 动静 Agent

动静加载jar包,调用MANIFEST.MF文件中申明的Agent-Class类的agentmain办法触发织入逻辑。这种形式次要用来线上动静调试,应用此机制的中间件也有很多,比方:Btrace、Arthas等,此形式不适宜常驻内存应用,因而要排除掉。

3.4 最终计划抉择

通过下面的剖析梳理可知,要实现反复代码的形象有 3 种形式:基于JSR 269 的插桩、基于 Java Agent 的字节码加强、基于Spring AOP的自定义切面。接下来进一步的比照:

如上表所示,从实现老本上来看,AOP 最简略,但这个计划不能笼罩所有场景,存在肯定的局限性,不合乎咱们谋求极致的调性,因而首先排除。Java Agent 能达到的成果与 JSR 269 雷同,然而启动参数里须要减少 -javaagent 配置,有大量的运维工作,同时还有 JDK 兼容性的坑须要趟,对非中间件团队来说,这种形式从短暂看会带来累赘,因而也要排除。基于 JSR 269 的插桩形式,对Java编译器工作流程的了解和 AST 的操作会带来实现上的复杂性,后期投入比拟大,然而组件一旦成型,会带来一劳永逸的解决方案,能够很自信的讲,插桩实现的组件是监控埋点场景里的银弹(事实证明了这点,不然也不敢这么吹)。

冰山之上,此组件给使用者带来了简洁优雅的体验,一个jar包,一行代码,妙笔生花。那冰山之下是如何实现的呢?那就要从原理说起了。

四、插桩实现原理

简略来讲,插桩是在编译期基于 JSR 269的注解处理器中操作AST的形式操纵语法节点,最终编译到class文件中。要做好插桩了解相干的底层原理是必要的。大多数读者对编译器相干内容比拟生疏,这里会用较大的篇幅做个绝对零碎的介绍。

Java编译器是将源码翻译成 class 字节码的工具,Java编译器有多种实现:Open JDK的javac、Eclipse的ecj和ajc、IBM的jikes等,javac是公司内次要的编译器,本文是基于Open JDK 1.8 解说。

作为一款工业级编译器外部实现比较复杂,其涵盖的内容足够写一本书了。联合自己对javac源码的了解,尝试通俗易懂的讲清楚插桩波及到的常识,有不尽之处欢送斧正。有趣味进一步钻研的读者倡议浏览 javac源码[6]。

上面将解说编译器执行流程,相干javac源码导航,以及注解处理器如何运作。

4.1 编译器执行流程

依据官网材料[3]javac 解决流程能够粗略的分为 3个局部:Parse and Enter、Annotation Processing、Analyse and Generate,如下图:

Parse and Enter

Parse阶段次要通过词法分析器(Scanner)读取源码生产 token 流,被语法分析器(JavacParser)生产结构出AST,Java代码都能够通过AST表达出来,读者能够通过JCTree查看相干的实现。为了让读者能更直观的了解AST,自己做了一个源码解析成AST后的图形化展现:

(注:AST图形生成通过IDEA插件JavaParser-AST-Inspector生成dot格局文本,并应用线上工具GraphvizOnline转换为图片,见参考资料5、7)

示例源码:

token流:

[ package ] <- [ com ] <- [ . ] <- …... <- [ } ]

解析成AST后如下:

Enter阶段次要是依据AST填充符号表,此处为插桩之后的流程,因而不再开展。

Annotation Processing

注解解决阶段,此处会调用基于 JSR269 标准的注解处理器,是javac对外的扩大。通过注解处理器让开发者(指非javac开发者,下同)具备自定义执行逻辑的能力,这就是插桩的要害。在这个阶段,能够获取到前一阶段生成的AST,从而进行操作。

Analyse and Generate

剖析AST并生成class字节码,此处为插桩之后的流程,不再开展。

4.2 相干javac源码导航

javac触发入口类门路是:com. sun. tools. javac. Main,代码如下:

教训证Maven 执行构建调的是此类中的main办法。其余构建工具未做验证,猜想相似的。在JDK外部也提供了javax. tools. Tool Provider# get System Java Compiler的入口,实际上外部实现也是调的这个类里的compile办法。

通过一系列的命令参数解析和初始化操作,最终调到真正的外围入口,办法是com. sun. tools. javac. main. Java Compiler# compile,如下图:

这里有3个要害调用:

852行:初始化注解处理器,通过Main入口的调用是通过JDK SPI的形式收集。

855–858行:对应后面流程图里的Parse and Enter和Annotation Processing两个阶段的流程,其中办法processAnnotations便是执行注解处理器的触发入口。

860行:对应Analyse and Generate阶段的流程。

4.3 注解处理器

Java从JDK 1.6 开始,引入了基于JSR 269 标准的注解处理器,容许开发者在编译期间执行本人的代码逻辑。如本文讲的UMP监控埋点插桩组件一样,由此衍生出了很多优良的技术组件,如后面提到的Lombok、Mapstruct等。注解处理器应用比较简单,前面示例代码有注解处理器简略实现也能够参考。这里重点讲一下注解处理器整体执行原理:

1、编译开始的时候,会执行办法init Process Annotations (compile的截图852行),以SPI的形式收集到所有的注解处理器,SPI对应接口:javax. annotation. processing. Processor。

2、在办法process Annotations中执行注解处理器调用办法Javac Processing Environment# do Processing。

3、所有的注解处理器处理完毕一次,称为一轮(round),每轮开始会执行一次Processor# init办法以便开发者自定义初始化信息,如缓存上下文等。初始化实现后,javac会依据注解、版本等条件过滤出符合条件的注解处理器,并调用其接口办法Processor# process,即开发者自定义的实现。

4、在开发者自定义的注解处理器里,实现AST操作的逻辑。

5、一轮执行实现后,发现新的Java源文件或者class文件,则开启新的一轮。直到不再产生Java或者class文件为止。有的开源我的项目实现注解处理器时,为了保障本身能够继续执行,会通过这个机制创立一个空白的Java文件达到目标,其实这也是了解原理的益处。

6、如果在一轮中未发现新的Java源文件和class文件产生则执行最初一轮(last Round)。最初一轮执行结束后,如果有新的Java源文件生成,则进行Parse and Enter 流程解决。到这里,整个注解处理器的流程就完结了。

7、进入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织入创立的节点,从而达到了织入代码的目标。

六、反思与总结

到这里,讲了埋点组件的应用、技术选型、以及插桩相干的内容,最终开发进去的组件在工作中也起到了很好的成果。然而在这个过程中有一些反思。

1、插桩门槛高

通过后面的内容不难得出一个事实,要实现一个小小的性能,须要开发者破费大量的精力去学习了解编译器底层的一些原理。从ROI角度看,投入和产出是重大不成正比的。为了能提供牢靠的实现,集体破费了大量业余时间去做技术选型剖析和编译器相干常识,能够说是纯靠集体的趣味和一股倔劲一点点搭建起来的,细节是魔鬼,这个踩坑的过程比拟干燥。实际上插桩机制有很多通用的场景能够摸索,之所以始终很少见到此类机制的利用。次要是其门槛较高,对大多数开发者来说比拟生疏。因而升高开发者应用门槛能力让一些想法变成事实。做一把好用的锤子,比砸入一个钉子要更有价值。

在监控埋点插桩组件真正落地时,在我的项目内做了肯定形象,并反对了一些开关、自定义链路跟踪等性能。但从作用范畴来讲是不够的,所以下一步打算做一个插桩方面的技术框架,从易用性、可维护性等方面做好进一步的形象,同时做好可测试性相干工作,蕴含验证各版本JDK的反对、各种Java语法的笼罩等。

2、插桩是把双刃剑

javac官网对批改AST的形式持激进态度,也存在一些争议。然而工夫是最好的验证工具,从Lombok 等组件的倒退看出,插桩机制是能经住短暂考验的。如何正当利用这种能力是十分重要的,正当应用可使零碎简洁优雅,使用不当就等于在代码里下毒了。所以要有节制的批改AST,要懂前后运行机制,围绕通用的场景应用,防止滥用。

3、意识以后上下文环境的局限性

遇到问题时,如果在以后的上下文环境里找不到适合的解决方案,从这个环境跳进去换个维度兴许能看到不同的风光。就像物理机到虚拟机再到当初的容器,都是突破了原来的规定逐渐倒退出新的技术生态。大多数的开发工作都是基于一个高层次的封装下面进行,而冲破往往都是从底层开始的,适当的时候也能够向下做一些摸索,可能会产生一些有价值的货色。

参考文献

[1] JSR 269:

https://www.jcp.org/en/jsr/detail?id=269

[2] Understanding AOP Proxies:

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-understanding-aop-proxies

[3] Compilation Overview:

https://openjdk.org/groups/compiler/doc/compilation-overview/index.html

[4] The Hacker’s Guide to Javac:

http://scg.unibe.ch/archive/projects/Erni08b.pdf

[5] JavaParser-AST-Inspector:

https://github.com/MysterAitch/JavaParser-AST-Inspector

[6] OpenJDK source:

http://hg.openjdk.java.net/jdk8u/jdk8u60/langtools/

[7] Graphviz Online:

https://dreampuf.github.io/GraphvizOnline/#digraph G {}