自学内容网 自学内容网

《leetcode-runner》【图解】如何手搓一个debug调试器——调试程序【JDI开发】【万字详解】

前文:
《leetcode-runner》如何手搓一个debug调试器——引言
《leetcode-runner》如何手搓一个debug调试器——架构
《leetcode-runner》如何手搓一个debug调试器——指令系统

本文主要聚焦于如何编写调试程序

背景

在leetcode算法背景下,用户只编写了一个Solution文件。在此基础上,项目该做出哪些额外操作,才能够启动并运行程序呢?

显然,我们需要程序入口。其次,还需要自己实现一套调试程序,控制调试进度

程序入口

方案选择

在Java中,程序入口是main方法。想要启动Solution类,我们存在两种解决方案

  1. 复制用户编写的Solution的所有内容,并动态添加main函数入口,将新得到的内容写入一个全新的文件。最后启动这个全新文件在这里插入图片描述

  2. 原封不动的拷贝用户编写的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

连接

总共有两种连接方式

  1. 连接正在运行的程序,并返回目标VM的镜像
  2. 启动一个应用程序并连接返回目标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()获得EventQueueEventQueue.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镜像可以分为PrimitiveValueObjectReference。前者是基本类型的镜像,后者是对象类型的镜像

在这里插入图片描述

Type,类型的镜像
在这里插入图片描述

ValueType之间的关系,有点像实例,和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 类准备请求

md,写不动了,今天就这样吧,明天继续补充…

架构


原文地址:https://blog.csdn.net/qq_62835094/article/details/145159294

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!