本篇幅内容很长,但它应该被记录——既能自身总结,也能传递知识。目前在之前的老版本实现的能力上重构了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内容。
设计架构(真复杂啊):
https://docs.huihoo.com/javaone/2015/BOF3204-Build-a-Java-Debugger-for-the-Cloud-Practices-in-the-Alibaba-Financial-Cloud.pdf
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。
需要解决的几个核心问题,其中包括:
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源码编译的三个阶段
而在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的源码直接打包一起。