作者:京东零售 刘世杰
导读
本文结合京东监控埋点场景,对处理样板代码的技能选型计划进行剖析,给出终究处理计划后,结合理论和实践进一步打开。经过重视文中的技能剖析进程和技能场景,读者可收获一种样板代码思维进程和处理思路,并对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:
www.jcp.org/en/jsr/deta…
[2] Understanding AOP Proxies:
docs.spring.io/spring-fram…
[3] Compilation Overview:
openjdk.org/groups/comp…
[4] The Hacker’s Guide to Javac:
scg.unibe.ch/archive/pro…
[5] JavaParser-AST-Inspector:
github.com/MysterAitch…
[6] OpenJDK source:
hg.openjdk.java.net/jdk8u/jdk8u…
[7] Graphviz Online:
dreampuf.github.io/GraphvizOnl…