自己动手实现java断点/单步调试(二)

1,679 阅读6分钟

自从上一篇《自己动手实现java断点/单步调试(一)》

 是时候应该总结一下JDI的事件了

事件类型描述
ClassPrepareEvent装载某个指定的类所引发的事件
ClassUnloadEvent卸载某个指定的类所引发的事件
BreakingpointEvent设置断点所引发的事件
ExceptionEvent目标虚拟机运行中抛出指定异常所引发的事件
MethodEntryEvent进入某个指定方法体时引发的事件
MethodExitEvent某个指定方法执行完成后引发的事件
MonitorContendedEnteredEvent线程已经进入某个指定 Monitor 资源所引发的事件
MonitorContendedEnterEvent线程将要进入某个指定 Monitor 资源所引发的事件
MonitorWaitedEvent线程完成对某个指定 Monitor 资源等待所引发的事件
MonitorWaitEvent线程开始等待对某个指定 Monitor 资源所引发的事件
StepEvent目标应用程序执行下一条指令或者代码行所引发的事件
AccessWatchpointEvent查看类的某个指定 Field 所引发的事件
ModificationWatchpointEvent修改类的某个指定 Field 值所引发的事件
ThreadDeathEvent某个指定线程运行完成所引发的事件
ThreadStartEvent某个指定线程开始运行所引发的事件
VMDeathEvent目标虚拟机停止运行所以的事件
VMDisconnectEvent目标虚拟机与调试器断开链接所引发的事件
VMStartEvent目标虚拟机初始化时所引发的事件

在上一篇之中我们只是用到了BreakingpointEvent和VMDisconnectEvent事件,这一篇我们为了加单步调试会用到StepEvent事件了,创建执行下一条、进入方法,跳出方法的事件代码如下

/**
     * 众所周知,debug单步调试过程最重要的几个调试方式:执行下一条(step_over),执行方法里面(step_into),
     * 跳出方法(step_out)。
     * @param eventType 断点调试事件类型 STEP_INTO(1),STEP_OVER(2),STEP_OUT(3)
     * @return
     * @throws Exception
     */
    private EventRequest createEvent(EventType eventType) throws Exception {
​
        /**
         * 根据事件类型获取对应的事件请求对象并激活,最终会被放到事件队列中
         */
        EventRequestManager eventRequestManager = virtualMachine.eventRequestManager();
​
        /**
         * 主要是为了把当前事件请求删掉,要不然执行到下一行
         * 又要发送一个单步调试的事件,就会报一个线程只能有一种单步调试事件,这里很多细节都是
         * 本人花费大量事件调试得到的,可能不是最优雅的,但是肯定是可实现的
         */
        if(eventRequest != null) {
            eventRequestManager.deleteEventRequest(eventRequest);
        }
​
        eventRequest = eventRequestManager.createStepRequest(threadReference,StepRequest.STEP_LINE,eventType.getIndex());
        eventRequest.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
        eventRequest.enable();
​
        /**
         * 同上创建断点事件,这里也是创建完事件,就释放被调试程序
         */
        if(eventsSet != null) {
            eventsSet.resume();
        }
        return eventRequest;
    }

获取当前本地变量,成员变量,方法信息,类信息等方法修改为如下

/**
     * 消费调试的事件请求,然后拿到当前执行的方法,参数,变量等信息,也就是debug过程中我们关注的那一堆变量信息
     * @return
     * @throws Exception
     */
    private DebugInfo getInfo() throws Exception {
        DebugInfo debugInfo = new DebugInfo();
        EventQueue eventQueue = virtualMachine.eventQueue();
        /**
         * 这个是阻塞方法,当有事件发出这里才可以remove拿到EventsSet
         */
        eventsSet= eventQueue.remove();
        EventIterator eventIterator = eventsSet.eventIterator();
        if(eventIterator.hasNext()) {
            Event event = eventIterator.next();
            /**
             * 一个debug程序能够debug肯定要有个断点,直接从断点事件这里拿到当前被调试程序当前的执行线程引用,
             * 这个引用是后面可以拿到信息的关键,所以保存在成员变量中,归属于当前的调试对象
             */
            if(event instanceof BreakpointEvent) {
                threadReference = ((BreakpointEvent) event).thread();
            } else if(event instanceof VMDisconnectEvent) {
                /**
                 * 这种事件是属于讲武德的判断方式,断点到最后一行之后调用virtualMachine.dispose()结束调试连接
                 */
                debugInfo.setEnd(true);
                return debugInfo;
            } else if(event instanceof StepEvent) {
                threadReference = ((StepEvent) event).thread();
            }
            try {
                /**
                 * 获取被调试类当前执行的栈帧,然后获取当前执行的位置
                 */
                StackFrame stackFrame = threadReference.frame(0);
                Location location = stackFrame.location();
                /**
                 * 当前走到线程退出了,就over了,这里其实是我在调试过程中发现如果调试的时候不讲武德,明明到了最后一行
                 * 还要发送一个STEP_OVER事件出来,就会报错。本着调试端就是客户,客户就是上帝的心态,做了一个不太优雅
                 * 的判断
                 */
                if("java.lang.Thread.exit()".equals(location.method().toString())) {
                    debugInfo.setEnd(true);
                    return debugInfo;
                }
                /**
                 * 无脑的封装返回对象
                 */
                debugInfo.setClassName(location.declaringType().name());
                debugInfo.setMethodName(location.method().name());
                debugInfo.setLineNumber(location.lineNumber());
                /**
                 * 封装成员变量
                 */
                ObjectReference or = stackFrame.thisObject();
                if(or != null) {
                    List<Field> fields = ((LocationImpl) location).declaringType().fields();
                    for(int i = 0;fields != null && i < fields.size();i++) {
                        Field field = fields.get(i);
                        Object val = parseValue(or.getValue(field),0);
                        DebugInfo.VarInfo varInfo = new DebugInfo.VarInfo(field.name(),field.typeName(),val);
                        debugInfo.getFields().add(varInfo);
                    }
                }
                /**
                 * 封装局部变量和参数,参数是方法传入的参数
                 */
                List<LocalVariable> varList = stackFrame.visibleVariables();
                for (LocalVariable localVariable : varList) {
                    /**
                     * 这地方使用threadReference.frame(0)而不是使用上面已经拿到的stackFrame,从代码上看是等价,
                     * 但是有个很坑的地方,如果使用stackFrame由于下面使用threadReference执行过invokeMethod会导致
                     * stackFrame的isValid为false,再次通过stackFrame.getValue就会报错,每次重新threadReference.frame(0)
                     * 就没有问题,由于看不到源码,个人推测threadReference.frame(0)这里会生成一份拷贝stackFrame,由于手动执行方法,
                     * 方法需要用到栈帧会导致执行完方法,这个拷贝的栈帧被销毁而变得不可用,而每次重新获取最上面得栈帧,就不会有问题
                     */
                    DebugInfo.VarInfo varInfo = new DebugInfo.VarInfo(localVariable.name(),localVariable.typeName(),parseValue(threadReference.frame(0).getValue(localVariable),0));
                    if(localVariable.isArgument()) {
                        debugInfo.getArgs().add(varInfo);
                    } else {
                        debugInfo.getVars().add(varInfo);
                    }
                }
            } catch(AbsentInformationException | VMDisconnectedException e1) {
                debugInfo.setEnd(true);
                return debugInfo;
            } catch(Exception e) {
                debugInfo.setEnd(true);
                return debugInfo;
            }
​
        }
​
        return debugInfo;
    }

事件枚举如下

/**
 * 调试事件类型
 * @author rongdi
 * @date 2021/1/31
 */
public enum EventType {
    // 进入方法
    STEP_INTO(1),
    // 下一条
    STEP_OVER(2),
    // 跳出方法
    STEP_OUT(3);
​
    private int index;
​
    private EventType(int index) {
        this.index = index;
    }
​
    public int getIndex() {
        return index;
    }
​
    public static EventType getType(Integer type) {
        if(type == null) {
            return STEP_OVER;
        }
        if(type.equals(1)) {
            return STEP_INTO;
        } else if(type.equals(3)){
            return STEP_OUT;
        } else {
            return STEP_OVER;
        }
    }
}

为了方便使用,我们合并一下方法,统一对外提供的工具方法如下

/**
     * 打断点并获取当前执行的类,方法,各种变量信息,主要是给调试端断点调试的场景,
     * 当前执行之后有断点,使用此方法会直接运行到断点处,需要注意的是不要两次请求打同一行的断点,这样会导致第二次断点
     * 执行时如果后续没有断点了,会直接执行到连接断开
     * @param className
     * @param lineNumber
     * @return
     * @throws Exception
     */
    public DebugInfo markBpAndGetInfo(String className, Integer lineNumber) throws Exception {
        markBreakpoint(className, lineNumber);
        return getInfo();
    }
​
    /**
     * 单步调试,
     * STEP_INTO(1) 执行到方法里
     * STEP_OVER(2) 执行下一行代码
     * STEP_OUT(3)  跳出方法执行
     * @param eventType
     * @return
     * @throws Exception
     */
    public DebugInfo stepAndGetInfo(EventType eventType) throws Exception {
        createEvent(eventType);
        return getInfo();
    }
​
    /**
     * 当断点到最后一行后,调用断开连接结束调试
     */
    public DebugInfo disconnect() throws Exception {
        virtualMachine.dispose();
        map.remove(tag);
        return getInfo();
    }

最后我们提供一个统一的接口类,统一对外提供断点/单步调试服务

/**
 * 调试接口
 * @author rongdi
 * @date 2021/1/31
 */
@RestController
public class DebuggerController {
​
    @RequestMapping("/breakpoint")
    public DebugInfo breakpoint(@RequestParam String tag, @RequestParam String hostname, @RequestParam Integer port, @RequestParam String className, @RequestParam Integer lineNumber) throws Exception {
        Debugger debugger = Debugger.getInstance(tag,hostname,port);
        return debugger.markBpAndGetInfo(className,lineNumber);
    }
​
    @RequestMapping("/stepInto")
    public DebugInfo stepInto(@RequestParam String tag) throws Exception {
        Debugger debugger = Debugger.getInstance(tag);
        return debugger.stepAndGetInfo(EventType.STEP_INTO);
    }
​
    @RequestMapping("/stepOver")
    public DebugInfo stepOver(@RequestParam String tag) throws Exception {
        Debugger debugger = Debugger.getInstance(tag);
        return debugger.stepAndGetInfo(EventType.STEP_OVER);
    }
​
    @RequestMapping("/stepOut")
    public DebugInfo step(@RequestParam String tag) throws Exception {
        Debugger debugger = Debugger.getInstance(tag);
        return debugger.stepAndGetInfo(EventType.STEP_OUT);
    }
​
    @RequestMapping("/disconnect")
    public DebugInfo disconnect(@RequestParam String tag) throws Exception {
        Debugger debugger = Debugger.getInstance(tag);
        return debugger.disconnect();
    }
}

至此,对于远程断点调试的功能已经基本完成了,虽然写的过程中确实很虐,但是写完后还是发现挺简单的。扩展思路(个人感觉作为远程的调试没有必要做以下扩展):

  1. 加入类似IDE调试界面左边的方法栈信息

    只需要加入MethodEntryEvent和MethodExitEvent事件并引入一个stack对象,每当进入方法的时候把调试信息压栈,退出方法时出栈调试信息,然后调试返回信息加上这个栈的信息返回就可以了

  2. 加入条件断点功能这里可以通过ognl、spring的spEL表达式都可以实现

  3. 手动方法执行返回结果其实解决方案同2


好了,自己动手实现JAVA断点调试的文章暂时告一个段落了,需要详细源码可以关注一下同名公众号,让我有动力继续研究网上搜索不到的东西。