自己动手实现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(); } }
至此,对于远程断点调试的功能已经基本完成了,虽然写的过程中确实很虐,但是写完后还是发现挺简单的。扩展思路(个人感觉作为远程的调试没有必要做以下扩展):
-
加入类似IDE调试界面左边的方法栈信息
只需要加入MethodEntryEvent和MethodExitEvent事件并引入一个stack对象,每当进入方法的时候把调试信息压栈,退出方法时出栈调试信息,然后调试返回信息加上这个栈的信息返回就可以了
- 加入条件断点功能这里可以通过ognl、spring的spEL表达式都可以实现
- 手动方法执行返回结果其实解决方案同2
好了,自己动手实现JAVA断点调试的文章暂时告一个段落了,需要详细源码可以关注一下同名公众号,让我有动力继续研究网上搜索不到的东西。