JDebugger之我花了一个月把debug搬到了线上-AST编译与Debug Online的实现

115 次浏览
2023年05月16日创建

本篇幅内容很长,但它应该被记录——既能自身总结,也能传递知识。目前在之前的老版本实现的能力上重构了90%的代码:

目前做到了代码文件路径、搜索、展示所有的断点(mute所有断点、clear all 所有断点 )、展示变量、console日志、以及debug的一些基础能力step over、step into、step out、resume、stop、flush。

必要性的说明

阿里ZDebugger,谷歌Cloud Debugger都实现了web端的断点调试,当然他们实现的能力非常复杂——但一个轻量级的web断点调试能力对于我们京东Java技术栈的开发者来说是非常有必要的——因为一个复杂的项目,往往依赖的环境和配置非常复杂,当出现需要调试或者排查线上问题时,以传统的日志方式诚然也能解决问题,但这比较繁琐,尤其是日志的打印规范非常考验程序员的经验和素质。当一个项目经过多次交手后,在对老业务进行问题诊断时,真的称得上是两眼一抹黑,无奈只能加日志、打包、上线、请求、查看日志、找出问题,浪费生命。而传统IDE集成的debug能力需要额外开通端口,为了解决这些问题,这正是jdebugger出现的主要目的——无需开通端口,部署后,直接路径访问即可debug,做到足够轻量。同时呢,也是帮助研发从需求的生命周期中从开发--->自测-->联调-->测试-->上线-->上线后排查问题的整个过程提效。

想要实现Debug Online之前,我们需要知道典型的IDE(例如IDEA、Eclipse、Netbeans)是如何实现debug能力的,以及介绍一下阿里实现的ZDebugger和谷歌实现的Cloud Debugger内容。

ZDebugger

  • 部署一套到线上,就可以解决因为环境问题而导致的不可能,线上应用也可以调起来
  • 我们提供late attach的机制,能在目标jvm中将JDWP(Java Debug Wire Protocol) agent在运行期动态加载起来,而不需要在应用启动的时候就加上jdwp agent,目前我们正在进行测试
  • 提供了watch point的调试模式,对于同一个应用大家都可以上去设置断点,然后经过断点的时候Zdebugger会将一些变量信息,线程信息推给你,断点是分用户的噢
  • 一个断点只断住一个线程就可以解决断点在通用位置导致线程都阻塞而手忙脚乱的问题了
  • eclipse不在身边,有浏览器就行,Zdebugger设计成了一个web系统,无需安装,想什么时候用就什么时候用

设计架构(真复杂啊):

https://docs.huihoo.com/javaone/2015/BOF3204-Build-a-Java-Debugger-for-the-Cloud-Practices-in-the-Alibaba-Financial-Cloud.pdf

谷歌的Snapshot Debugger

Cloud Debugger 可免费使用。但是,您需要启用了结算功能的Google Cloud 项目,才能使用 Cloud Debugger。https://cloud.google.com/debugger/docs?hl=zh-cn。Cloud Debugger 已于2022 年 5 月 16 日弃用,该服务已于2023 年 5 月 31 日关闭。为满足在此关停后出现的调试需求,google构建了一个开源 CLI 工具 Snapshot Debugger。https://github.com/GoogleCloudPlatform/snapshot-debugger。

JDebugger核心实现

需要解决的几个核心问题,其中包括:

  1. 对debug代码能够执行到时hang住,实现方式对原始代码侵入性最小
  2. web界面能够开启/关闭断点,即断点的核心能力
  3. 能够将断点时,将此处断点可见的所有变量值打印,其中包括实例变量和局部变量
  4. 能够对环境进行区分处理
  5. 能够对执行上下文进行跟踪
  6. 日志
  7. 如何依赖javac

javac与抽象语法树AST


为了能在请求线程在执行到某处时hang住的方式有很多,例如利用线程阻塞、挂起甚至是处于循环状态。比如方法print:

public void print(String str){
   int a = 100;
   int b = 200;
   System.out.println(a + b);
}

当我们在int a = 100;打断点,则代码在即将执行int a =100;时需要hang住。为了实现这样的功能,我们需要知道何时代码执行到了该断点的位置,且自动hang住——为了解决这个问题,我们需要对每行代码做特殊处理。

比如在JDebugger的实现中,利用javac对目标抽象语法树AST进行动态insert(类似lombok)——在对应的statement前增加能够hang住、放行的逻辑,就能实现类似断点的效果——那为了能够侵入到编译阶段,我们需要实现编译时注解处理器AbstractProcessor。下图是process的执行流程:

这张图清晰地展示了Java源码编译的三个阶段

  • Parse and Enter: Java源文件被解析成抽象语法树(Abstract syntax tree,AST)
  • Annotation Processing: 扫描注解,调用对应的编译时注解处理器处理注解。这个过程可能会修改已有的源文件或者产生新的源文件,这些源文件将再次进入Parse and Enter阶段进行处理
  • Analyse and Generate: 分析AST并转化为class文件

而在Annotation Processing中,我们需要对生成的AST进行再次处理,下面以通过rootEnv获取的classElement为例,转成JClassDecl后处理每个statement,在每个statement前面增加一条的BreakPointExecutor.executor:

比如方法print的JTree.JMethodTree的原始statement:

public void print(String str){
   int a = 100;
   int b = 200;
   System.out.println(a + b);
}

我们对其进行处理后,最终生成的statement:

public void print(String str){
   BreakPointExecutor.execute(1, "print", this, [str]);
   int a = 100;
   BreakPointExecutor.execute(2, "print", this, [str, a]);
   int b = 200;
   BreakPointExecutor.execute(2, "print", this, [str, a, b]);
   System.out.println(a + b);
}

我们遍历每个classElement,通过classElement.getEnclosedElements()获取类的全部子元素,对方法子元素、内部类子元素进行处理:

for(JCTree tree : classElement.getEnclosedElements()){
  if(tree instanceof JCTree.JCMethodDecl){
    JCTree.JCMethodDecl jcMethodDecl = (JCTree.JCMethodDecl) tree;
    JCTree.JCBlock body = jcMethodDecl.body;
    com.sun.tools.javac.util.List parameters = jcMethodDecl.params;
    //上面是每个断点都公用的局部变量,再对方法内的JCStatement进行遍历,
    //如果是VariableDecl或者是ExpressionStatement中的JCAssign,
    //则在此statement之后的每个breakpoint都会新增该局部变量,成为下个statement的新增局部变量
    java.util.List<String> breakPointsVariables = new ArrayList<>();
    parameters.forEach(eachParameter -> {
       breakPointsVariables.add(eachParameter.name.toString());
    });	
    ListBuffer finalStatementsBuffer = new ListBuffer<>();
    for (JCTree.JCStatement statement : body.stats /* body statement */) {
       Collection collections = this.processEachStatement(statement, breakPointsVariables, clazz);
       finalStatementsBuffer.addAll(collections);
    }}
    //最后重新刷新此body
    body.stats = finalStatementsBuffer.toList(); 
}

然而,每个方法内包含的语句非常繁杂,其中包括:JCVariableDecl变量声明、JCExpressionStatement表达式、JCIf、JCFor、JCEnhanceFor、JcWhile、JCDoWhile、JCBlock等,我们必须对所有不同的语句进行一一处理,下面是对每条statement做特殊处理的细节processEachStatement:

private Collection<? extends JCTree.JCStatement> processEachStatement(JCTree.JCStatement statement, List<String> variables, Clazz clazz) {
        try {
            java.util.List<String> result = new ArrayList<>(variables); //前一步的变量
            final TreeMaker treeMaker = CompilerProcessor.treeMaker;


            // int x = 0;
            if (statement instanceof JCTree.JCVariableDecl) {  //后一步变量
                this.variableDeclare(statement, variables);
            }


            // int add = "a" + "b"
            if (statement instanceof JCTree.JCExpressionStatement) { //后一步变量
                this.expressStatement(statement, variables);
            }
            //...继而处理if、ifelse、while、for、for增强、try catch finally、switch、block等
            
            //最后是生成代码
            List<JCTree.JCStatement> r = null;
            synchronized (AstCallable.class) {
                treeMaker.pos = statement.pos;
                r = com.sun.tools.javac.util.List.of(this.insertNewStatement(pointIndex++, result), statement);
            }
            reeturn r;
}

经过上面的处理结果,我们在原始的代码中对可以断点的代码前都增加了BreakPointExecutor.execute。如此,在int a = 100;处打断点,可以理解为BreakPointExecutor.execute需要while true,断点的能力就显而易见了。

public void print(String str){
   BreakPointExecutor.execute(1, "print", this, [str]);
   int a = 100;
   BreakPointExecutor.execute(2, "print", this, [str, a]);
   int b = 200;
   BreakPointExecutor.execute(2, "print", this, [str, a, b]);
   System.out.println(a + b);
}

厨房kitchen、主厨chef、菜品dish、食物food


执行接口调用或者是内部定时器时,通常是一个线程处理,比如tomcat的http线程,比如rpc的内部worker线程,比如定时器的schedule线程。此时我们基于线程的while true、阻塞等方式虽然可以实现线程隔离,但基于web实现的debug能力在执行step over、step into等可能使用的是另外一个线程,所以设计出厨房kitchen、主厨chef、菜品dish、食物food角色。

操作线程通过更新缓存的dish,而断点线程通过不同的获取最新的dish,来处理自身获取food。

变量打印


因为这种实现debug的方式,并没有采用通用的IDE的socket走JVMTI的方式实现,所以只能获取局部对象以及实例对象,在输出层面目前只能简单的将其json化输出前端,其实现原理是在annotation processor处理时,获取方法声明的参数、局部变量定义打包进每条BreakPointExecutor的execute语句中:

public void print(String str){
   BreakPointExecutor.execute(1, "print", this, [str]);
   int a = 100;
   BreakPointExecutor.execute(2, "print", this, [str, a]);
   int b = 200;
   BreakPointExecutor.execute(2, "print", this, [str, a, b]);
   System.out.println(a + b);
}

比如上面断点int a = 100处,只能获取str的局部变量,经过int a = 100;后能获取str和a两个局部变量,以此类推。

前端的断点能力step over、step into等实现原理


通过AST生成的带BreakPointExecutor的源码classes内容即使按照原文输出,也是无法实现断点能力,所以为了在前端展示上,尽量贴合IDE的debug效果,将类似:

BreakPointExecutor.execute(1, "print", this, [str]);

代码format成如下代码:

<span class="point-normal point-base" onclick="breakPointClicked(event)" status="0" id="point|TestClazz|1"></span>

当输出前端后,点击当前span即可完成渲染,并发送addBreakPoint或者removeBreakPoint的http请求到后端,将id为point|TestClazz|1的断点标记。当前端渲染时,因行数较多的情况下,仍然有较高的性能损耗——为了优化,这样的渲染在前端请求此类渲染时第一次完成,完成渲染后只对有断点的情况做特殊处理。

环境隔离与jar内html、css等加载


为了隔离线上与预发环境,我们在maven的compile阶段注入-Djdebugger=true的参数。不加-Djdebugger=true无断点能力,业务完全不受影响(同不加dependency)。因为tomcat会扫描jar下的META-INF下的resources目录,所以自然将html等前端文件放入该路径下。为了不在web.xml中做到强制引入,所以将servelt的版本升级到了3.0,通过@Webservlet引入不同的action,但有一点,@WebServlet在传统的spring项目中,利用tomcat7.0时可以直接被扫描到,但在springboot项目中,需要配合@ServletComponentScan才能被加载。但为了能够比较少的侵入让依赖的业务方hard code@ServletComponent,所以在检查每个classElement时,如果包含SprintBootApplication时,且没有声明@ServletComponentScan时帮助声明@ServletComponentScan,如果声明了@ServletComponentScan,则在value中添加"com.jd.java.debug.runtime"路径从而将servlet扫描。

轻量与javac的依赖


在整个jar中只依赖了servlet3.0,以及jackson。对于javac的依赖,之前build的时候一直在maven的dependency的systemPath中引入../tools.jar。后来将sun的源码直接打包一起。