前文:
《leetcode-runner》如何手搓一个debug调试器——引言
《leetcode-runner》如何手搓一个debug调试器——架构
《leetcode-runner》如何手搓一个debug调试器——指令系统
本文主要聚焦于如何编写调试程序
背景
在leetcode算法背景下,用户只编写了一个Solution文件。在此基础上,项目该做出哪些额外操作,才能够启动并运行程序呢?
显然,我们需要程序入口。其次,还需要自己实现一套调试程序,控制调试进度
程序入口
方案选择
在Java中,程序入口是main方法。想要启动Solution类,我们存在两种解决方案
-
复制用户编写的Solution的所有内容,并动态添加main函数入口,将新得到的内容写入一个全新的文件。最后启动这个全新文件
-
原封不动的拷贝用户编写的Solution的所有内容,同时创建一个全新的Main类,在Main类中调用Solution的方法
考虑到规整性,leetcode-runner选择了第二种解决方案
如何将测试案例转换为适配Solution方法入参的代码
模板引入
让我们通过一个简单的case引入解决方案
solution模板
class Solution {
public int lengthOfLongestSubstring(String s) {
}
}
测试案例
“abcabcbb”
现在的需求是,根据solution模板
,测试案例
,创建一个调用Solution的Main函数
假设我们没有经过程序自动计算生成,让我们手写,Main类长啥样呢?
import java.util.*;
public class Main {
public static void main(String[] args) {
Solution solution = new Solution(); // 创建实例
String a = "abcabcbb"; // 测试案例转换为Java代码
solution.lengthOfLongestSubstring(a); // 调用核心方法
}
}
让我们分析一下这段代码哪些是固定的,哪些是需要动态生成的
首先整个Main的结构是死的,创建实例是死的,活的部分是测试案例转换代码
和方法调用
进一步改写,得到如下模板
import java.util.*;
public class Main {
public static void main(String[] args) {
Solution solution = new Solution();
{{callCode}}
}
}
{{callCode}},标志着调用代码生成的位置,需要程序动态生成创建
测试案例转换
现在有一个字符串——“abcabcbb”,我们需要将他转换为Java代码。但现在存在一个问题,我们需要将"abcabcbb"赋值给变量,那么变量名叫啥?,变量类型是啥?
现在的测试案例非常简单,变量名给个a,类型一眼字符串
那我现在上上强度
请问,[1,2,3]应该转换成什么类型的变量呢?
a. 数组
b. TreeNode
c. ListNode
现在各位读者猜猜,选啥
3、2、1
答案是——都有可能!!!
以leetcode-1367为例
这样的数组类型既可以表示ListNode,也可以表示TreeNode。当然,int[]数组自然也是可以的
从上述案例可以发现,测试案例转变得到的变量类型并不是恒定的,它取决于上下文——Solution的方法入参
如果入参类型是ListNode,那么[1,2,3]就是ListNode。如果入参类型是TreeNode,那么[1,2,3]就是TreeNode。如果入参是int[],那么[1,2,3]就是int[]
因此,我们要明确将测试案例转换成何种类型的变量,就必须要知道入参类型是什么,而这就引出了核心代码分析功能
核心代码分析
所谓核心代码分析,就是对于leetcode提供的片段代码,识别出关键信息
在leetcode-runner项目中,核心信息有方法名
,所有的入参类型
,并通过AnalysisResult
封装分析结果
CodeAnalyzer
是代码分析器,用于分析核心代码片段,返回分析的结果
至于如何分析核心代码,需要利用正则这项技术
这里我将提供leetcode-runner部分源码
/**
* Java代码分析器
*
* @author feigebuge
* @email 2508020102@qq.com
*/
public class JavaCodeAnalyzer extends AbstractCodeAnalyzer{
public JavaCodeAnalyzer(Project project) {
super(project);
}
/*
(\w+) 捕获组, 匹配字母数字下划线
*/
private static final String methodPattern = "public\\s+.*\\s+(\\w+)\\s*\\(([^)]*)\\)";
private static final Pattern pattern = Pattern.compile(methodPattern);
public AnalysisResult analyze(String code) {
LogUtils.simpleDebug(code);
// 正则表达式匹配方法签名
Matcher matcher = pattern.matcher(code);
if (matcher.find()) {
String methodName = matcher.group(1); // 获取方法名
String parameters = matcher.group(2); // 获取参数列表
// 解析参数类型
List<String> parameterTypes = new ArrayList<>();
String[] parametersArray = parameters.split("\\s*,\\s*");
for (String param : parametersArray) {
// 提取类型部分
String[] parts = param.split("\\s+");
if (parts.length > 1) {
parameterTypes.add(parts[0].trim()); // 只获取类型
}
}
return new AnalysisResult(methodName, parameterTypes.toArray(new String[0]));
}
throw new DebugError("代码片段分析错误! 无法匹配任何有效信息\n code = " + code);
}
}
在代码中,利用到正则的匹配组的功能,通过match.group方法,匹配得到不同的类型
举个例子
public String dfs(int a, int b)
这行代码将会被regex的group匹配到两组内容
group 1:dfs
group 2:int a, int b
group 1匹配得到方法名——dfs
group 2匹配得到括号内部的所有内容,接下来只需要按照逗号进行分割,取第一个符号就可以得到入参类型
通过分析结果,将测试案例转换为Java代码
先上UML,再解释
在leetcode-runner中,负责将测试案例转换为对应代码的类是TestcaseConvertor
,但我目前写的毕竟是Java 调试器,自然而然的,负责这块的类就是JavaTestcaseConvertor,后文将称呼他为JTC
在JTC处理测试案例时,会将测试案例和方法入参类型进行匹配,然后统一交给ConvertorFactory
,根据不同的入参类型生成不同的VariableConvertor
VariableConvertor
负责将测试案例转换成不同类型的代码。比如IntArrayConvertor
,负责将输入转换为int[]
变量;IntConvertor,负责将输入转为为int
变量
流程汇总
现在,我们将上文介绍的类进行汇总,得到整体流程
在项目中,已经存在Main.template,具体内容如下
import java.util.*;
public class Main {
public static void main(String[] args) {
Solution solution = new Solution();
{{callCode}}
}
}
现在,我们需要做的是将测试案例转换成代码 + 创建实例调用代码,将代码填入{{callCode}}
内部
整套流程如下
对应到leetcode-runner代码,就长这样
tip:
- autoAnalyze()方法是对analyze(String)做出的封装,他会自动获取核心代码,并传递给analyze方法
- autoConvert()方法是对convert(String testcase)方法做出的封装,autoConvert()会自动获取代码片段,并传递给convert(String testcase)方法
JDI层次
Mirror
JDI(Java Debug Interface),是一套为了debug调试获取目标JVM运行状态的接口
通过JDI定义的接口,我们可以驱动目标JVM调试目标代码,同时获取JVM执行的状态信息,以及目标代码的数据信息
在JDI开发中,最最核心的是VirtualMachie
类,他封装了正在执行debug的JVM的所有信息。为了和执行调试程序的JVM做出区分,执行debug的JVM我们称为TargetVM
这里,需要进行一个区分,在JDI开发中,VirtualMachine
是TargetVM的镜像——Mirror
在JDI开发过程中,所有的操作都会如实的作用到TargetVM
,就像镜子那样,你一动,我就动。因此,在JDI的包下,所有类都是Mirror
的子类,换句话说,Mirror是顶级父类
VirtualMachine
连接
总共有两种连接方式
- 连接正在运行的程序,并返回目标VM的镜像
- 启动一个应用程序并连接返回目标VM的镜像
这两者的区别是:方法1可以是远程连接,方法2必须是本地连接
对于方法一,连接需要地址,端口信息,因此方法一在debug调试时,可以远程连接。JDI在电脑A,目标VM在电脑B
对于方法二,JDI提供的接口同时负责启动,连接。因为启动的功能交由JDI,因此只能是本地连接
leetcode-runner提供了两种连接方式,笔者将提供部分连接代码
方法一
private void debugRemotely() {
startVMService();
connectVM();
}
private void startVMService() {
this.port = DebugUtils.findAvailablePort();
LogUtils.simpleDebug("get available port : " + this.port);
String cdCmd = "cd " + env.getFilePath();
String startCmd = String.format("%s -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=%d -cp %s %s",
env.getJava(), port, env.getFilePath(), "Main");
String combinedCmd = "cmd /c " + cdCmd + " & " + startCmd;
LogUtils.simpleDebug(combinedCmd);
try {
Process exec = Runtime.getRuntime().exec(combinedCmd);
getRunInfo(exec);
} catch(InterruptedException ignored) {
} catch (Exception e) {
throw new DebugError(e.toString(), e);
}
}
/**
* 连接VM, 开始debug
*/
private void connectVM() {
// 创建连接
VirtualMachineManager vmm = Bootstrap.virtualMachineManager();
AttachingConnector connector = getConnector(vmm);
// 配置调试连接信息
Map<String, Connector.Argument> arguments = connector.defaultArguments();
arguments.get("port").setValue(String.valueOf(port));
// 连接到目标 JVM
VirtualMachine vm = null;
// 3次连接尝试
int tryCount = 1;
do {
try {
DebugUtils.simpleDebug("第 " + tryCount + " 次连接, 尝试中...", project);
vm = connector.attach(arguments);
DebugUtils.simpleDebug("连接成功", project);
break;
} catch (IOException | IllegalConnectorArgumentsException e) {
DebugUtils.simpleDebug("连接失败: " + e, project);
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}
}
tryCount++;
} while (tryCount <= 3);
if (vm == null) {
LogUtils.warn("vm 连接失败");
throw new DebugError("vm 连接失败");
}
startProcessEvent(vm);
}
方法二
/**
* 本地断点启动
*/
private void debugLocally() {
// 获取 LaunchingConnector
LaunchingConnector connector = Bootstrap.virtualMachineManager().defaultConnector();
// 配置启动参数
Map<String, Connector.Argument> arguments = connector.defaultArguments();
arguments.get("main").setValue("Main"); // 替换为你的目标类全路径
arguments.get("options").setValue("-classpath " + env.getFilePath() + " -Dencoding=utf-8"); // 指定类路径
// fix: 编译jdk和运行jdk不一致问题
arguments.get("home").setValue(env.getJAVA_HOME());
arguments.get("vmexec").setValue("java.exe");
// 启动目标 JVM
VirtualMachine vm;
try {
vm = connector.launch(arguments);
} catch (IOException | IllegalConnectorArgumentsException | VMStartException e) {
throw new DebugError("vm启动失败!", e);
}
// 捕获目标虚拟机的输出
captureStream(vm.process().getInputStream(), OutputHelper.STD_OUT);
captureStream(vm.process().getErrorStream(), OutputHelper.STD_ERROR);
// 获取当前类的调试信息
startProcessEvent(vm);
}
EventQueue / EventSet
在介绍本小节内容前,请允许我提个问题
如果TargetVM处理产生各种结果,该如何通知VirtualMachine
呢?
或者换个问法,VirtualMachine
如何知道TargetVM干了啥,产生了生么结果呢?
这就得引出EventQueue
EventQueue
,管理即将到来的TargetVM在debug过程中产生的事件。事件总是会被封装到EventSet
当中。EventSet
总是由debug程序创建生成,并可以通过EventQueue
读取
VirtualMachine
如果想要知道TargetVM产生了哪些事件,可以通过VirtualMachine.eventQueue()
获得EventQueue
,EventQueue.remove()
得到EventSet
。最后通过遍历EventSet
可以获得TargetVM即将处理的各种事件
笔者将简化leetcode-runner代码,提供一个基本的处理demo
EventQueue eventQueue = virtualMachine.eventQueue();
while (true) {
EventSet set = eventQueue.remove();
Iterator<Event> it = set.iterator();
boolean resumeStoppedApp = false;
while (it.hasNext()) {
// 只要有一个事件不resume, 就必须resume
resumeStoppedApp |= !handleEvent(it.next());
}
if (resumeStoppedApp) {
set.resume();
}
}
Event
发生在TargetVM,并且调试器非常感兴趣的事件。Event是所有事件的顶级父类,当事件发生,事件实例将会写入EventQueue,等待调试程序处理
Event有很多类型
- BreakpointEvent
- ClassPrepareEvent
- StepEvent
- …
Event中封装了非常多的重要信息
如果某个Event继承了LocatableEvent,那他就拥有了TargetVM执行的位置信息和线程引用
这两个对象相当关键,其具体信息将在下一小节介绍
ThreadReference / Location
ThreadReference:目标JVM的线程引用,包含TargetVM当前执行线程的所有信息。我们都知道,代码会在某个线程中执行,在调用方法时,会执行入栈操作。栈中包含局部变量的引用,通过引用可以获取局部变量的实际值
Location:表示目标JVM当暂停线程当前执行到的位置信息。
包括
- sourceName:当前执行位置的源码名称
- sourcePath:源码路径
- lineNumber:执行代码行号
- …
Value,Type
Value是TargetVM中,值的镜像。在JDI开发体系中,Value的整个继承图谱巨tm复杂且麻烦
下图是JDI的文档,大体上分,Java所有value镜像可以分为PrimitiveValue
,ObjectReference
。前者是基本类型的镜像,后者是对象类型的镜像
Type,类型的镜像
Value
,Type
之间的关系,有点像实例,和Class之间的关系
对于对象类型的Value,如果想要获取对象的某个字段的值,需要通过ReferenceType获取Field信息,然后通过ObjectReference.getValue(Field)
的方法获得Value
这里贴出一段处理ArrayDeque内部elements属性的代码
private String handleArrayDeque(ObjectReference objRef, int depth) {
ReferenceType referenceType = objRef.referenceType();
Value elements = objRef.getValue(referenceType.fieldByName("elements"));
...
}
EventRequestManager
事件请求管理器,通过管理器,可以向TargetVM发送各种事件请求
eg
- createStepRequest 创建单步运行请求
- createBreakpointRequest 创建断点请求
- createClassPrepareRequest 类准备请求
- …