一起單測引起的專案載入失敗慘案

京东云开发者發表於2024-11-05

一、前言

最近在開發一個功能模組時,在功能自測階段,透過使用單測測試功能的完整性,在測試單測聯通性使用到靜態方法測試時,發現單測報錯,透過查閱解決方案發現需要對Javaassist包進行排包或者升版本處理。透過排包解決掉單測報錯,在部署專案時發現頻繁報bean注入失敗問題,最終定位發現是因為對Javaassist包排包引起的bean載入失敗。故而對Javaassist包相關知識進行學習整理文章如下。

單測相關報錯資訊如下:

Powermock - java.lang.IllegalStateException: Failed to transform class

解決單測報錯的文章連結:

https://stackoverflow.com/questions/32854688/powermock-java-lang-illegalstateexception-failed-to-transform-class

二、問題復現

1、前期準備

首先使用了Spring框架新建一個demo,並寫一個簡單測試類對問題進行復現。

UserService的定義:

public interface UserService {
    void save(User user);
}

UserServiceImpl的實現程式碼:

@Service
public class UserServiceImpl implements UserService {
    private UserDao userDao;

    @Autowired
    public UserServiceImpl(UserDao userDao) {
        this.userDao = userDao;
    }

    @Override
    public void save(User user) {
        userDao.save(user);
    }
}

這裡我們使用了Spring框架的@Service@Autowired註解,以便讓Spring框架自動裝配UserDao例項。

但是,在我們的POM檔案中,雖然我們新增了對Spring框架的依賴,但是並沒有新增Javaassist庫的依賴。而UserServiceImpl中確實使用了Javaassist庫來進行位元組碼操作, UserServiceImpl的具體實現程式碼:

public class UserServiceImpl implements UserService {
    // ...
    private static final String USER_CLASS_NAME = "com.example.User";

    private static final Class<?> USER_CLASS;

    static {
        try {
            USER_CLASS = Class.forName(USER_CLASS_NAME);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
    }

    public void save(User user) {
        try {
            // 建立一個ClassPool物件
            ClassPool cp = ClassPool.getDefault();

            // 從ClassPool中獲取一個CtClass物件
            CtClass ctClass = cp.get(USER_CLASS_NAME);

            // 獲取無參構造器
            CtConstructor ctConstructor = ctClass.getDeclaredConstructor(new CtClass[]{});

            // 獲取save方法
            CtMethod saveMethod = ctClass.getDeclaredMethod("save");

            // 生成程式碼
            saveMethod.insertBefore("{System.out.println(\"插入程式碼前\");}");
            saveMethod.insertAfter("{System.out.println(\"插入程式碼後\");}");

            // 生成新的位元組碼並裝載到記憶體
            Class<?> targetClass = ctClass.toClass();
            Object instance = targetClass.newInstance();

            // 呼叫save方法
            Method method = targetClass.getMethod("save", USER_CLASS);
            method.invoke(instance, user);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

在這段程式碼中,我們透過Javaassist庫生成了一個新的位元組碼,並使用反射機制將其例項化,並在呼叫save()方法前後插入了一些程式碼。但是,由於Javaassist庫缺失,導致專案在啟動過程中無法正確載入UserServiceImpl的例項,從而出現了下述錯誤資訊。

2、報錯資訊

在部署程式時發現,應用無法正常啟動,並出現如下錯誤資訊:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userService' defined in file [C:\workspace\project\target\classes\com\example\UserServiceImpl.class]: Instantiation of bean failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.example.UserService]: No default constructor found; nested exception is java.lang.NoSuchMethodException: com.example.UserService.<init>()

從錯誤資訊中我們可以看到,應用在建立UserService的例項時遇到了問題,無法例項化成功。

3、解決方案

為了修復這個問題,我們需要在POM檔案中加入對Javaassist庫的依賴:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.27.0-GA</version>
</dependency>

新增依賴後,重新編譯並部署應用程式即可正常執行

三、Javaassist包

1、什麼是Javaassist?

Javaassist 是由東京工業大學數學和電腦科學系的 Shigeru Chiba (千葉滋)教授創造的。Javaassist 作為實現動態位元組碼生成的一個開源類庫,極大地簡化了 Java 開發者對底層位元組碼操作的難度,讓開發者能夠更加輕鬆地在執行時動態生成類、修改類檔案來達到輕量級 AOP、ORM、基於代理的遠端方法呼叫等功能。

(Javaassist已加入了開放原始碼JBoss 應用伺服器專案,透過使用Javaassist對位元組碼操作為JBoss實現動態AOP框架。)

2、什麼是動態程式設計?

動態程式設計是相對於靜態程式設計而言的,平時我們討論比較多的就是靜態程式語言,例如Java,與動態程式語言,例如JavaScript。那二者有什麼明顯的區別呢?簡單的說就是在靜態程式設計中,型別檢查是在編譯時完成的,而動態程式設計中型別檢查是在執行時完成的。所謂動態程式設計就是繞過編譯過程在執行時進行操作的技術,在Java中有如下幾種方式:

反射

這個搞Java的應該比較熟悉,原理也就是透過在執行時獲得型別資訊然後做相應的操作。由於Java執行過程中是將型別載入虛擬機器中的,在執行時我們就可以動態獲取到所有型別的資訊。只能獲取卻不能修改型別資訊。

動態編譯

動態編譯是從Java 6開始支援的,主要是透過一個JavaCompiler介面來完成的。透過這種方式我們可以直接編譯一個已經存在的java檔案,也可以在記憶體中動態生成Java程式碼,動態編譯執行。

呼叫JavaScript引擎

早在Java 6就加入了對Script(JSR223)的支援。這是一個指令碼框架,提供了讓指令碼語言來訪問Java內部的方法。你可以在執行的時候找到指令碼引擎,然後呼叫這個引擎去執行指令碼。這個指令碼API允許你為指令碼語言提供Java支援。

動態生成位元組碼

這種技術透過操作Java位元組碼的方式在JVM中生成新類或者對已經載入的類動態新增元素。

3、動態程式設計解決什麼問題?

在靜態語言中引入動態特性,主要是為了解決一些使用場景的痛點。其實完全使用靜態程式設計也辦的到,只是付出的代價比較高,沒有動態程式設計來的優雅。例如依賴注入框架Spring使用了反射,而Dagger2 卻使用了程式碼生成的方式(APT)。

例如:

a: 在那些依賴關係需要動態確認的場景: b: 需要在執行時動態插入程式碼的場景,比如動態代理的實現。 c: 透過配置檔案來實現相關功能的場景

4、Javassit使用方法

javassistjboss的一個子專案,其主要的優點,在於簡單,而且快速。直接使用java編碼的形式,而不需要了解虛擬機器指令,就能動態改變類的結構,或者動態生成類。

操作java位元組碼的工具有兩個比較流行,一個是ASM,一個是Javassit

ASM直接操作位元組碼指令,執行效率高,要求使用者掌握Java類位元組碼檔案格式及指令,對使用者的要求比較高。
Javassit 提供了更高階的API,執行效率相對較,但無需掌握位元組碼指令的知識,對使用者要求較低。

應用層面來講一般使用建議優先選擇Javassit,如果後續發現Javassit 成為了整個應用的效率瓶頸的話可以再考慮ASM。當然如果開發的是一個基礎類庫,或者基礎平臺,還是直接使用ASM吧,相信從事這方面工作的開發者能力應該比較高。

Javassist中最為重要的是ClassPoolCtClass CtMethod 以及 CtField這幾個類。


ClassPool:一個基於HashMap實現的CtClass物件容器,其中是類名稱,是表示該類的CtClass物件。預設的ClassPool使用與底層JVM相同的類路徑,因此在某些情況下,可能需要向ClassPool新增類路徑或類位元組。
getDefault (): 返回預設的ClassPool ,單例模式,一般透過該方法建立我們的ClassPool
appendClassPath(ClassPath cp), insertClassPath(ClassPath cp) : 將一個ClassPath加到類搜尋路徑的末尾位置或插入到起始位置。通常透過該方法寫入額外的類搜尋路徑,以解決多個類載入器環境中找不到類問題;
importPackage(String packageName):匯入包;
makeClass(String classname):建立一個空類,沒有變數和方法,後序透過CtClass的函式進行新增;
get(String classname)、getCtClass(String classname) : 根據類路徑名獲取該類的CtClass物件,用於後續的編輯。
CtClass:表示一個類,這些CtClass物件可以從ClassPool獲得。
debugDump; String型別,如果生成.class檔案,儲存在這個目錄下。
setName(String name): 給類重新命名;
setSuperclass(CtClass clazz): 設定父類;
addField(CtField f, Initializer init): 新增欄位(屬性),初始值見CtField;
addMethod(CtMethod m): 新增方法(函式);
toBytecode(): 返回修改後的位元組碼。需要注意的是一旦呼叫該方法,則無法繼續修改CtClass
toClass(): 將修改後的CtClass載入至當前執行緒的上下文類載入器中,CtClasstoClass方法是透過呼叫本方法實現。需要注意的是一旦呼叫該方法,則無法繼續修改已經被載入的CtClass
writeFile(String directoryName): 根據CtClass生成 .class 檔案;
defrost(): 解凍類,用於使用了toclass()toBytecodewriteFile(),類已經被JVM載入,Javassist凍結CtClass後;
detach(): 避免記憶體溢位,從ClassPool中移除一些不需要的CtClass
CtMethods:表示類中的方法。
insertBefore(String src):在方法的起始位置插入程式碼;
insertAfter(String src):在方法的所有 return 語句前插入程式碼以確保語句能夠被執行,除非遇到exception;
insertAt(int lineNum, String src):在指定的位置插入程式碼;
addCatch(String src, CtClass exceptionType):將方法內語句作為try的程式碼塊,插入catch程式碼塊src;
setBody(String src):將方法的內容設定為要寫入的程式碼,當方法被 abstract修飾時,該修飾符被移除;
setModifiers(int mod):設定訪問級別,一般使用Modifier呼叫常量;
invoke(Object obj, Object... args):反射呼叫位元組碼生成類的方法。
CtFields :表示類中的欄位。
CtField(CtClass type, String name, CtClass declaring) :建構函式,新增欄位型別,名稱,所屬的類;
CtField.Initializer constant():CtClass使用addField時初始值的設定;
setModifiers(int mod):設定訪問級別,一般使用Modifier呼叫常量。
$開頭的特殊字元
具體含義
$0, $1, $2, … $0=this,$1表示方法的第一個引數,依次類推,如果方法是靜態的,則 $0 不可用
$args 方法引數陣列.它的型別為 Object[],$args[0]=1 , 1,1,args[1]=$2
$r 返回結果的型別,用於強制型別轉換
$w 包裝器型別,用於強制型別轉換,當返回值是包裝型別時,可以用此來強轉
$_ 返回值,一般在insertAfter中用到,用於得到原方法的返回值
$slg 引數型別陣列,$sig[0]表示第一個引數型別
$type 返回值型別,一般在insertAfter中用到,即$_的型別
$class $0或this的型別
$e 異常型別

5、常用的Java插樁工具有哪些?

Java 插樁工具是一種能夠修改 Java 位元組碼的工具,透過在應用程式執行時動態修改位元組碼來實現對程式的監控跟蹤除錯最佳化等功能。

工具位元組碼抽象級別具體描述
ASM、BCEL 低階 庫需要直接在位元組碼級別上進行操作。通常,它們提供大多數功能豐富的功能,但與其他位元組碼操作工具相比,它們的使用也最複雜。
Javaassist 中級 庫提供了位元組碼的某種抽象級別,並簡化了其修改。例如,代替修改位元組碼,可以使用類似於Java的語法進行更改,然後將其編譯為位元組碼,然後由使用的庫修改為原始位元組碼。通常,它們缺少修改後的程式碼驗證的功能-這意味著,錯誤可能在修改準備過程中被忽略,然後在執行時被發現。
AspectJ、CGLib 高階 庫使用高階指令進行操作,並且通常配備有用於語法驗證的工具集。不幸的是,從修改後的位元組碼進行的最高抽象化通常會導致某些功能的喪失,這些功能僅在直接修改位元組碼時可用。

四、總結

本文透過對由於Javaassist包缺失導致專案啟動過程中bean載入失敗的問題進行復現,並透過demo進行例項分析,解釋了因為缺失Javaassist庫導致的應用程式啟動失敗問題。並對Javaassist包相關知識進行介紹,後續會繼續對Javaassist相關知識進行學習補充。

建議大家在構建Maven專案時,仔細檢查POM檔案中的依賴,確保沒有漏掉任何必要的庫,以免因為遺漏而引起不必要的問題。

相關文章