又是好長時間沒有寫部落格了,今天我們就來談一下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多個方法,你願意硬著頭皮看下去還是果斷放棄呢?有些大廠的程式碼要不是不看看不好找工作,是真的沒有勇氣看下去。有些開源軟體不是人家不想參與進去,是你程式碼足夠複雜,技術足夠牛,很少有人看得懂,呵呵。下一篇部落格繼續實現斷點除錯的單步除錯相關功能,感興趣可以關注同名公眾號,方便實時推送更新和獲取完整原始碼。