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

三國夢迴發表於2020-05-29

前言

這篇其實是對一年前的一篇文章的補坑。

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

當時,就是在spring mvc應用裡定義一個api,然後api裡,進行如下定義:

    /**
     * 遠端debug,讀取引數中的class檔案的路徑,然後載入,並執行其中的方法
     */
    @RequestMapping("/remoteDebugByUploadFile.do")
    @ResponseBody
    public String remoteDebugByUploadFile(@RequestParam String className, @RequestParam String methodName, MultipartFile file)

大家看上面的註釋,就是讀取檔案流,這個檔案流裡包含了我們要遠端執行的程式碼;className和methodName,分別指定這個檔案的類名和debug方法的方法名。

如果大家看得一臉懵的話,也沒關係,下面我基於此次改版升級後的應用給大家舉個例子。

假設我有下面這樣一個controller。

    @Autowired
    private IRedisCacheService iRedisCacheService;

    /**
     * 快取獲取介面
     * @param cacheKey
     */
    @RequestMapping("getCache.do")
    public String getCache(@RequestParam String  cacheKey){
        String value = iRedisCacheService.getCache(cacheKey);
        System.out.println(value);

        return value;
    }

裡面就是呼叫了一個IRedisCacheService的getCache方法。

結果,上面這個api的結果不符預期,然後我們看看上面的這個getCache的實現。



    /**
     * desc:
     *
     * @author : xxx
     * creat_date: 2019/6/18 0018
     * creat_time: 10:17
     **/
    @Service
    @Slf4j
    public class IRedisCacheServiceImpl implements IRedisCacheService {
        Random random = new Random();
    
        @Override
        public String getCache(String cacheKey) {
            String target = null;
            // 1 
            String count = getCount(cacheKey);
            // ----------------------後面有複雜邏輯--------------------------
            if (Integer.parseInt(count) > 1){
                target = "abc";
            }else {
                // 一些業務邏輯,但是忘記給 target 賦值
                // .....
            }
    
            return target.trim();
        }
    
        @Override
        public String getCount(String cacheKey){
            // 假設是從redis 讀取快取,這裡簡單起見,假設value的值就是cacheKey
            return String.valueOf(random.nextInt(20));
        }
    }
    



這裡的1處,呼叫了另一個方法getCount,因為getCount沒有日誌,也沒有列印getCount的返回值。問題可能是getCount返回的不對,也可能是後續的邏輯,把這個返回值改了。現在要排查問題,怎麼辦呢?

本地除錯?麻煩。本地環境和測試環境也不一樣,本地能不能重現問題,都是個問題。

大家可以使用阿里出的arthas,但我們這裡採用另一種方法。

寫個除錯檔案:

package com.learn;

import com.remotedebug.service.IRedisCacheService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;

public class TempDebug {
    public static final Logger log = LoggerFactory.getLogger(TempDebug.class);
	
  	// 1
    @Autowired
    private IRedisCacheService bean;

	// 2
    public void debug() {
        String count = bean.getCount("-2");
        // 3
        log.info("result:{}", count);
    }

}
  • 1處,注入了一個bean,我們需要呼叫這個bean的getCount
  • 2處,我們定義了一個debug方法,裡面呼叫了bean.getCount("-2"),這裡的-2這個引數,我是隨便傳的,這個不重要。我們希望,把這個程式碼丟到伺服器上去執行,然後看3處列印出來的日誌,不就可以判斷,getCount這一步是否出錯了嗎?

所以,大家明白了我們要做的事情沒?

寫一個除錯檔案(檔案裡儘量只是檢視操作,如果要做那種對資料庫、快取進行修改的話,要慎重一點,程式碼寫穩一點),傳到服務端的api,api執行這段程式碼。然後,我們可以檢視服務端的日誌,來幫助我們排查問題。

效果展示:

api中大致的步驟

  • 編譯上傳來的debug用途的java檔案為class檔案,獲取其class檔案的位元組陣列
  • 定義一個類載入器,從我們第一步拿到的class檔案的位元組陣列中,載入為一個class
  • 對class進行反射,建立出物件
  • (可選)對物件中的field進行注入(如果field上定義了autowired註解)
  • 呼叫物件的指定方法,如前面的例子,就是呼叫debug方法

步驟1:編譯java檔案為class檔案

這篇文章,之所以等了這麼久,就是一年前,那時候只能上傳class檔案;當時就想過直接上傳java,服務端自動編譯,奈何技術問題沒搞定,所以後來就拖著了。

這次是怎麼搞定了編譯問題呢?差不多是直接拷貝了阿里的arthas程式碼中的相關的幾個檔案,只要有以下幾個步驟,具體請大家克隆原始碼檢視。

  • new 一個 com.taobao.arthas.compiler.DynamicCompiler

    DynamicCompiler dynamicCompiler = new DynamicCompiler(this.getClass().getClassLoader());
    

  • 新增要編譯的類的原始碼

    String javaSource;
    try {
      javaSource = IOUtils.toString(inputStream, Charset.defaultCharset());
    }
    dynamicCompiler.addSource(className, javaSource);
    
  • 編譯

    Map<String, byte[]> byteCodes = dynamicCompiler.buildByteCodes();
    

    這個返回的map,key就是類名,value就是class檔案的位元組碼陣列。

步驟2:定義一個類載入器,載入為Class物件

大家再仔細看看我們的debug程式碼:

    @Autowired
    private IRedisCacheService bean;


    public void debug() {
        String count = bean.getCount("-2");
        log.info("result:{}", count);
    }

這裡面,是用到了我們的應用中的類的,比如上面這個bean。這個bean,在spring boot裡,假設是由類載入器A載入的,那我們載入我們這段debug程式碼,應該怎麼載入呢?還是用類載入器A?

ok,沒問題。類載入器A,載入了我們的TempDebug這個類。那,假設我改動了一點程式碼:

    public void debug() {
      	//1 xxxxxx
      	....
        String count = bean.getCount("-2");
        log.info("result:{}", count);
    }

這裡1處,改了點程式碼,再次debug,那麼,類載入器A還能載入我們的類嗎?不能,因為已經快取了這個類了,不會再次載入。

所以,我們乾脆定義一個一次性的類載入器,每次用了就丟。我這裡的方法,就是定義一個類載入器A的child。所謂的child,就是符合雙親委派,這個類載入器,除了載入我們的bug類,其他的類,全部丟給parent。

    public UploadFileStreamClassLoader(InputStream inputStream, String className, ClassLoader parentWebappClassLoader) {
        super(parentWebappClassLoader);
        this.className = className;
        // 1
        this.inputStream = inputStream;
    }

    @Override
    protected Class<?> findClass(String name)  {
        // 2
        byte[] data = getData();
        // 4
        return defineClass(className,data,0,data.length);
    }

    private byte[] getData(){
        try {
            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
            byte[] bytes = new byte[2048];
            int num = 0;
            // 3
            while ((num = inputStream.read(bytes)) != -1){
                byteArrayOutputStream.write(bytes, 0,num);
            }

            return byteArrayOutputStream.toByteArray();
        } catch (Exception e) {
            log.error("read stream failed.{}",e);
            throw new RuntimeException(e);
        }
    }
  • 1處,把前面編譯好的class的位元組陣列流,傳進來
  • 2處,過載了findClass,所以,我們是符合雙親委派的,這裡,直接去getData,也就是獲取位元組流陣列
  • 3處,呼叫defineClass,生成Class物件。

上面類載入器好了,基本的程式碼就有了:

        /**
         * 新建一個classloader,該classloader的parent,為當前執行緒的classloader
         */
        InputStream inputStream = new ByteArrayInputStream(compiledClassByteArray);
        UploadFileStreamClassLoader myClassLoader = new UploadFileStreamClassLoader(inputStream, className, classloader);
        Class<?> myDebugClass = null;
        try {
            myDebugClass = myClassLoader.loadClass(className);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }

步驟3:反射class,生成物件

        /**
         * 新建物件
         */
        Object debugClassInstance;
        try {
            debugClassInstance = myDebugClass.newInstance();
        } catch (InstantiationException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }

步驟4:對autowired field,注入bean

我們的service中,實現了ApplicationContextAware介面,讓框架給我們注入了:


    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {

        this.applicationContext = applicationContext;
    }

獲取要注入的欄位

		/**
         * 檢視物件中的@autowired欄位,注入值
         */
        Field[] declaredFields = myDebugClass.getDeclaredFields();
        Set<Field> set = null;
        if (declaredFields != null) {
            set = Arrays.stream(declaredFields)
                    .filter(f -> f.isAnnotationPresent(Autowired.class))
                    .collect(Collectors.toSet());
        }

注入欄位

        /**
         * 注入欄位
         */
        try {
            log.info("start to inject fields set:{}",set);
            for (Field field : set) {
                Class<?> fieldClass = field.getType();
                Object bean = applicationContext.getBean(fieldClass);
                field.setAccessible(true);
                field.set(debugClassInstance,bean);
            }
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

步驟5:萬事俱備,只欠東風

我們這一步很簡單,呼叫就行了。

        try {
            myDebugClass.getMethod(methodName).invoke(debugClassInstance);
        } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
            throw new RuntimeException(e);
        }

		log.info("結束執行:{}中的方法:{}", className, methodName);

完整程式碼

https://gitee.com/ckl111/remotedebug

總結

感謝arthas,不然的話,編譯java為class檔案,我感覺我是暫時搞不出來的。多虧了有這麼多優秀的前輩,我們才能走得更遠。

大家如有問題,可加群討論。

相關文章