作者:京东批发 刘世杰
导读
本文联合京东监控埋点场景,对解决样板代码的技术选型计划进行剖析,给出最终解决方案后,联合实践和实际进一步开展。通过关注文中的技术剖析过程和技术场景,读者可播种一种样板代码思维过程和解决思路,并对 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 {}