自己動手實現java斷點/單步除錯(二)

碼小D發表於2021-02-05

自從上一篇《自己動手實現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斷點除錯的文章暫時告一個段落了,需要詳細原始碼可以關注一下同名公眾號,讓我有動力繼續研究網上搜尋不到的東西。

相關文章