Java 字節碼引用檢測原理與實戰
一、字節碼與引用檢測
1.1 Java 字節碼
本章中的字節碼重點研究 Java 字節碼,Java 字節碼(Java bytecode)是 Java 虛擬機執行的一種指令格式。可以通過 javap -c -v xxx.class(Class 文件路徑) 命令來查看一個 Class 對應的字節碼文件,如下圖所示:
1.2 字節碼檢測
字節碼檢測本質就是對. java 或. kt 文件編譯後生成的 Class 文件進行相關的分析和檢測。在正式介紹字節碼分析在引用檢測上的原理與實戰前,先介紹下字節碼引用檢測的技術預研背景。
二、字節碼檢測技術的預研背景
整個預研背景需要先從筆者負責的 APP-- 內銷官網 APP 的軟件架構講起。
2.1 內銷官網 APP 軟件架構
內銷官網 APP 目前共 12 個子倉,子倉分別獨立編譯成 AAR 文件供 APP 工程使用,軟件架構圖如下圖所示:
APP 以下,上層淺藍色爲業務層,中間綠色爲組件層,最下層深藍色爲基礎框架層:
-
業務層:位於架構最上層,根據業務線劃分的業務模塊(比如商城、社區、服務),與產品業務相對應。
-
組件層:是 APP 的一些基礎功能(比如登錄、自升級)和業務公用的組件(比如分享、地址管理、視頻播放),提供一定的複用能力。
-
基礎框架層:通過跟業務完全無關的基礎組件(比如三方框架、自行封裝的通用能力),提供完全的複用能力。
2.2 內銷官網 APP 客戶端開發模式
-
官網 APP 目前主要分 3 條業務線,多業務版本並行開發是常態,所以模塊化非常必要。
-
官網 APP 模塊化的子倉均已 AAR 形式供 APP 使用,且存在上層 AAR 依賴下層 AAR 的情況。
-
官網 APP 模塊化分倉優化工作穿插在各業務版本中,各業務版本並行開發,底層倉庫難免有修改。
-
官網 APP 各業務版本並行開發時,一般只會新拉取當前版本需要修改代碼的倉庫,其他倉庫均繼續依賴老版本的 AAR。
2.3 類、方法、屬性引用錯誤導致的運行時崩潰
假設以下場景:
官網 APP5.0 版本開發過程中,由於 HardWare 倉沒有業務修改,所以繼續使用上個版本 4.9.0.0 的 HardWare(版本開發過程中一般只會重新拉取需要修改的倉庫,無需修改的倉庫會繼續使用老版本),但 Core 倉有代碼修改,所以拉取了新的 5.0 分支,並修改了相關代碼,刪除了 CoreUtils 類中的某個 fun1 方法,如下圖所示:
注:硬件檢測模塊 v4.9.0.0 版本 AAR 中用到了核心倉 CoreUtils.class 中的 fun1 方法,其他倉包括主 APP 工程均未使用到該 fun1 方法。
**請大家思考下,以上場景項目編譯是否會有問題? **
答:編譯無問題
APP 主倉依賴的是 4.9.0.0 版本的 HardWare 倉編譯後的 AAR 文件,這個 AAR 文件早在 4.9 版本就編好沒動,所以 HardWare 倉沒有編譯問題;
APP 主倉依賴的是 5.0.0.0 版本的 Core 倉,HardWare 依賴的是 4.9.0.0 版本的 Core 倉,最終編譯會取 Core 倉的高版本 5.0.0.0 版本參與 APP 工程編譯,App 倉沒有使用被刪除的 fun1 方法,也不存在編譯問題。
以上場景項目編譯完成後運行過程中是否會有問題?
答:有問題。
在 APP 運行到 HardWare 倉調用了 CoreUtils 類中 fun1 方法的情況下就會出現運行時崩潰:Method Not Found。
因爲最終參與 APP 工程編譯的是 5.0.0.0 版本的 Core 倉,該版本已經刪除了 fun1 方法,所以會出現運行時錯誤。
真實案例:
1)找不到方法
2)找不到類
所幸以上問題均在開發、測試階段發現並及時修復掉了,如果流到線上,就是運行到某功能時的必崩場景,將會非常嚴重。
如果你負責的 APP 的所有 module 均是源碼依賴,一般情況下如果存在引用問題,編譯器會進行提示,所以一般情況下無需擔心(除非依賴的底層 sdk 存在引用問題),但如果是類似官網這樣的軟件架構,則需要重點注意。
2.4 現狀分析、思考
本地測試過程中已出現過引用問題導致的運行時異常,這種運行時異常的檢測只靠人工是不夠的,必須要有自動化的檢測工具來進行檢查。傳統的 findBugs、Lint 等是代碼靜態檢測工具,是無法檢測出這種潛在的引用問題導致的運行時異常的,靜態代碼檢測無法解決此問題。所以自研自動化的檢測工具迫在眉睫!
三、字節碼檢測的解決方案
如果能在 APK 編譯期間,通過自動化工具對所有 JAR、AAR 包中每個類做一遍檢測,檢測其中調用的方法、屬性的使用是否存在引用問題,將檢測出疑似問題的地方在編譯時進行提示,有必要的情況下直接報錯終止編譯,並輸出錯誤日誌來提醒開發人員檢查,防止問題流入線上出現運行時異常。
原理:各子倉的 Java 類(或 Kotlin 類)在編譯成 AAR 或 JAR 後,AAR、JAR 中會有所有類的 Class 文件,我們實際上就是需要對編譯後生成的 Class 文件進行分析。
如何對 Class 文件進行字節碼分析?
這裏推薦使用 JavaAssist 或 ASM,我們知道 Android 編譯過程主要通過 Gradle 來控制的,要想分析 Class 文件字節碼,我們需要實現自己的 Gradle Transform,在 Transform 裏對 Class 字節碼進行分析,這裏我們直接做成 Gradle 插件。
在編譯期間自動分析 Class 字節碼是否存在方法引用、屬性引用、類引用找不到或者當前類無權訪問的問題,發現問題停止編譯,並輸出相關日誌,提醒開發人員分析,並支持對插件的配置。
到這裏,整個方案的主體框架就比較清晰了,如下圖所示:
3.1 方法和屬性引用檢測原理
方法和屬性引用問題的識別:
如何識別一個方法引用存在問題?
-
該方法被刪除,找不到相關方法名;
-
找不到方法簽名相同的方法,主要是指方法的入參數量、入參類型無法匹配;
-
方法是非 public 方法,當前類無權限訪問該方法。
如何識別一個屬性(字段)引用存在問題?
-
該屬性被刪除,找不到相關屬性、字段;
-
屬性是非 public 屬性,當前類無權限訪問該屬性。
權限修飾符說明:
方法和屬性引用的字節碼檢測:我們可以利用 JavaAssist、ASM 等支持字節碼操作的庫來實現對所有類中方法、屬性的掃描,並分析方法調用、屬性引用是否存在引用問題。
3.2 方法和屬性引用檢測實戰
以下代碼均已 Kotlin 編寫,實現 Gradle Plugin、Transform 具體過程省略,直接上檢測功能的代碼。方法、字段引用檢測:
// Gradle Plugin、自定義Transform的部分這裏不做贅述
// 方法引用檢測
// 遍歷每個類中的 每個方法 (包括構造方法 addBy Qihaoxin)
classObj.declaredBehaviors.forEach { ctMethod ->
//遍歷當前類中所有方法
ctMethod.instrument(object : ExprEditor() {
override fun edit(m: MethodCall?) {
super.edit(m)
//每個方法調用都會回調此方法,在此方法中進行檢測
//引用檢查功能
try {
//這裏不是每個方法都需要校驗的,過濾掉 我們不需要處理的 系統方法,第三方sdk方法 等等 只校驗我們自己的業務邏輯代碼
if (ctMethod.declaringClass.name.isNeedCheck()) {
return
}
if (m == null) {
throw Exception("MethodCall is null")
}
//不需要檢查的包名
if (m.className.isNotWarn() || classObj.name.isNotWarn()) {
return
}
//method找不到,底層會直接拋異常的,包括方法刪除、方法簽名不匹配的情況
m.method.instrument(ExprEditor())
//訪問權限檢測,該方法非public,且對當前調用這個方法的類是不可見的
if (!m.method.visibleFrom(classObj)) {
throw Exception("${m.method.name} 對 ${classObj.name} 這個類是不可見的")
}
} catch (e: Exception) {
e.message?.let {
errorInfo += "--方法分析 Exception Message: ${e.message} \n"
}
errorInfo += "--方法分析異常發生在 ${ctMethod.declaringClass.name} 這個類的${m?.lineNumber}行, ${ctMethod.name} 這個方法 \n"
errorInfo += "------------------------------------------------\n"
isError = true;
}
}
/**
* 成員變量調用的分析主要有:
* 變量直接被刪掉後找不到的問題
* private變量的只能定義該變量的類試用
* protected變量的可被類自己\子類\同包名的訪問
* */
override fun edit(f: FieldAccess?) {
super.edit(f)
try {
if (f == null) {
throw Exception("FieldAccess is null")
}
//不需要檢查的包名
if (f.className.isNotWarn() || classObj.name.isNotWarn()) {
return
}
//這裏不用判空,如果field找不到(這個屬性被刪掉了),底層會直接拋異常NotFoundException
val modifiers = f.field.modifiers
if (ctMethod.declaringClass.name == classObj.name) {
//只處理定義在本類中的方法,不然基類裏的方法也會被處理到--會出現本類實際沒訪問基類裏的private變量但報錯的問題
if (ctMethod.declaringClass.name == classObj.name) {
if (!f.field.visibleFrom(classObj)) {
throw Exception("${f.field.name} 對 ${classObj.name} 這個類是不可見的")
}
}
}
} catch (e: Exception) {
e.message?.let {
errorInfo += "--字段分析 Exception Message: ${e.message} \n"
}
errorInfo += "--字段分析異常發生在 ${classObj.name} 該類在 ${f?.lineNumber}行,使用 ${f?.fieldName} 這個屬性時\n"
errorInfo += "------------------------------------------------\n"
isError = true
}
}
})
}
在以上代碼實現中,是遍歷了所有的方法,對方法內的方法調用、字段訪問進行了檢測。那麼全局變量如何檢查呢?
class BillActivity {
...
private String mTest1 = CreateNewAddressActivity.TAG;
private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
...
}
例如以上代碼中,mTest1 屬性的值以及 mTest2 屬性的值應該如何做檢測?這個問題困擾筆者良久。在 JavaAssist、ASM 中均未能找到獲取屬性當前值的相關的 Api、也未能找到 Class 字節碼直接分析屬性值的相關思路以及資料。
在研究了 Class 字節碼相關知識,並做了大量的實驗,打了大量的 Log 後,解決思路才慢慢浮出水面。
我們先來看下 BillActivity 的一段字節碼:
在這裏我們找到了定義的 mTest1 這個全局變量,然後大家可以注意到,右邊 Method 中出現了一個 init 方法,實際上 Java 在編譯之後會在字節碼文件中生成 init 方法,稱之爲實例構造器,該實例構造器會將語句塊,變量初始化,調用父類的構造器等操作收斂到 init 方法中。那我們的 mTest2 這個全局變量呢?
搜索後發現 mTest2 實際上是在 static 代碼塊中,這裏似乎 mTest2 賦值並沒有被方法包裹,如下圖所示:
實際上通過查閱大量資料後得知,Java 在編譯之後會在字節碼文件中生成 clinit 方法,稱之爲類構造器,類構造器會將靜態語句塊,靜態變量初始化,收斂到 clinit 方法中。上圖通過 javap 查看 Class 字節碼中未顯示 clinit 方法是因爲 javap 未對此進行相關的適配展示而已。
通過實驗 Log 發現 mTest2 的初始化確實出現在 clinit 方法中,且在 ASMPlugin 的 ByteCode 中查看跟上圖相同的字節碼,展示爲帶有 clinit 方法標識的字節碼,如下圖所示:
研究到這裏,我們實際也就知道了 mTest1 和 mTest2 的賦值實際都發生在 init 和 clinit 方法中。所以我們前面遍歷類中所有方法來檢測方法和屬性的引用檢查是可以覆蓋到全局變量的。
問題到這裏似乎已經全部完美解決了,但我在全局變量的代碼這裏看了幾眼後,又發現了新的問題:
class BillActivity {
...
private String mTest1 = CreateNewAddressActivity.TAG;
private static String mTest2 = new CreateNewAddressActivity().getFormatProvinceInfo("a","b", "c");
...
}
我們前面只關心了 TAG 這個屬性和 getFormatProvinceInfo 這個方法的引用是否存在問題,但我們沒有對 CreateNewAddressActivity 這個類本身做引用檢查,假設這個類是 private 的,這裏依然會有問題。所以我們引用檢查不能忘記對類引用的檢查。
3.3 類引用檢查原理
如何識別一個類引用存在問題?
-
該類被刪除,找不到相關類;
-
類是非 public 的,當前類無權限訪問該類。
3.4 類引用檢測實戰
類引用檢查
//類的引用檢查
if (classObj.packageName.isNeedCheck()) {
classObj.refClasses?.toList()?.forEach { refClassName ->
try {
if (refClassName.toString().isNotWarn() || classObj.name.isNotWarn()) {
return@forEach
}
//該類被刪除,找不到相關類
val refClass = classPool.getCtClass(refClassName.toString())
?: throw NotFoundException("無法找到該類:$refClassName")
//權限檢測
//.....省略.....跟方法和屬性的權限檢測一樣,這裏不再贅述
} catch (e: Exception) {
e.message?.let {
errorInfo += "--類引用分析 Exception Message: ${e.message} \n"
}
errorInfo += "--類引用分析異常 在類:${classObj.name} 中引用了 $refClassName \n"
errorInfo += "------------------------------------------------\n"
isError = true
}
}
}
到這裏本次字節碼引用檢測的原理以及實戰就介紹完了。
3.5 解決方案的反思
在內銷官網的 buildSrc 中實現了引用檢測功能後,得知其他 APP 很多都已做了模塊化,聯想到其他 APP 可能也採用類似官網的模塊化架構,也會存在類似痛點,反思當前技術實現並不具備通用的接入能力,深感這件事其實並沒有做完,在解決自身 APP 痛點後需要橫向賦能其他 APP,解決大團隊所面臨的痛點,所有才有了後面的獨立 Gradle 插件。
四、獨立 Gradle 插件
如果需要在編譯期間進行引用檢測的 APP 模塊,歡迎大家接入我開發的這款字節碼引用檢測的 Gradle 插件。
4.1 獨立 Gradle 插件目標
1)獨立 Gradle 插件,方便所有 APP 接入;
2)支持常用的開發配置項,支持插件功能開關、異常跳過等配置;
3)對 Java、Kotlin 編譯後的字節碼進行引用檢查,能在 CI、Jenkins 上編譯 APK 包發現引用問題時,編譯報錯並輸出引用問題的具體信息供開發分析、解決。
4.2 插件功能
1)方法引用檢測;
2)屬性(字段)引用檢測;
3)類引用檢測;
4)插件支持常用配置,可開可關。
比如能檢測出 Class Not Found \Method Not Found 或者 Field Not Found 的問題。整個插件在編譯期間運行時間很短,以內銷官網 APP 爲例,該插件在 APP 編譯期間運行時間在 2.3 秒左右,速度很快,不必擔心會增加編譯耗時。
4.3 插件接入
在主工程根目錄 build.gradle 中添加依賴:
dependencies {
...
classpath "com.byteace.refercheck:byteace-refercheck:35-SNAPSHOT" //目前是試運行版本,版本還需迭代;歡迎大家體驗並提建議和問題,幫助不斷完善插件功能
}
在 APP 工程的 build.gradle 中使用插件並設置配置信息:
//官網自研的字節碼引用檢查插件
apply plugin: 'com.byteace.refercheck'
//官網自研的字節碼引用檢查插件-配置項
referCheckConfig {
enable true //是否打開引用檢查功能
strictMode true // 控制是否發現問題時停止構建,
check "com.abc.def" //需要檢查的類的包名,因爲工程中會使用很多sdk或者第三方庫我們一般不做檢查,只檢查我們需要關注的類的包名
notWarn "org.apache.http,com.core.videocompressor.VideoController" //人工檢查確認後不需要報錯的包名
}
4.4 插件配置項說明
Enable:
是否打開引用檢查功能,如果爲 false,則不進行引用檢查
StrictMode:
嚴苛模式開啓時, 發現引用異常直接中斷編譯(嚴苛模式關閉時, 只會將異常信息打在編譯過程的日誌中,發現引用問題不會終止編譯)。
建議:Jekins 或 CI 上打 Release 包時 build.gradle 中配置的 enable 和 strictMode 都設置爲 true。
Check:
需要檢測的包名,一般只配置檢查當前 APP 包名即可,如需對依賴的第三方 sdk 等做檢查,可根據需要進行配置。
NotWarn:
發現引用問題不報錯的白名單,在開發人員檢查插件報錯的問題並認定實際不會導致崩潰後,可將當前引用不到的類名配置在這裏,可跳過檢查。如 A 類引用不到 B 類中的某個方法,可將 B 類的類名配置在這裏,將不會報錯。
4.5 內銷官網 APP 中 NotWarn 配置項說明
內銷官網 APP 將 org.apache.http 以及 com.core.videocompressor.VideoController 加入到了不報錯白名單中。org.apache.http 實際用的是 Android 系統中的包,該包並沒有參與 APK 編譯,如果不加該配置項,則會報錯,但實際運行不會出錯。
com.core.videocompressor.VideoController 該項不加的話會報錯:FileProcessFactory 中引用不到 CompressProgressListener 類。排查下 FileProcessFactory 代碼,FileProcessFactory 類的 138 行 調用了 convertVideo 方法,最後一個 listner 參數傳的 null。
該類的字節碼 Class 文件如下,會自動對 converVideo 最後一個入參 null 進行強制類型轉換:
而這個 CompressProgressListener 並不是 public 的,是默認的 package。而且 FileProcessFactory 類與 CompressProgressListener 不在同一個 package 下,所以會報錯。但實際運行時並不會崩潰,所以需要將其類名加入到不報錯的白名單中。
如果在插件使用過程中遇到不應報錯的案例,可以通過白名單控制進行跳過,同時希望將案例反饋給我,我這邊對案例進行分析並對插件進行迭代更新。
五、總結
預研過程中由於字節碼知識較深,且網絡上類似字節碼插樁、進行代碼生成的的教程較多,但做字節碼分析的資料太少,所以需要熟悉字節碼知識並在實踐中慢慢實驗和摸索,細節也需慢慢打磨。
在預研過程中積極思考解決方案的通用性和可配置性,最終開發出通用的 Gradle 插件,積極推動其他模塊接入,藉此次寶貴的機會進行橫向技術賦能,爭取大團隊的成功。
目前已有兩個 APP 接入插件,插件會持續維護並迭代,等插件穩定後規劃集成到 CI、Jenkins 上。歡迎有需求的 APP 接入引用檢測的 Gradle 插件,希望能幫助到存在引用檢測痛點的 APP 和團隊。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/y9NSNMcvxpyccTqPM72y-g