自己動手實現java斷點/單步調試(一)
又是好長時間沒有寫部落格了,今天我們就來談一下java程式的斷點調試。寫這篇主題的主要原因是身邊的公司或者個人都執著於做apaas平台,簡單來說apaas平台就是一個零程式碼或者低程式碼的配置平台,通過配置平台相對快速的配置出web端和移動端的程式碼。這種系統我15年的時候和一個前端朋友為了方便快速的接外包也做過這種配置平台,做了2年多,後面又在某家公司做了一年多apaas平台,我算是深有體會。首先零程式碼明顯只是適合少兒編程領域的玩具,覺得零程式碼可以包打所有的人大有人在,個人猜想要麼是程式碼寫的不夠多,或者是被面向對象洗腦了,如果這個世界上所有系統的業務都是簡單的通過各種對象調用各種方法,然後通過一定邏輯組合起來,那麼確實可以用零程式碼,但是對象里的具體邏輯難道不是更加複雜的面向過程的程式碼嗎,循環幾次,幾層嵌套循環,循環里各種判斷跳轉,傳遞若干個局部變數,跳出若干層循環之外等等,那麼這種要怎麼用零程式碼的邏輯圖畫出來呢?我想就算真的能畫出來,肯定比直接擼程式碼更加困難了。相比較低程式碼比較靠譜一些,低程式碼也就是要寫程式碼,如果要寫程式碼不提供調試,那是不是在耍流氓呢?
其實15年那會我也遇到了需要在配置平台提供調試功能的尷尬,當時在網上找了一遍,根本找不到可以直接使用的斷點調試程式碼,為此還自己設計了一個蹩腳的解釋性語言,這個可以從之前部落格找到相關內容。後面Nashorn引擎出來後,就替換成了使用js寫業務程式碼,調試使用aop+方法攔截器+stack的方式實現了一個簡單的斷點調試,不過實現的一直都覺得很蹩腳,剛好現在有點時間了,決定研究一下java的斷點調試,百度找了下一堆JDI的實現,但是基本上全部都是介紹一堆理論,然後給出一個helloword式的例子,給待調試程式打個斷點,然後發出斷點事件,然後消費斷點事件,列印日誌,然後就沒有然後了。我在想如果要列印日誌用aop它不香嗎?說好的調試呢?更有進一步的:使用JDI調試多執行緒應用,點進去還是無腦的消費事件,列印一堆日誌。想不到時隔多年網上還是找不到一個可以直接用的斷點調試程式,不過我也要感謝你們,如果已經有了,那我就沒有這篇文章什麼事情了。鑒於網上一堆JDI的理論文章,本著只寫網上找不到的原創作品,本篇就決定不講理論了,理論百度上一大堆,搜索「JDI調試」即可。
進入正題,其實jdk的tools.jar中已經實現了一套調試的JDI的api,也就是java的調試介面,只不過用起來真的是很花時間,為了增強各位看官的興趣,先演示一下斷點調試的效果,下面進入我最喜歡的貼程式碼環節,先準備如下被調試服務
1 package com.rdpaas.debugger.test.controller; 2 3 import com.rdpaas.debugger.test.bean.Person; 4 import com.rdpaas.debugger.test.service.TestService; 5 import com.rdpaas.debugger.test.utils.MyList; 6 import com.rdpaas.debugger.test.utils.MyMap; 7 import org.springframework.beans.factory.annotation.Autowired; 8 import org.springframework.web.bind.annotation.RequestMapping; 9 import org.springframework.web.bind.annotation.RequestParam; 10 import org.springframework.web.bind.annotation.RestController; 11 12 import java.util.Arrays; 13 14 /** 15 * 被調試介面 16 * @author rongdi 17 * @date 2021/1/24 18 */ 19 @RestController 20 public class TestController { 21 22 @Autowired 23 private TestService testService; 24 25 private Integer flag = 1; 26 27 @RequestMapping("/test") 28 public Person test(@RequestParam String name) throws Exception { 29 Person ret = testService.getPerson(name); 30 MyList list1 = new MyList(); 31 list1.addAll(Arrays.asList(1,2,3)); 32 MyList list2 = new MyList(); 33 list2.add(new Person("張三",20)); 34 MyMap map1 = new MyMap(); 35 map1.put("name","小明"); 36 MyMap map2 = new MyMap(); 37 map2.put("person",new Person("李四",30)); 38 return ret; 39 } 40 41 42 }
1 package com.rdpaas.debugger.test.service; 2 3 import com.rdpaas.debugger.test.bean.Person; 4 import org.springframework.stereotype.Service; 5 6 @Service 7 public class TestService { 8 9 public Person getPerson(String name) { 10 Person p = new Person(); 11 p.setAge(20); 12 p.setName(name); 13 return p; 14 } 15 16 }
1 package com.rdpaas.debugger.test.bean; 2 3 public class Person { 4 5 private String name; 6 7 private Integer age; 8 9 public Person(String name, Integer age) { 10 this.name = name; 11 this.age = age; 12 } 13 14 public Person() { 15 } 16 17 public String getName() { 18 return name; 19 } 20 21 public void setName(String name) { 22 this.name = name; 23 } 24 25 public Integer getAge() { 26 return age; 27 } 28 29 public void setAge(Integer age) { 30 this.age = age; 31 } 32 }
1 package com.rdpaas.debugger.test.utils; 2 3 import java.util.ArrayList; 4 5 /** 6 * 故意定義一個集合的實現類,看看調試程式是否可以識別,並顯示 7 */ 8 public class MyList extends ArrayList { 9 10 }
1 package com.rdpaas.debugger.test.utils; 2 3 import java.util.HashMap; 4 5 /** 6 * 故意定義一個map的實現類,看看調試程式是否可以識別,並顯示 7 */ 8 public class MyMap extends HashMap { 9 10 }
1 package com.rdpaas.debugger.test; 2 3 import org.springframework.boot.SpringApplication; 4 import org.springframework.boot.autoconfigure.SpringBootApplication; 5 6 /** 7 * @author rongdi 8 * @date 2021/1/24 9 */ 10 @SpringBootApplication 11 public class RunTestApplication { 12 13 public static void main(String[] args) { 14 SpringApplication.run(RunTestApplication.class,args); 15 } 16 }
我們先使用調試介面給TestController類29行打上斷點

斷點介面處於阻塞狀態,然後請求打了斷點的服務

這時候被調試的介面處於阻塞狀態了,然後再看斷點介面已經返回了

最後我們結束調試

依賴如下
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <dependency> <groupId>jdk.tools</groupId> <artifactId>jdk.tools</artifactId> <version>1.8</version> <scope>system</scope> <systemPath>${JAVA_HOME}\lib/tools.jar</systemPath> </dependency> </dependencies>
好了,看到如上效果感興趣的應該可以堅持看下去了。遠程調試不統一同JVM的本地調試,JDI遠程調試返回的對象全部是鏡像對象,哪怕是最簡單的一般數據類型,這就為處理數據都提升了很大的難度,特別是集合,映射那些,後面再說。
首先我們需要先準備一個被調試的程式,不管是web服務或者是本地的程式都可以,但是一定要添加如下啟動參數,目的就是為了配置調試需要的各種參數
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5000
如上就是開放了一個埠為5000的socket埠用於遠程調試的連接埠,連接程式碼如下
/** * 連接指定主機的指定調試埠返回一個虛擬主機對象,以下屬於公式程式碼就不做解釋了 * @param hostname 待調試程式的主機地址 * @param port 調試程式開放的後門調試埠 * @return * @throws Exception */ private VirtualMachine connJVM(String hostname, Integer port) throws Exception { VirtualMachineManager vmm = Bootstrap.virtualMachineManager(); List<AttachingConnector> connectors = vmm.attachingConnectors(); SocketAttachingConnector sac = null; for(AttachingConnector ac:connectors) { if(ac instanceof SocketAttachingConnector) { sac = (SocketAttachingConnector) ac; } } if(sac == null) { throw new Exception("未找到SocketAttachingConnector連接器"); } Map<String, Connector.Argument> arguments = sac.defaultArguments(); arguments.get("hostname").setValue(hostname); arguments.get("port").setValue(String.valueOf(port)); return sac.attach(arguments); }
如上連接程式碼網上一大把,屬於公式程式碼了,好吧,我承認還是直接貼出程式碼,然後在程式碼里加上詳細的注釋說起來容易一些,這樣又貼程式碼又在外面解釋感覺很彆扭,下面直接貼上調試的所有程式碼:
/** * 當斷點到最後一行後,調用斷開連接結束調試 */ public DebugInfo disconnect() throws Exception { virtualMachine.dispose(); map.remove(tag); return getInfo(); }/** * 在指定類的指定行打上斷點 * @param className 類的全限定名 * @param line 斷點所在的有效行號(不要不講武德打在空白行上) * @throws Exception */ private void markBreakpoint(String className, Integer line) throws Exception { /** * 根據虛擬主機拿到一個事件請求管理器 */ EventRequestManager eventRequestManager = virtualMachine.eventRequestManager(); /** * 主要是為了添加當前斷點是把之前斷點事刪掉, */ if(eventRequest != null) { eventRequestManager.deleteEventRequest(eventRequest); } /** * 根據調試類的全限定名,拿到一個調試類的遠程引用類型,請注意這裡是遠程調試,在當前調試程式的jvm中不會 * 裝載有被調試類,所以這裡只能是得到一個包裝後的類型,至於為啥是個集合,是因為這個被調試類可能正在被多個 * 執行緒調用 */ List<ReferenceType> rts = virtualMachine.classesByName(className); if(rts == null || rts.isEmpty()) { throw new Exception("無法獲取有效的debug類"); } /** * 不要說我不講武德,正常的本地調試在多執行緒環境中也只能調試最先到達的那個執行緒的調用,所以這裡也是直接 * 獲取第一個執行緒調用,同樣只能可憐兮兮的獲取到一個Class的包裝類型,誰叫我們是遠程調試呢 */ ClassType classType = (ClassType) rts.get(0); /** * 根據行獲取位置對象,這裡為啥又是個集合,好吧我承認忽悠不過去了,我也不明白,誰叫這JDI是人家設計的呢 */ List<Location> locations = classType.locationsOfLine(line); if(locations == null || locations.isEmpty()) { throw new Exception("無法獲取有效的debug行"); } /** * 一如既往的獲取第一個位置資訊 */ Location location = locations.get(0); /** * 創建一個斷點並激活,這是公式程式碼,下面的EventRequest.SUSPEND_EVENT_THREAD表示斷點執行過程阻塞當前執行緒, * SUSPEND_ALL 表示阻塞所有執行緒。實際上創建並激活的事件請求會被放在一個時間隊列中 */ BreakpointRequest breakpointRequest = eventRequestManager.createBreakpointRequest(location); breakpointRequest.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD); breakpointRequest.enable(); /** * 當前斷點創建好了,趕緊釋放被調試程式,讓他有機會執行到當前斷點,如果不放行就會一直卡在當前斷點之前的其它斷點, * 沒機會到這裡了,這裡選擇在這裡放行而不是在執行完上一個斷點後馬上放行是因為我們的斷點調試的斷點請求並不是 * 剛開始調試就確定好的,而是執行到當前行後由前端判斷本行是否有斷點,然後請求到調試程式的,屬於動態添加斷點, * 如果上一個斷點執行完,馬上釋放那麼當前斷點可能都還沒請求就過去了。 */ if(eventsSet != null) { eventsSet.resume(); } } /** * 消費調試的事件請求,然後拿到當前執行的方法,參數,變數等資訊,也就是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; } try { /** * 獲取被調試類當前執行的棧幀,然後獲取當前執行的位置 */ StackFrame stackFrame = threadReference.frame(0); Location location = stackFrame.location(); /** * 無腦的封裝返回對象 */ 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; } /** * 費勁的轉換,一切都是因為調試類和被調試類不在一個JVM中,所以拿到的對象都只是一個包裝類,拿不到源對象 * @param value 待解析的值 * @param depth 當前深度編號 * @return * @throws Exception */ private Object parseValue(Value value,int depth) throws Exception { if(value instanceof StringReference || value instanceof IntegerValue || value instanceof BooleanValue || value instanceof ByteValue || value instanceof CharValue || value instanceof ShortValue || value instanceof LongValue || value instanceof FloatValue || value instanceof DoubleValue) { return parseCommonValue(value); } else if(value instanceof ObjectReference) { int localDepth = depth; ObjectReference obj = (ObjectReference) value; String type = obj.referenceType().name(); if("java.lang.Integer".equals(type) || "java.lang.Boolean".equals(type) || "java.lang.Float".equals(type) || "java.lang.Double".equals(type) || "java.lang.Long".equals(type) || "java.lang.Byte".equals(type) || "java.lang.Character".equals(type)) { Field f = obj.referenceType().fieldByName("value"); return parseCommonValue(obj.getValue(f)); } else if("java.util.Date".equals(type)) { Field field = obj.referenceType().fieldByName("fastTime"); Date date = new Date(Long.parseLong("" + obj.getValue(field))); return date; } else if(value instanceof ArrayReference) { ArrayReference ar = (ArrayReference) value; List<Value> values = ar.getValues(); List<Object> list = new ArrayList<>(); for(int i = 0;i < values.size();i++) { list.add(parseValue(values.get(i),depth)); } return list; /** * 個人感覺都已經有點不講武德了,實在沒有找到更優雅的方法了 */ } else if(isCollection(obj)) { Method toArrayMethod = obj.referenceType().methodsByName("toArray").get(0); value = obj.invokeMethod(threadReference, toArrayMethod, Collections.emptyList(), 0); return parseValue(value,++localDepth); } else if(isMap(obj)) { /** * 這裡是一個比較巧妙的利用遞歸方式,將map先轉成集合,然後再調用本方法轉成數組,然後就可以走到ArrayReference進行處理 */ Method entrySetMethod = obj.referenceType().methodsByName("entrySet").get(0); value = obj.invokeMethod(threadReference, entrySetMethod, Collections.emptyList(), 0); return parseValue(value,++localDepth); } else { Map<String,Object> map = new HashMap<>(); String className = obj.referenceType().name(); map.put("class",className); /** * 到了Object就不繼續了 */ if("java.lang.Object".equals(className)) { return map; } List<Field> fields = obj.referenceType().allFields(); for(int i = 0;fields != null && i < fields.size();i++) { localDepth = depth; /** * 這裡有個遞歸,萬一被調試類不講武德搞一個無限自循環的對象,比如Person類里有個成員變數p直接聲明的時候 * 就new一個Person,這樣這個Person對象的深度是無限的,為了防止記憶體溢出,限制深度不超過2,你要是不信邪, * 你改成5試試,就本例的例子,執行到最後一行後,繼續stepOver,可以給你返回上十萬行數據,呵呵 */ if(localDepth < 2) { Field f = fields.get(i); map.put(f.name(), parseValue(obj.getValue(f), ++localDepth)); } } return map; } } return null; } /** * 萬惡的窮舉,真的是很噁心,如果不轉直接放這個包裝的Value出去變成json後就拿不到真實的value值, * 別看列印的時候可以列印,還好這些鬼東西是有規律的,調試的時候試出來了一個,其餘都出來了 * @param value * @return */ private Object parseCommonValue(Value value) { if(value instanceof StringReference) { return ((StringReferenceImpl) value).value(); } else if(value instanceof IntegerValue) { return ((IntegerValueImpl) value).value(); } else if(value instanceof BooleanValue) { return ((BooleanValueImpl) value).value(); } else if(value instanceof ByteValue) { return ((ByteValueImpl) value).value(); } else if(value instanceof CharValue) { return ((CharValueImpl) value).value(); } else if(value instanceof ShortValue) { return ((ShortValueImpl) value).value(); } else if(value instanceof LongValue) { return ((LongValueImpl) value).value(); } else if(value instanceof FloatValue) { return ((FloatValueImpl) value).value(); } else if(value instanceof DoubleValue) { return ((DoubleValueImpl) value).value(); } else { return null; } } /** * 判斷是不是集合,經過了多輪的糾結,最開始嘗試使用java.util開頭,包含List的,如: * type.startsWith("java.util.") && ((type.indexOf("List") != -1) || (type.indexOf("Set") != -1)) * 結果發現太片面,不講武德都沒法形容了,如果是List的實現類就沒辦法了,只能通過這種方式了,畢竟找了很多api找不到直接判斷 * 這個調試的鏡像對象是否是集合的方法。請不要作死,明明不是集合,非要給自己的類定義一個toArray方法 */ private boolean isCollection(ObjectReference obj) throws ClassNotLoadedException { List<Method> toArrayMethods = obj.referenceType().methodsByName("toArray"); boolean flag = false; for(int i = 0;i < toArrayMethods.size();i++) { Method toArrayMethod = toArrayMethods.get(i); flag = (toArrayMethod.argumentTypes().size() == 0); if(flag) { break; } } return flag; } /** * 判斷是不是Map,經過了多輪的糾結,最開始嘗試使用java.util開頭,包含Map的,如: * (type.startsWith("java.util.") && (type.indexOf("Map") != -1) && !type.endsWith("$Node")) * 還是發現太片面,如果是Map的實現類就沒辦法了,只能通過這種判斷是否有不帶桉樹的entrySet方法的方式了,你自己實現 * 的類總不會明明不是一個map,你非要定義一個entrySet方法,這種作死的情況,我就不管了,畢竟找了很多api找不到 * 直接判斷這個調試的鏡像對象是否是map的方法。 */ private boolean isMap(ObjectReference obj) throws ClassNotLoadedException { List<Method> toArrayMethods = obj.referenceType().methodsByName("entrySet"); boolean flag = false; for(int i = 0;i < toArrayMethods.size();i++) { Method toArrayMethod = toArrayMethods.get(i); flag = (toArrayMethod.argumentTypes().size() == 0); if(flag) { break; } } return flag; }
如上程式碼,其實在我寫這份程式碼時的所有思路和糾結全部在注釋裡面了,個人感覺JDI提供的api是真的很難用,需要很強的耐心去斷點和一個個api去嘗試,而且由於沒找到tools.jar的源碼,更增加了使用的難度。
最後相關依賴如下
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> </dependency> <dependency> <groupId>jdk.tools</groupId> <artifactId>jdk.tools</artifactId> <version>1.8</version> <scope>system</scope> <systemPath>${JAVA_HOME}\lib/tools.jar</systemPath> </dependency> </dependencies>
一個類搞定所有,是我最喜歡的方式,有些人可能不認同,非要搞一些花里胡哨的設計模式,調用層次搞得很深,我想說,如果JVM沒有方法內聯(你寫了一堆方法編譯的時候直接給你把程式碼copy到一個方法里去執行)之類的優化,那市面上絕大多數程式碼沒有性能可言,要不然為啥尾遞歸優化是將遞歸優化成循環。每次調用一個方法都涉及到新建方法棧,保存本地變數,銷毀方法棧等繁瑣的過程,所以每次調用方法都是有代價的,作為開發者,我一直認為非必要不要優化結構,直來直去是最容易理解的結構,優化結構不能帶來性能的提升,優化演算法才是,性能最極限的程式碼往往都是最簡單的。
當然為了擴展性確實需要使用一些設計模式,但是那也是要有需要擴展的地方才需要用到,屁大點項目,屁大的個工具,剛開始就設計的那麼複雜,你是要擴展啥,又是遇到了啥擴展瓶頸。往往很多開源項目寫出來其實根本不是為了讓別人方便的看懂,隨便一個調用都搞個2位數的調用深度,這到底是是炫技還是故意增加技術壁壘,我要是寫個方法一直往裡面調用,調用20多個方法,你願意硬著頭皮看下去還是果斷放棄呢?有些大廠的程式碼要不是不看看不好找工作,是真的沒有勇氣看下去。有些開源軟體不是人家不想參與進去,是你程式碼足夠複雜,技術足夠牛,很少有人看得懂,呵呵。下一篇部落格繼續實現斷點調試的單步調試相關功能,感興趣可以關注同名公眾號,方便實時推送更新和獲取完整源碼。

