:加載與激活機(jī)制全解)
那行報(bào)錯(cuò)我盯了很久failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p。在這之前我已經(jīng)數(shù)不清見(jiàn)過(guò)多少次和 plugins 有關(guān)的東西——桌面軟件的插件目錄、IDE 的擴(kuò)展市場(chǎng)、播放器里的插件源還有各種框架啟動(dòng)時(shí)冒出來(lái)的failed to load plugins提示。plugins 這個(gè)單詞幾乎和軟件一樣古老但每次它出問(wèn)題我發(fā)現(xiàn)自己還是會(huì)下意識(shí)先懷疑“插件沒(méi)裝好”而不是懷疑加載器本身。直到這次把web boot和harness兩個(gè)報(bào)錯(cuò)放到一起排查我才真正把一套插件系統(tǒng)的加載鏈路捋清楚。如果你也遇到過(guò)entries did not activate、failed to load plugins這類(lèi)提示或者只是想知道 IAR plugins、MusicFree plugins 這些到底在干什么這篇文章應(yīng)該能給你一個(gè)比“百度一下”更靠譜的答案。1. “2 entries did not activate”現(xiàn)場(chǎng)還原1.1 報(bào)錯(cuò)出現(xiàn)的項(xiàng)目背景我接手的那個(gè)項(xiàng)目是一個(gè)基于 Web 技術(shù)棧構(gòu)建的桌面殼程序。應(yīng)用啟動(dòng)的早期階段會(huì)有一段叫web boot的裝配流程用來(lái)加載一批第三方插件而harness則是框架層面對(duì)這個(gè)裝配階段的命名——你可以把它理解成一套夾具負(fù)責(zé)把各個(gè)插件按聲明好的順序裝到宿主環(huán)境里。項(xiàng)目跑起來(lái)后日志里先出現(xiàn)了一句harness failed to load plugins web boot: 1 entry did not activate huayu-yuan重啟之后又變成了failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p報(bào)錯(cuò)里出現(xiàn)linxin666/dsh-p和huayu-yuan這兩個(gè)名字說(shuō)明它們?cè)诓寮鍐卫锎_實(shí)被掃描到了但最終沒(méi)有被激活。問(wèn)題剛出現(xiàn)時(shí)我的第一反應(yīng)非常樸素這兩個(gè)插件包是不是沒(méi)裝好1.2 我的第一輪錯(cuò)誤操作我先后執(zhí)行了重裝依賴(lài)、清空本地緩存、回退到之前能跑的提交甚至換了一臺(tái)干凈的機(jī)器拉代碼重新跑。結(jié)果報(bào)錯(cuò)紋絲不動(dòng)。這輪操作浪費(fèi)了大概一個(gè)下午現(xiàn)在回頭看問(wèn)題就出在我對(duì)插件加載邏輯的認(rèn)知停留在“裝上就能用”的層面。后來(lái)我翻了一下框架源碼發(fā)現(xiàn)報(bào)錯(cuò)里的activate并不是一句隨口語(yǔ)氣詞而是插件生命周期中明確的一步。一個(gè)插件被掃描到、被解析成功甚至模塊文件已經(jīng)被執(zhí)行了都不代表它進(jìn)入了激活狀態(tài)。did not activate翻譯成人話是加載器認(rèn)可了這個(gè)插件條目的存在認(rèn)可了它的配置格式但在“真正把能力登記到宿主”這一步失敗了。1.3 “activate”不是啟動(dòng)是插件生命周期里的一道關(guān)卡很多剛接觸插件開(kāi)發(fā)的朋友會(huì)把“加載”和“激活”混為一談。實(shí)際上在成熟的插件體系里這兩個(gè)詞對(duì)應(yīng)完全不同的階段。加載階段是模塊層面的代碼被 import 進(jìn)來(lái)了變量被初始化了激活階段是能力層面的插件調(diào)用宿主提供的上下文把自己提供的服務(wù)、命令、事件處理器逐個(gè)注冊(cè)進(jìn)去??梢灶?lèi)比成一個(gè)外包公司進(jìn)場(chǎng)接項(xiàng)目收到用工名單掃描、核對(duì)營(yíng)業(yè)執(zhí)照與資質(zhì)解析、簽合同進(jìn)場(chǎng)加載、真正開(kāi)工干活激活?!? entries did not activate”的意思就是名單上有名字資質(zhì)查過(guò)了人也到現(xiàn)場(chǎng)了但當(dāng)天沒(méi)有開(kāi)工。所以排查方向從一開(kāi)始就不該是“包為什么沒(méi)裝上”而應(yīng)該是“這兩個(gè)插件為什么沒(méi)能完成激活那一步”。2. 插件系統(tǒng)的一整套握手流程掃描、解析、加載、激活2.1 四個(gè)階段分別做了什么幾乎所有插件系統(tǒng)無(wú)論形態(tài)怎么變底層都有這樣一個(gè)四個(gè)階段的流程掃描加載器根據(jù)配置文件、目錄約定或注冊(cè)表找到候選插件條目。解析讀取插件清單校驗(yàn)名稱(chēng)、入口路徑、依賴(lài)的宿主 API 版本是否滿(mǎn)足要求。加載把入口模塊 require 或 import 進(jìn)來(lái)模塊頂層代碼開(kāi)始執(zhí)行。激活調(diào)用插件的激活鉤子插件把自身能力真正注冊(cè)到宿主上下文。這四個(gè)階段里每個(gè)階段都可能失敗但失敗的表現(xiàn)形式不一樣。掃描失敗通常是“找不到插件”或0 entries found解析失敗會(huì)直接報(bào)“依賴(lài)版本不滿(mǎn)足”或“清單格式錯(cuò)誤”加載失敗能看到模塊級(jí)別的報(bào)錯(cuò)而激活失敗才會(huì)出現(xiàn)did not activate這種聽(tīng)起來(lái)很含蓄的提示。2.2 報(bào)錯(cuò)文案怎么讀我后來(lái)學(xué)會(huì)了一件事拿到報(bào)錯(cuò)先做信息拆解而不是急著搜索整句話。拿failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p舉例web boot說(shuō)明報(bào)錯(cuò)來(lái)自啟動(dòng)裝配階段不是運(yùn)行期動(dòng)態(tài)加載。2 entries說(shuō)明掃描階段確實(shí)發(fā)現(xiàn)了兩個(gè)插件條目掃描沒(méi)掛。linxin666/dsh-p是失敗條目中的具體來(lái)源至少有一個(gè)插件的身份信息被識(shí)別出來(lái)了。did not activate說(shuō)明問(wèn)題發(fā)生在最后一步前面的解析、加載至少對(duì)這兩個(gè)條目是完成的。如果報(bào)錯(cuò)里連包名都沒(méi)出現(xiàn)那問(wèn)題多半出在掃描或解析階段比如插件清單的路徑寫(xiě)錯(cuò)、文件名不對(duì)、目錄沒(méi)被掃描到。看到did not activate的時(shí)候請(qǐng)把注意力從“裝沒(méi)裝”轉(zhuǎn)移到“入口聲明對(duì)不對(duì)”“依賴(lài)版本滿(mǎn)不滿(mǎn)足”上百分之九十的問(wèn)題都分布在這兩個(gè)點(diǎn)。2.3 為什么“沒(méi)激活”不等于“沒(méi)加載”這一點(diǎn)值得單獨(dú)拿出來(lái)說(shuō)。在支持熱更新或動(dòng)態(tài)插拔的插件體系里插件被加載到內(nèi)存并不代表它立刻激活。加載器可能會(huì)把插件放在“已就緒但未啟用”的狀態(tài)等某個(gè)條件滿(mǎn)足后再調(diào)用激活鉤子。比如宿主 API 版本不滿(mǎn)足時(shí)會(huì)延遲激活或者插件聲明了依賴(lài)另一個(gè)尚未就緒的插件也會(huì)被掛起。我這次遇到的情況就屬于“掛起后的集體失敗”宿主在解析階段沒(méi)有把共享依賴(lài)注入到某些條目上導(dǎo)致兩個(gè)插件都進(jìn)入了等待狀態(tài)最終在超時(shí)后統(tǒng)一報(bào)did not activate。這就解釋了為什么第一次報(bào) 1 個(gè)、第二次報(bào) 2 個(gè)——第一次是其中一個(gè)先觸發(fā)超時(shí)第二次重啟后兩個(gè)都被判定為無(wú)法激活。所以看到復(fù)數(shù)報(bào)錯(cuò)時(shí)別急著認(rèn)定所有插件都?jí)牧讼葯z查它們之間的共享依賴(lài)和注入順序。3. IDE插件、播放器插件與啟動(dòng)裝配插件三種典型的 plugins 形態(tài)3.1 IAR 這類(lèi) IDE 插件擴(kuò)展的是“開(kāi)發(fā)流程”有朋友在熱搜里問(wèn)iar plugins 是干什么的其實(shí)這個(gè)問(wèn)題可以推廣到所有 IDE 類(lèi)插件。IAR Embedded Workbench 是嵌入式開(kāi)發(fā)里非常常見(jiàn)的 IDE它的插件體系主要圍繞編譯、調(diào)試、代碼分析、工程生成這些開(kāi)發(fā)流程來(lái)做擴(kuò)展。你可以通過(guò)插件接入自定義的編譯規(guī)則、給調(diào)試器增加外設(shè)觀察窗口、批量生成工程模板甚至把內(nèi)部構(gòu)建流程接到自己的代碼生成器上。這類(lèi)插件的特點(diǎn)是靜態(tài)安裝、權(quán)限大、對(duì) IDE 版本的依賴(lài)非常強(qiáng)。IDE 升級(jí)一個(gè)主版本插件接口可能就不兼容了于是啟動(dòng)時(shí)表現(xiàn)為failed to load plugin。如果你用的是這類(lèi)插件排查方向通常是“插件的目標(biāo) IDE 版本”和“安裝路徑權(quán)限”而不是插件內(nèi)部的邏輯。3.2 MusicFree 這類(lèi)播放器插件注入的是“內(nèi)容能力”MusicFree 是另一種典型。它是一個(gè)開(kāi)源音樂(lè)播放器核心只負(fù)責(zé)播放、隊(duì)列管理、界面渲染這些基礎(chǔ)能力內(nèi)容源相關(guān)的能力全部通過(guò)插件按需接入。播放器與插件之間是一個(gè)很薄的接口協(xié)議插件負(fù)責(zé)提供可播放的內(nèi)容條目播放器負(fù)責(zé)消費(fèi)和播放。這種架構(gòu)的好處是主程序體積小、迭代頻率低第三方可以獨(dú)立開(kāi)發(fā)源插件不需要等主程序發(fā)版。普通人搜索musicfree plugins大部分是遇到“插件從哪來(lái)”“裝完為什么沒(méi)生效”之類(lèi)的問(wèn)題。這類(lèi)問(wèn)題大多不是播放器壞了而是插件協(xié)議版本和播放器當(dāng)前版本對(duì)不上。插件開(kāi)發(fā)者在舊協(xié)議上寫(xiě)的插件到了新版本播放器里輕則部分功能失效重則直接不被識(shí)別表現(xiàn)就是插件列表里能看到條目但加載時(shí)沒(méi)有任何實(shí)際數(shù)據(jù)返回。這類(lèi)排查的要點(diǎn)是核對(duì)協(xié)議版本號(hào)以及插件是否聲明了自己依賴(lài)的播放器最低版本。3.3 Web Boot/Harness 這類(lèi)啟動(dòng)期插件拼的是裝配時(shí)序回到我手頭的項(xiàng)目。web boot 是宿主應(yīng)用在啟動(dòng)早期執(zhí)行的一段裝配邏輯而 harness 是負(fù)責(zé)驅(qū)動(dòng)這段裝配的“夾具”。這類(lèi)插件沒(méi)有圖形化管理界面配置全部寫(xiě)在文件里報(bào)錯(cuò)只能靠日志。它的核心難點(diǎn)是時(shí)序插件 A 還沒(méi)激活插件 B 依賴(lài) A 提供的服務(wù)于是 B 也跟著失敗。容易給人一種“全線崩潰”的錯(cuò)覺(jué)。排查這類(lèi)插件體系最忌諱的就是同時(shí)懷疑所有插件。正確姿勢(shì)是先選一個(gè)最簡(jiǎn)單的插件單獨(dú)跑確認(rèn)加載器本身健康然后再恢復(fù)其他插件逐個(gè)疊加。這也是我下面要說(shuō)的五步定位法的由來(lái)。3.4 一張表看清三種插件體系的差異插件體系插件形態(tài)加載時(shí)機(jī)失敗常見(jiàn)原因典型報(bào)錯(cuò)關(guān)鍵字IDE 類(lèi)IAR 等靜態(tài)擴(kuò)展包隨 IDE 安裝啟動(dòng)時(shí)一次性加載IDE 版本不兼容、權(quán)限不足failed to load plugin播放器類(lèi)MusicFree 等第三方源插件動(dòng)態(tài)導(dǎo)入安裝或刷新時(shí)動(dòng)態(tài)接入?yún)f(xié)議版本不一致、數(shù)據(jù)格式變化plugin load failed啟動(dòng)裝配類(lèi)Web Boot/Harness配置文件聲明的條目應(yīng)用啟動(dòng)早期按序裝配入口簽名、依賴(lài)版本、共享依賴(lài)注入失敗entries did not activate4. 插件激活失敗的五步定位法從日志到最小復(fù)現(xiàn)4.1 分清報(bào)錯(cuò)發(fā)生在哪個(gè)階段拿到entries did not activate之后第一步是回顧完整的啟動(dòng)日志確定失敗到底發(fā)生在哪個(gè)階段。我這次的標(biāo)準(zhǔn)日志長(zhǎng)這樣[web-boot] scanning plugin entries... found 2 [web-boot] resolving linxin666/dsh-p ... ok [web-boot] resolving huayu-yuan ... ok [web-boot] loading linxin666/dsh-p ... ok [web-boot] activating linxin666/dsh-p ... failed: host API version mismatch (expected 2.0, got 1.4) [web-boot] 2 entries did not activate注意最后兩行activating ... failed意味著掃描、解析、加載三個(gè)階段全都通過(guò)了問(wèn)題就是激活。如果某個(gè)插件在 resolving 階段就失敗日志里會(huì)顯示unsupported host version或者missing peer dependency。這兩個(gè)分支的修復(fù)方式完全不同前者改插件入口后者改插件清單里的版本聲明。4.2 寫(xiě)一個(gè)最小插件隔離宿主問(wèn)題隔離宿主和插件是排查這類(lèi)問(wèn)題最快的方法。我會(huì)在插件目錄里臨時(shí)放一個(gè)最小插件入口函數(shù)只做一件事export default async function activate(context) { console.log([minimal-plugin] activated, host version:, context.hostVersion); }如果最小插件能正常激活說(shuō)明宿主加載器本身沒(méi)壞問(wèn)題在業(yè)務(wù)插件側(cè)。如果最小插件也報(bào)did not activate那就要回頭檢查宿主側(cè)的加載器配置、版本常量、或者裝配階段的依賴(lài)注入邏輯。這一步能把排查范圍砍掉一半。4.3 對(duì)照 API 版本與入口簽名激活失敗最常見(jiàn)的具體原因就兩個(gè)依賴(lài)版本不匹配以及入口簽名不一致。版本問(wèn)題指的是插件清單里聲明了它需要宿主 API 的某個(gè)版本范圍而宿主實(shí)際提供的版本不在范圍內(nèi)。比如{ name: linxin666/dsh-p, version: 1.2.0, entry: ./dist/index.js, hostApi: { version: 2.0.0 3.0.0 } }一旦宿主是 1.4加載器就會(huì)直接把激活請(qǐng)求攔截掉。入口簽名問(wèn)題則是另一種情況宿主用默認(rèn)導(dǎo)出調(diào)用激活鉤子插件卻用了命名導(dǎo)出或者宿主傳入的是context對(duì)象插件卻把參數(shù)寫(xiě)成了可選參數(shù)并在內(nèi)部忽略。這些細(xì)節(jié)在獨(dú)立測(cè)試時(shí)根本不會(huì)暴露進(jìn)入宿主環(huán)境后才會(huì)被激活器嚴(yán)格校驗(yàn)。4.4 逐條禁用插件排查“連坐”回到那次的“2 entries did not activate”。我分別做了兩組實(shí)驗(yàn)只啟用linxin666/dsh-p禁用另一個(gè)以及反過(guò)來(lái)。結(jié)果兩個(gè)插件單獨(dú)跑都能通過(guò)解析但都掛在同一個(gè)地方——宿主沒(méi)有把共享依賴(lài)注入到插件的激活上下文里。單獨(dú)看任何一個(gè)插件的報(bào)錯(cuò)都不完整只有把兩個(gè)插件同時(shí)啟用才會(huì)暴露它們共同依賴(lài)的那個(gè)服務(wù)沒(méi)被初始化。這也是為什么我不建議在排查初期就“信任”報(bào)錯(cuò)里點(diǎn)名的每一個(gè)插件。復(fù)數(shù)的失敗原因可能是共因而不是每個(gè)插件各自獨(dú)立地壞了。逐條禁用、逐個(gè)疊加是驗(yàn)證這個(gè)判斷最直接的方式。4.5 修復(fù)與回歸讓日志先于激活代碼找到根因后修復(fù)動(dòng)作本身不難把插件的版本聲明從2.0.0放寬到與宿主匹配的范圍同時(shí)在插件的入口函數(shù)往外挪一行調(diào)試日志確保日志輸出先于任何業(yè)務(wù)邏輯。這樣萬(wàn)一以后又激活失敗日志里至少能看到插件被調(diào)用了而不是一片寂靜。修復(fù)完的驗(yàn)證日志應(yīng)該是這樣[web-boot] activating linxin666/dsh-p ... ok [web-boot] activating huayu-yuan ... ok [web-boot] all 2 entries activated另外提醒一句這種回歸驗(yàn)證別只在本地做一次就完了插件系統(tǒng)最怕“靜態(tài)正常、動(dòng)態(tài)翻車(chē)”。把啟動(dòng)腳本跑兩遍、把熱重載觸發(fā)一次確認(rèn)沒(méi)有偶發(fā)性的時(shí)序問(wèn)題再合入。5. 這一輪折騰下來(lái)我給自己立的幾條插件規(guī)矩5.1 入口聲明是插件與宿主的合同不許有一字偏差我見(jiàn)過(guò)太多插件功能寫(xiě)得漂漂亮亮唯獨(dú)入口函數(shù)簽名和宿主文檔不一致。少一個(gè)參數(shù)、導(dǎo)出名拼錯(cuò)、該異步的寫(xiě)成同步激活階段直接靜默失敗?,F(xiàn)在我對(duì)插件入口的態(tài)度和對(duì)待合同一樣先對(duì)著宿主文檔逐字核對(duì)再用最小插件跑通一次空實(shí)現(xiàn)然后才敢寫(xiě)真正的業(yè)務(wù)邏輯。5.2 插件的副作用要管住插件在加載階段執(zhí)行的所有代碼都跑在宿主進(jìn)程里。全局變量、未清理的定時(shí)器、修改原型鏈這些操作輕則污染其他插件重則讓加載器直接判定激活失敗。盡量把副作用收斂在activate(context)的局部作用域里能不動(dòng)全局就不動(dòng)全局。插件之間互相干擾的問(wèn)題往往要到生產(chǎn)環(huán)境才爆發(fā)而那時(shí)候排查成本是最高的。5.3 版本范圍寧嚴(yán)勿松聲明插件依賴(lài)宿主 API 的版本范圍時(shí)我以前的習(xí)慣是隨便寫(xiě)個(gè)寬松的1.0.0覺(jué)得這樣兼容性好。后來(lái)宿主發(fā)版做了破壞性變更所有插件在激活階段集體罷工?,F(xiàn)在我都會(huì)老老實(shí)實(shí)寫(xiě)明確的最小版本和排除范圍并且在 CI 里跑一個(gè)宿主最新版本的冒煙用例。寧可在開(kāi)發(fā)期多暴露幾次不兼容也不要在發(fā)布后收到一條did not activate的報(bào)錯(cuò)。那次折騰完之后我把最小復(fù)現(xiàn)插件一直留在倉(cāng)庫(kù)里?,F(xiàn)在再看到和 plugins 相關(guān)的報(bào)錯(cuò)我會(huì)先把注意力放在加載器和插件之間的契約上而不是急著懷疑“插件壞了”。對(duì)于想弄清楚 plugins 到底是什么的朋友我的建議也很簡(jiǎn)單先別管那些花哨的插件市場(chǎng)找一個(gè)你控制得住的最小宿主親手寫(xiě)一個(gè)插件再親手讓它激活失敗一次這比看十篇文檔都管用。