当前位置: 首页 > 科技观察

如何让Java编译器帮你写代码_0

时间:2023-03-21 12:36:49 科技观察

后台监控是服务端应用需要具备的一个非常重要的能力。通过监控,可以直观的看到核心业务指标、服务运行质量等,可监控就需要相应的监控和埋点。大家在埋点的过程中,往往会写很多重复的代码。虽然可以实现基本功能,但费时费力,不够优雅。根据“DRY(Don'tRepeaterYourself)”原则,这是代码中的“恶臭”。对于有代码洁癖的人来说,这种重复是不能接受的。那么有什么办法可以解决这种“重复”呢?经过综合研究,基于前端编译器插桩技术,实现了一个埋点组件,Java编译器可以通过编织埋点逻辑来帮助我们编写代码。经过不断的打磨,已经被包括京东APP主站服务器在内的多个团队广泛使用。本文主要是结合监控埋点的场景分享一种解决样板代码的方法,希望能起到抛砖引玉的作用。下面将从组件介绍、技术选型过程、实现原理和部分源码实现等方面逐步讲解。组件介绍京东的内部监控系统叫做UMP。和所有监控系统一样,核心部分包括埋点、报表、分析集成、告警、看板。更优雅简洁的实现。先来看传统的硬编码追踪方式,主要分为创建追踪对象、记录可用率、提交追踪点三个步骤:从上图可以看出,真正的逻辑只有红框内的范围。要完成埋点,必须把这段代码包围起来,代码层级变深,可读性差。所有的埋点都是这样的样板代码。再来看看使用组件后嵌入的方法:通过对比不难看出,使用组件后,只需要在方法上加上注解,代码的可读性有了明显的提升。该组件由2部分组成:埋点封装API和AST运算处理器。埋点API封装:运行时调用,对原生埋点进行封装抽象,方便用户扩展监控KEY。AST运算处理器:在编译时调用,它会根据注解@UMP,将埋点封装API按照规则编织到方法体中。(注:结合京东实际业务场景,该组件实现了回退、自定义可用率、重名方法区分、支持IDE插件、监控关键自定义生成规则等详细功能。由于本文主要讲解了底层实现原理,详细功能这里就不赘述了,有兴趣的京东同仁可以内网联系:liushijie3)技术选型的过程通过上面的示例代码,相信很多人都觉得这个功能很简单,使用SpringAOP可以快速完成。诚然很多团队都是这样做的,但是这个方案并不是那么完美。下面选型分析中会有相关说明,请耐心阅读。如下图所示,从软件开发周期来看,织入埋点主要分为三个阶段:编译期、后编译期和运行期。01编译期这里的编译期是指将Java源文件编译成类字节码的过程。Java编译器提供了基于JSR269规范[1]的注解处理器机制,通过操作AST(AbstractSyntaxTree,抽象语法树,下同)实现逻辑编织。业界有很多基于这种机制的应用,比如Lombok、MapStruct、JPA等;这种机制的好处是在编译时执行,问题可以被前端加载,没有多余的依赖,所以做出来的工具使用起来更方便。缺点也很明显。熟练操作AST并没有你想的那么简单。不了解相关流程而写出的代码不够稳定,需要花费大量时间去熟悉编译器的底层原理。当然,这个过程是用户感知不到的。02编译编译是指将字节码编译成类字节码后对其进行增强的过程。在这个阶段,posting需要适配不同的构建工具:Maven、Gradle、Ant、Ivy等,还需要用户自己添加额外的构建配置。因此,存在开发量大,使用不方便的问题。首先,应该排除这个选项。可能只有极少数场景需要在这个阶段进行存根。03运行期运行期是指程序启动后,在运行时增强程序的过程。这个阶段有3种编织逻辑的方式。按照启动顺序可以分为:静态代理、AOP和动态代理。1、静态AgentJVM启动时,使用-javaagent加载指定jar包,调用MANIFEST.MF文件中Premain-Class类的premain方法触发织入逻辑。是技术中间件最常用的方法,相关工作是借助字节码工具完成的。应用这种机制的中间件有很多,比如京东内部的链接监控pfinder,外部开源的skywalkingprobe,阿里的TTL等。这种方式的优点是整体比较成熟,缺点主要是兼容性问题.测试不同的JDK版本成本高昂,有问题只能上网找。同时,如果不是专业的中间件团队,还是有一定的技术壁垒,维护成本也比较高;2.SpringAOPSpringAOP大家都不陌生。通过Spring代理机制,可以在方法调用前后编织逻辑。AOP最大的优点就是使用方便,同时也有很多缺点:当方法A调用同一个类中的方法B时,是不可能走到切面的。这是Spring官方文档[2]的解释“但是,一旦调用最终到达目标对象(在本例中为SimplePojo引用),它可能对自身进行的任何方法调用,例如this.bar()或this.foo(),将针对this引用被调用,而不是代理“。这个问题会导致内部方法调用的逻辑无法执行。在监控埋点场景下,会出现数据丢失;AOP只能包围方法,方法体内部的逻辑没办法介入。仅仅通过捕获异常来判断逻辑是不够的。在某些场景下,需要通过返回值状态来判断逻辑是否正常。介绍中的示例代码就是这种情况,这是RPC调用解析中非常常见的操作。私有方法、静态方法、final类、方法等场景不能切。3、DynamicAgent动态加载jar包,调用MANIFEST.MF文件中声明的Agent-Class类的agentmain方法触发织入逻辑。该方法主要用于在线动态调试。使用这种机制的中间件也有很多,比如:Btrace、Arthas等,这种方式不适合常驻内存使用,应该排除。04最终方案选择从上面的分析可以看出,实现重复代码抽象的方式有3种:基于JSR269的插装、基于JavaAgent的字节码增强、基于SpringAOP的自定义切面。进一步对比:如上表所示,从实现成本来看,AOP是最简单的,但是这种方案不能覆盖所有场景,有一定的局限性,不符合我们对调性极致的追求,所以排除第一的。JavaAgent可以实现的效果和JSR269一样,但是需要在启动参数中加入-javaagent配置,运维工作量小,有JDK兼容坑需要解决被访问。对于非中间件团队来说,这种方式从长远来看,会带来负担,所以也应该排除。基于JSR269插桩方式,对Java编译器工作流程的理解和AST的运行会给实现带来复杂性,前期投入比较大,但一旦形成组件,带来的是一劳永逸-for-all方案,可以放心说白了,instrumentation实现的组件是监控埋点场景的灵丹妙药(事实证明了这一点,不然我也不敢吹牛)。冰山之上,这个组件为用户带来了简洁优雅的体验,一个jar包,一行代码,神采飞扬之笔。冰山之下是如何实现的?那么我们先从原理说起。Instrumentation的实现原理很简单。Instrumentation是在编译时通过在基于JSR269的注解处理器中操作AST来操作语法节点,最后编译成class文件。了解相关的底层原理,需要做好仪器仪表。大多数读者对编译器相关的内容比较陌生,所以这里我将用较大的篇幅做一个比较系统的介绍。Java编译器是将源代码翻译成类字节码的工具。Java编译器的实现有很多:OpenJDK的javac,Eclipse的ecj和ajc,IBM的jikes等,javac是公司主要的编译器。本文基于OpenJDK1.8进行讲解。作为工业级编译器,内部实现比较复杂,涵盖的内容足以写一本书。结合自己对javac源码的理解,尽量把stubbing涉及的知识通俗易懂的讲解一下。如有不尽之处,欢迎指正。建议有兴趣进一步研究的读者阅读javac源代码[6]。下面将解释编译器执行过程,相关javac源码导航,注解处理器是如何工作的。01编译器执行流程根据官网资料[3],javac的处理流程大致可以分为三个部分:ParseandEnter,AnnotationProcessing,AnalyzeandGenerate,如下图所示:Parse和EnterParse阶段是主要由词法分析器(Scanner)读取。源代码用于生成令牌流,然后语法分析器(JavacParser)使用这些令牌流来构建AST。Java代码可以通过AST来表达,读者可以通过JCTree查看相关实现。为了让读者更直观的理解AST,我将解析成AST的源码做了图形化展示:示例源码:tokenflow:[package]<-[com]<-[.]<-……<-[}]解析成AST如下:进入阶段主要是根据AST填充符号表,这里是instrumentation之后的过程,就不展开了。AnnotationProcessing注解处理阶段,调用基于JSR269规范的注解处理器,是javac的外部扩展。通过注解处理器,开发者(指非javac开发者,下同)可以自定义执行逻辑,这是插桩的关键。该阶段可以获取上一阶段生成的AST进行运算。AnalyzeandGenerate分析AST并生成类字节码。这里是插桩后的流程,不再展开。02相关javac源码导航javac触发入口类路径为:com.sun.tools.javac.Main,代码如下:经验证Maven执行build调用了该类的main方法。其他构建工具未验证,猜测类似。JDK内部也提供了javax.tools.ToolProvider#getSystemJavaCompiler这个入口。其实内部实现也是这个类中的compile方法。经过一系列的命令参数解析和初始化操作,最终转入真正的核心入口,方法为com.sun.tools.javac.main.JavaCompiler#compile,如下图所示:有3个关键调用这里:第852行:初始化注解处理器,通过Main入口的调用是通过JDKSPI收集的。855-858行:对应前面流程图中ParseandEnter和AnnotationProcessing的两阶段流程,方法processAnnotations是执行注解处理器的触发入口。第860行:对应AnalyzeandGenerate阶段的流程。03注解处理器Java从JDK1.6开始引入了基于JSR269规范的注解处理器,允许开发者在编译时执行自己的代码逻辑。像本文提到的UMP监控嵌入点插入组件,已经衍生出很多优秀的技术组件,比如上面提到的Lombok和Mapstruct。注释处理器使用起来相对简单。您也可以参考下面示例代码中注解处理器的简单实现。这里重点说一下注解处理器的整体执行原理:当编译开始时,会执行方法initProcessAnnotations(compile截图中的第852行),所有的注解处理器都会以SPI的形式被收集起来。SPI对应的接口:javax.annotation.processing。处理器。在方法processAnnotations中执行注解处理器,调用方法JavacProcessingEnvironment#doProcessing。所有注解处理器处理一次,称为一轮。在每一轮开始时,Processor#init方法将被执行一次,以允许开发者自定义初始化信息,例如缓存上下文。初始化完成后,javac会根据注解、版本等条件筛选出符合条件的注解处理器,并调用其接口方法Processor#process,这是开发者自定义的实现。在开发人员定义的注释处理器中实现AST操作的逻辑。一轮执行完成后,如果发现新的Java源文件或类文件,则开始新一轮。直到没有生成Java或类文件。一些开源项目在实现注解处理器的时候,为了保证可以继续执行,会通过这种机制创建一个空白的Java文件来达到目的。其实这也是理解原理的好处。如果一轮没有找到新的Java源文件和class文件,则执行最后一轮(lastRound)。最后一轮执行后,如果有新的Java源文件产生,就会进行ParseandEnter过程。至此,整个注解处理器流程结束。进入AnalyzeandGenerate阶段,最终生成类,完成整体编译。接下来,我们将展示如何通过UMP监控埋点功能在注解处理器中操作AST。源码示例关于AST运算的探索,早在2008年就有相关资料[4]。Lombok和Mapstruct都是开源工具,也可以参考学习。下面是一个简单的例子来说明如何插入桩。注解处理器使用框架上图展示了注解处理器具体的基本使用框架。init和process是注解处理器的核心方法。前者是初始化注解处理器的入口,后者是操作AST的入口。javac还提供了一些有用的工具类,例如:TreeMaker:一个创建AST的工厂类,所有节点都继承自JCTree,通过TreeMaker创建。JavacElements:操作Element的工具类,可以用来定位具体的AST。将一个导入节点编织成一个类下面是一个简单的场景,将一个导入节点编织成一个类:为了便于理解,代码实现已经简化,您可以使用注释查看如何编织:一般情况下,编织逻辑是CreateAST节点通过TreeMaker,操作已有的AST对创建的节点进行编织,从而达到编织代码的目的。在这里反思和总结,谈谈嵌入式组件的使用、技术选型、插桩相关的内容,最终开发出来的组件也对工作起到了很好的作用。但一路上有一些反思。从前面的内容不难得出一个事实。为了实现一个小小的功能,开发者需要花费大量的精力去学习和理解底层编译器的一些原理。从ROI来看,投入与产出严重不成比例。为了提供可靠的实现,我花了很多业余时间在技术选型分析和编译器相关知识上。可以说是纯粹靠个人兴趣和固执建立起来的。细节是魔鬼。入坑过程比较枯燥。事实上,插桩机制有很多常见的场景需要探索,所以这种机制的应用一直很少见。主要是它的门槛比较高,对于大多数开发者来说比较陌生。因此,降低开发者的使用门槛,可以让一些想法变成现实。做一把好锤子比钉钉子更有价值。监控埋点检测组件在实际实现的时候,在项目中做了一定的抽象,支持了一些开关、自定义链接跟踪等功能。但在作用范围上还不够,所以下一步要构建存根插入的技术框架,在易用性和可维护性上进一步抽象,同时做好可测试性相关工作工作,包括验证JDK各个版本的支持,各种Java语法的覆盖等。Instrumentation是一把双刃剑。javac官方对修改AST持保守态度,存在一些争议。然而,时间是最好的验证工具。从Lombok等组件的发展来看,插桩机制是经得起长期考验的。如何合理地使用这个能力非常重要。合理的使用可以让系统变得简洁优雅,使用不当就相当于给代码投毒。因此需要少修改AST,了解前后台运行机制,围绕常见场景使用,避免滥用。认清当前语境的局限性遇到问题,如果在当前语境下找不到合适的解决方案,跳出这个环境,换个维度,也许就能看到不一样的风景。就像物理机到虚拟机再到现在的容器一样,都打破了原有的规则,逐渐发展出一个新的技术生态。大部分的开发工作都是基于高层的封装,突破往往是从底层开始的。合适的时候,也可以向下做一些探索,可能会产出一些有价值的东西。