一、前言
這篇算是類載入器的實戰第五篇,前面幾篇在這裡,後續會持續寫這方面的一些東西。
實戰分析Tomcat的類載入器結構(使用Eclipse MAT驗證)
- 從資料庫、redis取了些資料,做了一些運算後,沒拋異常,但是就是結果不對
- 拋了個空指標異常,但是看程式碼,感覺沒問題,是取出來就是空,還是中間什麼函式把它改壞了
- 發現導致一個bug的原因是用了JVM快取,但是怎麼清理呢?難道重啟?
- redis 資料不對,能不能悄咪咪重新拉一下
- 好想把某個全域性變數打出來看一下?好想執行一個資料庫查詢,看看他麼的結果對不對?
- 。。。
哎,程式設計師的世界,從來沒有容易二字。 說實話,我們這次要開的後門就是做上面這些事情的,我剛鼓搗出這個時,我感覺這個還挺shock,為啥大佬們不去弄呢,後來我偶然想到,在 周志明大佬的那本 《Java 虛擬機器:JVM高階特性與最佳實踐》書裡,提到過類似的解決思路。就在書的 9.3 節,如下圖,這裡就提到了類似的需求,就是要在不停服務情況下,動態執行程式碼,方案其實一直都有:將自己的除錯程式碼寫到JSP裡,丟到伺服器上,然後訪問該JSP。
我們要做的事情,其實有點類似JSP,比它好的地方在於:不用把檔案手動丟到伺服器上,直接上傳class就行了。 也正是因為這次的折騰,我才知道,JSP原來還是能做很多事情的。但是筆者在畢業時,JSP應用基本就很少了,大學學了點皮毛而已,工作後更是沒用到,但它的類載入器的思想還是值得我們學習的。
二、大體思路與展示
1、思路
我們的目標是,針對一個 spring mvc 開發的部署在tomcat 上的 war 包應用,不重啟的情況下,動態執行一些我們的除錯程式碼,除錯程式碼中,只要是原專案能用的東西,我們都可以用。具體的方式是,在專案中 增加一個Controller,該Controller 的介面,主要是接收客戶端傳過來的除錯類的 class 檔案,或者去指定的 url 載入除錯類的 class,然後用自定義類載入器載入該 class,new出物件,並執行我們指定的方法。
下面我先簡單介紹下演示專案:
應用是 spring MVC + spring(演示用,就沒有db層),內部有一個測試用的 Controller:
1 // TestController.java 2 3 package com.remotedebug.controller; 4 5 import com.remotedebug.service.IRedisCacheService; 6 import org.springframework.beans.factory.annotation.Autowired; 7 import org.springframework.web.bind.annotation.RequestMapping; 8 import org.springframework.web.bind.annotation.RequestParam; 9 import org.springframework.web.bind.annotation.RestController; 10 11 /** 12 * desc: 13 * 測試介面,模擬從redis中獲取快取。當然,實際場景下,看快取可以直接用工具的,這裡就是舉個栗子 14 * @author : caokunliang 15 * creat_date: 2019/6/18 0018 16 * creat_time: 10:13 17 **/ 18 @RestController 19 public class TestController { 20 21 @Autowired 22 private IRedisCacheService iRedisCacheService; 23 24 /** 25 * 快取獲取介面 26 * @param cacheKey 27 */ 28 @RequestMapping("getCache.do") 29 public String getCache(@RequestParam String cacheKey){ 30 String value = iRedisCacheService.getCache(cacheKey); 31 System.out.println(value); 32 33 return value; 34 } 35 }
1 // IRedisCacheServiceImpl.java 2 package com.remotedebug.service.impl; 3 4 import com.remotedebug.service.IRedisCacheService; 5 import lombok.extern.slf4j.Slf4j; 6 import org.springframework.stereotype.Service; 7 8 import java.util.List; 9 10 /** 11 * desc: 12 * 13 * @author : caokunliang 14 * creat_date: 2019/6/18 0018 15 * creat_time: 10:17 16 **/ 17 @Service 18 @Slf4j 19 public class IRedisCacheServiceImpl implements IRedisCacheService { 20 21 @Override 22 public String getCache(String cacheKey) { 23 String target = null; 24 // ----------------------前面有複雜邏輯-------------------------- 25 String count = getCount(cacheKey); 26 // ----------------------後面有複雜邏輯,包括對 count 進行修改-------------------------- 27 if (Integer.parseInt(count) > 1){ 28 target = "abc"; 29 }else { 30 // 一些業務邏輯,但是忘記給 target 賦值 31 // ..... 32 } 33 34 return target.trim(); 35 } 36 37 @Override 38 public String getCount(String cacheKey){ 39 // 假設是從redis 讀取快取,這裡簡單起見,假設value的值就是cacheKey 40 return cacheKey; 41 } 42 }
注意上面的實現類,getCache 方法,就是簡單地去呼叫了 getCount 方法,然後做了一些複雜計算,在 else 分支,我們沒給 target 賦值,所以 在 34 行呼叫 target.trim 時會拋NPE。我們這時候排查問題時,如果能夠呼叫 getCount 看到返回的值是多少,就好了!
知道了getCount 返回值,我們就可以接著看到底是返回的值有問題,還是是因為後面的邏輯有問題了。 常規情況下,我們是沒辦法的,只能肉眼看了,或者本地除錯,但本地除錯,取到的資料又不是真實環境的,很可能不能復現。
我們現在就可以寫一段下面這樣的程式碼,放到伺服器上執行,就可以將我們需要的資訊打出來了:
import com.remotedebug.service.IRedisCacheService; import com.remotedebug.utils.SpringContextUtils; import lombok.extern.slf4j.Slf4j; @Slf4j public class RemoteDebugTest { public void debug(){ IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class); String value = bean.getCount("user.count.userIdxxx"); log.info("value:{}", value ); } public static void main(String[] args) { new RemoteDebugTest().debug(); } }
ps:這裡的 SpringContextUtils 只是一個簡單的工具類,spring 容器會把自己賦值給 SpringContextUtils 中一個靜態變數,方便我們在一些不被spring 管理的bean中獲取 bean。
那要怎麼才能讓伺服器執行我們的 RemoteDebugTest 的 debug 方法呢,你可能想到了,我們再加一個 Controller 就行了:
1 package com.remotedebug.controller;
2
3 import com.remotedebug.utils.LocalFileSystemClassLoader;
4 import com.remotedebug.utils.MyReflectionUtils;
5 import com.remotedebug.utils.UploadFileStreamClassLoader;
6 import lombok.extern.slf4j.Slf4j;
7 import org.springframework.stereotype.Controller;
8 import org.springframework.web.bind.annotation.RequestMapping;
9 import org.springframework.web.bind.annotation.RequestParam;
10 import org.springframework.web.bind.annotation.ResponseBody;
11 import org.springframework.web.multipart.MultipartFile;
12
13 import java.io.InputStream;
14
15 /**
16 * desc:
17 * 原理:自定義類載入器,根據入參載入指定的除錯類,除錯類中需要引用webapp中的類,所以需要把webapp的類載入器作為parent傳給自定義類載入器。
18 * 這樣就可以執行 除錯類中的方法,除錯類中可以訪問 webapp中的類,所以通過 spring 容器的靜態引用來獲取spring中的bean,然後就可以執行很多業務方法了。
19 * 比如獲取系統的一些狀態、執行service/dao bean中的方法並列印結果(如果方法是get型別的操作,則可以獲取系統狀態,或者模擬取redis/mysql庫中的資料,如果
20 * 為update型別的service 方法,則可以用來改變系統狀態,在不用重啟的情況下,進行一定程度的熱修復。
21 * @author : caokunliang
22 * creat_date: 2018/10/19 0019
23 * creat_time: 14:02
24 **/
25 @Controller
26 @Slf4j
27 public class RemoteDebugController {
28
29
30 /**
31 * 遠端debug,讀取引數中的class檔案的路徑,然後載入,並執行其中的方法
32 */
33 @RequestMapping("/remoteDebug.do")
34 @ResponseBody
35 public String remoteDebug(@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName) throws Exception {
36 /**
37 * 獲取當前類載入器,當前類肯定是放在webapp的web-inf下的classes,這個類所以是由 webappclassloader 載入的,所以這裡獲取的就是這個
38 */
39 ClassLoader webappClassloader = this.getClass().getClassLoader();
40 log.info("webappClassloader:{}",webappClassloader);
41
42
43 /**
44 * 用自定義類載入器,載入引數中指定的filePath的class檔案,並執行其方法
45 */
46 log.info("開始執行:{}中的方法:{}",className,methodName);
47 LocalFileSystemClassLoader localFileSystemClassLoader = new LocalFileSystemClassLoader(filePath, className, webappClassloader);
48 Class<?> myDebugClass = localFileSystemClassLoader.loadClass(className);
49 MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
50
51 log.info("結束執行:{}中的方法:{}",className,methodName);
52
53 return "success";
54
55 }
56
57
58 /**
59 * 遠端debug,讀取引數中的class檔案的路徑,然後載入,並執行其中的方法
60 */
61 @RequestMapping("/remoteDebugByUploadFile.do")
62 @ResponseBody
63 public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
64 if (className == null || file == null || methodName == null) {
65 throw new RuntimeException("className,file,methodName must be set");
66 }
67
68 /**
69 * 獲取當前類載入器,當前類肯定是放在webapp的web-inf下的classes,這個類所以是由 webappclassloader 載入的,所以這裡獲取的就是這個
70 */
71 ClassLoader webappClassloader = this.getClass().getClassLoader();
72 log.info("webappClassloader:{}",webappClassloader);
73
74 /**
75 * 用自定義類載入器,載入引數中指定的class檔案,並執行其方法
76 */
77 log.info("開始執行:{}中的方法:{}",className,methodName);
78 InputStream inputStream = file.getInputStream();
79 UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
80 Class<?> myDebugClass = myClassLoader.loadClass(className);
81 MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
82 log.info("結束執行:{}中的方法:{}",className,methodName);
83
84
85 return "success";
86
87 }
88
89
90 /**
91 * 遠端debug,讀取引數中url指定的class檔案的路徑,然後載入,並執行其中的方法
92 */
93 @RequestMapping("/remoteDebugByURL.do")
94 @ResponseBody
95 public String remoteDebugByURL(@RequestParam String className,@RequestParam String url, @RequestParam String methodName) throws Exception {
96 if (className == null || url == null || methodName == null) {
97 throw new RuntimeException("className,url,methodName must be set");
98 }
99
100 /**
101 * 獲取當前類載入器,當前類肯定是放在webapp的web-inf下的classes,這個類所以是由 webappclassloader 載入的,所以這裡獲取的就是這個
102 */
103 ClassLoader webappClassloader = this.getClass().getClassLoader();
104 log.info("webappClassloader:{}",webappClassloader);
105
106 /**
107 * 用自定義類載入器,載入引數中指定的class檔案,並執行其方法
108 */
109 log.info("開始執行:{}中的方法:{}",className,methodName);
110 UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(url, className, webappClassloader);
111 Class<?> myDebugClass = myClassLoader.loadClass(className);
112 MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
113 log.info("結束執行:{}中的方法:{}",className,methodName);
114
115
116 return "success";
117 }
118 }
在這個 Controller 中,一共提供了三種方式,先說最直接的,就是通過上傳 class 檔案,這個很簡單,只要有一個介面工具(如 postman)就可以。 Controller 中會 用自定義類載入器,去載入 檔案流 代表的class,然後 new出物件,呼叫方法就行了。
2.效果展示
我的應用部署在 192.168.19.13上,Tomcat 埠為 8081,如下:
[root@localhost apache-tomcat-8.0.41]# ll webapps/
total 9336
drwxr-xr-x. 14 root root 4096 Jun 19 11:39 docs
drwxr-xr-x. 6 root root 4096 Jun 19 11:39 examples
drwxr-xr-x. 5 root root 4096 Jun 19 11:39 host-manager
drwxr-xr-x. 5 root root 4096 Jun 19 11:39 manager
drwxr-xr-x. 4 root root 4096 Jun 19 13:48 remotedebug
-rw-r--r--. 1 root root 9531510 Jun 19 13:47 remotedebug.war
drwxr-xr-x. 3 root root 4096 Jun 19 11:39 ROOT
我們在本地寫好一個測試檔案,(可以直接在 工程 裡面寫,這樣才方便引用工程的類,不然還要自己敲 import 路徑,那也太傻了),寫好後,右鍵 執行下 main,觸發編譯操作。
執行main,肯定會報錯,這是不用說的,但我們只需要 class 而已:
我們去 target 目錄下,找到編譯出來的 class,然後用 介面工具呼叫,如下:
下面我們看看執行結果:
然後我再改下測試類的debug方法:
public void debug(){
IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
String value = bean.getCount("123456789");
log.info("value:{}", value );
}
再次執行:
三、原始碼解析
程式碼我放在交友網站了,歡迎fork。
https://github.com/cctvckl/remotedebug
類結構如下:
我們重點分析 remoteDebugByUploadFile :
1 /**
2 * 遠端debug,讀取引數中的class檔案的路徑,然後載入,並執行其中的方法
3 */
4 @RequestMapping("/remoteDebugByUploadFile.do")
5 @ResponseBody
6 public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file) throws Exception {
7 if (className == null || file == null || methodName == null) {
8 throw new RuntimeException("className,file,methodName must be set");
9 }
10
11 /**
12 * 獲取當前類載入器,當前類肯定是放在webapp的web-inf下的classes,這個類所以是由 webappclassloader 載入的,所以這裡獲取的就是這個
13 */
14 ClassLoader webappClassloader = this.getClass().getClassLoader();
15 log.info("webappClassloader:{}",webappClassloader);
16
17 /**
18 * 用自定義類載入器,載入引數中指定的class檔案,並執行其方法
19 */
20 log.info("開始執行:{}中的方法:{}",className,methodName);
21 InputStream inputStream = file.getInputStream();
22 UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, webappClassloader);
23 Class<?> myDebugClass = myClassLoader.loadClass(className);
24 MyReflectionUtils.invokeMethodOfClass(myDebugClass, methodName);
25 log.info("結束執行:{}中的方法:{}",className,methodName);
26
27
28 return "success";
29
30 }
其中,14,15行,主要獲取當前的 webappclassloader 載入器,該載入器,通俗來講,就是載入應用目錄下的 web-inf/lib 和 web-inf/classes。 21 行,主要獲取檔案流; 22行,將流、要載入的class的類名、webappclassloader 作為引數,來生成 自定義的類載入器,其中 webappclassloader 將作為 我們自定義類載入器的 雙親載入器。 23行,用自定義類載入器載入我們的類; 24行,用載入類反射,生成物件,並執行 methodName指定的方法。
重點程式碼在 UploadFileStreamClassLoader,我們看一下:
1 package com.remotedebug.utils; 2 3 import lombok.extern.slf4j.Slf4j; 4 5 import java.io.ByteArrayOutputStream; 6 import java.io.IOException; 7 import java.io.InputStream; 8 import java.io.UnsupportedEncodingException; 9 import java.net.URL; 10 import java.net.URLConnection; 11 12 /** 13 * desc: 14 * 15 * @author : caokunliang 16 * creat_date: 2019/6/13 0013 17 * creat_time: 10:19 18 **/ 19 @Slf4j 20 public class UploadFileStreamClassLoader extends ClassLoader { 21 /** 22 * 要載入的class的類名 23 */ 24 private String className; 25 /** 26 * 要載入的除錯class的流,可以通過客戶端檔案上傳,也可以通過傳遞url來獲取 27 */ 28 private InputStream inputStream; 29 30 /** 31 * 32 * @param inputStream 要載入的class 的檔案流 33 * @param className 類名 34 * @param parentWebappClassLoader 父類載入器 35 */ 36 public UploadFileStreamClassLoader(InputStream inputStream, String className, ClassLoader parentWebappClassLoader) { 37 super(parentWebappClassLoader); 38 this.className = className; 39 this.inputStream = inputStream; 40 } 41 42 43 44 @Override 45 protected Class<?> findClass(String name) throws ClassNotFoundException { 46 byte[] data = getData(); 47 try { 48 String s = new String(data, "utf-8"); 49 // log.info("class content:{}",s); 50 51 } catch (UnsupportedEncodingException e) { 52 e.printStackTrace(); 53 } 54 return defineClass(className,data,0,data.length); 55 } 56 57 private byte[] getData(){ 58 try { 59 ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); 60 byte[] bytes = new byte[2048]; 61 int num = 0; 62 while ((num = inputStream.read(bytes)) != -1){ 63 byteArrayOutputStream.write(bytes, 0,num); 64 } 65 66 return byteArrayOutputStream.toByteArray(); 67 } catch (Exception e) { 68 log.error("read stream failed.{}",e); 69 throw new RuntimeException(e); 70 } 71 } 72 }
重點關注 46 行和 54行,46行主要是 從流中讀取位元組,轉為位元組陣列; 54行主要是將位元組陣列代表的 class 載入到虛擬機器中。另外,這裡我們只覆蓋了 findClass,是遵循雙親委派模型的,可以注意到,我們的測試類中,import了一些工程的類,比如:
1 import com.remotedebug.service.IRedisCacheService;
2 import com.remotedebug.utils.SpringContextUtils;
3 import lombok.extern.slf4j.Slf4j;
4
5
6 @Slf4j
7 public class RemoteDebugTest {
8 public void debug(){
9 IRedisCacheService bean = SpringContextUtils.getBean(IRedisCacheService.class);
10 String value = bean.getCount("123456789");
11
12 log.info("value:{}", value );
13 }
14
15 }
在載入這些類時,我們自定義的類載入器會先委託給父類載入器載入,而且,我們自定義的類載入器自身也載入不了這些類。這裡有個關鍵點在於,我們為什麼要把 應用的當前類載入器傳入作為自定義載入器的父載入器呢,因為不同類載入器載入出來的 class,不能互轉,所以我們必須用 同一個類載入器例項。
四、使用說明
上面詳細講述了程式碼實現,這裡,彙總一下,我們這邊一共提供了三個介面:
-
remoteDebug.do 引數:@RequestParam String className ,@RequestParam String filePath, @RequestParam String methodName
該介面,主要是從本地檔案系統載入 filepath 指定的檔案,所以這個介面,需要先把class 檔案 上傳到 伺服器的某個路徑下。
-
remoteDebugByUploadFile.do 引數: @RequestParam String className, @RequestParam String methodName, MultipartFile file
該介面,可以直接上傳class檔案,要支援檔案上傳,需要進行以下配置:
<bean
class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
<property name="defaultEncoding" value="utf-8" />
<property name="maxUploadSize" value="10485760000" />
<property name="maxInMemorySize" value="40960" />
</bean>
同時,我這邊的環境不知道為啥,還需要修改web.xml(我們其他專案中都沒配這個,尷尬):
<servlet>
<servlet-name>DispatcherServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:/remotedebug-servlet.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<multipart-config>
<location></location>
<max-file-size>20848820</max-file-size>
<max-request-size>418018841</max-request-size>
<file-size-threshold>1048576</file-size-threshold>
</multipart-config>
</servlet>
-
remoteDebugByURL @RequestParam String className,@RequestParam String url, @RequestParam String methodName
該介面,可接受一個網路url,從url 去載入指定的class。
五、總結
一開始沒想鼓搗這個,只是後邊學了類載入器後,感覺是不是可以利用其來做點什麼,於是想到了這個。因為熱替換,是不可能在同一個類載入器例項中重複載入同一個類的,所以目前的熱替換都是連根拔起,將類載入器一起換掉。在 web 應用中,web-inf下的classes和lib 都由唯一的一個類載入器載入,要替換其中的單個類,暫時沒想到什麼辦法,但是我就感覺,可以用一個單獨的類載入器去載入指定的一個位置(不同於 web-inf的位置),然後每次不用這個類,就把載入器一起丟了就行。然後一開始不知道可行,直到做出來試了後,發現確實沒有問題,理論上也能解釋。 後來,我在和同事討論的過程中,感覺我做的這個東西,和JSP很像,然後又想到 在周志明的那本書裡,好像有過類似的案例,去看了下,果然如此。。。
哈哈,好吧,我還以為是很新鮮的東西,原來大佬早就玩過了,JSP更是出現了不知道多少年了,只是以前沒怎麼玩過JSP。
這個方法,也是適用於 spring boot 的,只是需要稍微修改一下,後續我再稍微改改,發個spring boot 的版本出來。類載入器這個東西還是挺有用,後續我會繼續更新這方面的文章,包括 SPI、osgi(皮毛),各類框架中 類載入器的應用等,也希望和大家多多交流,共同交流才能一起進步嘛。
原始碼再發一下,在這裡: https://github.com/cctvckl/remotedebug
不同於之前的文章,這次排版改了下,比如字型變大了,有些段落換了顏色,大家覺得比預設的好看還是不好看?