@Java Web 程式設計師,我們一起給程式開個後門吧:讓你在保留現場,服務不重啟的情況下,執行我們的除錯程式碼

三國夢迴發表於2019-06-19

一、前言

 這篇算是類載入器的實戰第五篇,前面幾篇在這裡,後續會持續寫這方面的一些東西。

 

實戰分析Tomcat的類載入器結構(使用Eclipse MAT驗證)

還是Tomcat,關於類載入器的趣味實驗

  

進入正文,不知道你有沒有這樣的時候,線上上或者測試環境,報了個bug。這個 bug 可能是:
  • 從資料庫、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 }
View Code

 

在這個 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

不同於之前的文章,這次排版改了下,比如字型變大了,有些段落換了顏色,大家覺得比預設的好看還是不好看?

相關文章