前言
這篇其實是對一年前的一篇文章的補坑。
@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檔案,我感覺我是暫時搞不出來的。多虧了有這麼多優秀的前輩,我們才能走得更遠。
大家如有問題,可加群討論。