高德JS依賴分析工程及關鍵原理

来源:https://www.cnblogs.com/amap_tech/archive/2019/11/29/11959435.html
-Advertisement-
Play Games

德 App 進行 Bundle 化後,由於業務的複雜性,Bundle 的數量非常多。而這帶來了一個新的問題——Bundle 之間的依賴關係錯綜複雜,需要進行管控,使 Bundle 之間的依賴保持在架構設計之下。 ...


一、背景

高德 App 進行 Bundle 化後,由於業務的複雜性,Bundle 的數量非常多。而這帶來了一個新的問題——Bundle 之間的依賴關係錯綜複雜,需要進行管控,使 Bundle 之間的依賴保持在架構設計之下。

並且,為了保證 Bundle 能實現獨立運轉,在業務持續迭代的過程中,需要逆向的依賴關係來迅速確定迭代的影響範圍。同時,對於切麵 API(即對容器提供的系統 API,類似瀏覽器中的 BOM API),也需要確定每個切麵 API 的影響範圍以及使用趨勢,來作為修改或下線某個 API 的依據。

以組件庫為例,由於組件會被若幹業務項目所使用,我們對組件的修改會影響這些業務項目。在計劃修改前,需要根據正向的依賴關係(業務依賴組件)來算出逆向的依賴關係——該組件被哪些地方所依賴,從而確定這個組件修改的影響範圍。

比文件更高的維度,是 Bundle 間的依賴。我們有業務 Bundle,也有公共 Bundle。公共 Bundle 也分為不同層級的 Bundle。

對於公用 Bundle,業務 Bundle 可以依賴它,但公用 Bundle 不能反過來依賴業務 Bundle;同樣的,底層的 Bundle 也禁止依賴上層封裝的 Bundle。我們需要通過依賴分析,來確保這些依賴按照上述規則進行設計。

二、實現關鍵步驟

實現 JS 依賴分析,整個實現過程大致如下圖所示:

下麵挑一些關鍵步驟來展開介紹。

使用 AST 提取依賴路徑

要做文件級別的依賴分析,就需要提取每個文件中的依賴路徑,提取依賴路徑有 2 個方法:

  • 使用正則表達式,優點是方便實現,缺點是難以剔除註釋,靈活度也受限;
  • 先進行詞法分析和語法分析,得到 AST(抽象語法樹)後,遍歷每個語法樹節點,此方案的優點是分析精確,缺點是實現起來要比純正則麻煩,如果對應語言沒有提供 parser API(如 Less),那就不好實現。

一般為了保證準確性,能用第 2 個方案的都會用第 2 個方案。

以類 JS(.js、.jsx、.ts、.tsx)文件為例,我們可以通過 TypeScript 提供的 API ts.createSourceFile 來對類 JS 文件進行詞法分析和語法分析,得到 AST:

const ast = ts.createSourceFile(
  abPath,
  content,
  ts.ScriptTarget.Latest,
  false,
  SCRIPT_KIND[path.extname(abPath)]
);

得到 AST 後,就可以開始遍歷 AST 找到所有我們需要的依賴路徑了。遍歷時,可以通過使用 typeScript 模塊提供的 ts.forEachChild 來遍歷一個語法樹節點的所有子節點,從而實現一個遍歷函數 walk:

function walk (node: ts.Node) {
  ts.forEachChild(node, walk); // 深度優先遍歷

  // 根據不同類型的語法樹節點,進行不同的處理
  // 目的是找到 import、require 和 require.resolve 中的路徑
  // 上面 3 種寫法分為兩類——import 聲明和函數調用表達式
  // 其中函數調用表達式又分為直接調用(require)和屬性調用(require.resolve)
  switch (node.kind) {
    // import 聲明處理
    case ts.SyntaxKind.ImportDeclaration:
      // 省略細節……
      break;

    // 函數調用表達式處理
    case ts.SyntaxKind.CallExpression:
      // 省略細節
      break;
  }
}

通過這種方式,我們就可以精確地找到類 JS 文件中所有直接引用的依賴文件了。

當然了,在 case 具體實現中,除了用戶顯式地寫依賴路徑的情況,用戶還有可能通過變數的方式動態地進行依賴載入,這種情況就需要進行基於上下文的語義分析,使得一些常量可以替換成字元串。

但並不是所有的動態依賴都有辦法提取到,比如如果這個動態依賴路徑是 Ajax 返回的,那就沒有辦法了。不過無需過度考慮這些情況,直接寫字元串字面量的方式已經能滿足絕大多數場景了,之後計劃通過流程管控+編譯器檢驗對這類寫法進行限制,同時在運行時進行收集報警,要求必需顯式引用,以 100% 確保對切麵 API 的引用是可以被靜態分析的。

建立文件地圖進行尋路

我們對於依賴路徑的寫法,有一套自己的規則:

引用類 JS 文件支持不寫擴展名;

引用本 Bundle 文件,可直接只寫文件名;

使用相對路徑;

引用公用 Bundle 文件,通過 @${bundleName}/${fileName} 的方式引用,fileName 同樣是直接只寫該 Bundle 內的文件名。

這些方式要比 CommonJS 或 ECMAScript Module 的規劃要稍複雜一些,尤其是「直接只寫文件名」這個規則。對於我們來說,需要找到這個文件對應的真實路徑,才能繼續進行依賴分析。

要實現這個,做法是先構建一個文件地圖,其數據結構為 { [fileName]: ‘relative/path/to/file’ } 。我使用了 glob 來得到整個 Bundle 目錄下的所有文件樹節點,篩選出所有文件節點,將文件名作為 key,相對於 Bundle 根目錄的路徑作為 value,生成文件地圖。在使用時,「直接只寫文件名」的情況就可以直接根據文件名以 O(1) 的時間複雜度找到對應的相對路徑。

此外,對於「引用類 JS 文件支持不寫擴展名」這個規則,需要遍歷每個可能的擴展名,對路徑進行補充後查找對應路徑,複雜度會高一些。

依賴是圖的關係,需先建節點後建關係

在最開始實現依賴關係時,由於作為前端的慣性思維,會認為「一個文件依賴另一些文件」是一個樹的關係,在數據結構上就會自然地使用類似文件樹中 children: Node[] 的方式——鏈式樹結構。而實際上,依賴是會出現這種情況的:

如果使用樹的方式來維護,那麼 utils.js 節點就會分別出現在 page.jsx 和 comp.jsx 的 children 中,出現冗餘數據,在實際項目中這種情況會非常多。

但如果僅僅是體積的問題,可能還沒那麼嚴重,頂多費點空間成本。但我們又會發現,文件依賴還會出現這種迴圈依賴情況:

寫 TypeScript 時在進行類型聲明的時候,就經常會有這樣迴圈依賴的情況。甚至兩個文件之間也會迴圈依賴。這是合理的寫法。

但是,這種寫法對於直接使用鏈式樹結構來說,如果創建鏈式樹的演算法是「在創建節點時,先創建子節點,待子節點創建返回後再完成自身的創建」的話,就不可能實現了,因為我們會發現,假如這樣寫就會出現無限依賴:

const fooTs = new Node({
  name: 'foo.ts',
  children: [
    new Node({ 
      name: 'bar.ts', 
      children: [
        new Node({
          name: 'baz.ts',
          children: [
            new Node({
              name: 'foo.ts', // 和最頂的 foo.ts 是同一個
              children: [...] // 無限迴圈……
            })
          ]
        })
      ]
    })
  ]
})

此問題的根本原因是,這個關係是圖的關係,而不是樹的關係,所以在創建這個數據結構時,不能使用「在創建節點時,先創建子節點,待子節點創建返回後再完成自身的創建」演算法,必須把思路切換回圖的思路——先創建節點,再創建關係。

採用這種做法後,就相當於使用的是圖的鄰接鏈表結構了。我們來看看換成「先創建節點,再創建關係」後的寫法:

// 先創建各節點,並且將 children 置為空數組
const fooTs = new Node({
  name: 'foo.ts',
  children: []
});

const barTs = new Node({
  name: 'bar.ts',
  children: []
});

const bazTs = new Node({
  name: 'baz.ts',
  children: []
});


// 然後再創建關係
fooTs.children.push(barTs);
barTs.children.push(bazTs);
bazTs.children.push(fooTs);

使用這種寫法,就可以完成圖的創建了。

但是,這種數據結構只能存在於記憶體當中,無法進行序列化,因為它是迴圈引用的。而無法進行序列化就意味著無法進行儲存或傳輸,只能在自己進程里玩這樣子,這顯然是不行的。

所以還需要對數據結構進行改造,將鄰接鏈表中的引用換成子指針表,也就是為每個節點添加一個索引,在 children 里使用索引來進行對應:

const graph = {
  nodes: [
    { id: 0, name: 'foo.ts', children: [1] },
    { id: 1, name: 'bar.ts', children: [2] },
    { id: 2, name: 'baz.ts', children: [0] }
  ]
}

這裡會有同學問:為什麼我們不直接用 nodes 的下標,而要再添加一個跟下標數字一樣的 id 欄位?原因很簡單,因為下標是依賴數組本身的順序的,如果一旦打亂了這個順序——比如使用 filter 過濾出一部分節點出來,那這些下標就會發生變化。而添加一個 id 欄位看起來有點冗餘,但卻為後面的演算法降低了很多複雜度,更加具備可擴展性。

用棧來解決迴圈引用(有環有向圖)的問題

當我們需要使用上面生成的這個依賴關係數據時,如果需要進行 DFS(深度遍歷)或 BFS(廣度遍歷)演算法進行遍歷,就會發現由於這個依賴關係是迴圈依賴的,所以這些遞歸遍歷演算法是會死迴圈的。要解決這個問題很簡單,有三個辦法:

  • 在已有圖上添加一個欄位來進行標記

每次進入遍歷一個新節點時,先檢查之前是否遍歷過。但這種做法會污染這個圖。

  • 創建一個新的同樣依賴關係的圖,在這個新圖中進行標記

這種做法雖然能實現,但比較麻煩,也浪費空間。

  • 使用棧來記錄遍歷路徑

我們創建一個數組作為棧,用以下規則執行:

每遍歷一個節點,就往棧里壓入新節點的索引(push);

每從一個節點中返回,則移除棧中的頂部索引(pop);

每次進入新節點前,先檢測這個索引值是否已經在棧中存在(使用 includes),若存在則回退。

這種方式適用於 DFS 演算法。

三、總結

依賴關係是源代碼的另一種表達方式,也是把控巨型項目質量極為有利的工具。我們可以利用依賴關係挖掘出無數的想象空間,比如無用文件查找、版本間變動測試範圍精確化等場景。若結合 Android、iOS、C++ 等底層依賴關係,就可以計算出更多的分析結果。

目前,依賴關係掃描工程是迭代式進行的,我們採用敏捷開發模式,從一些簡單、粗略的 Bundle 級依賴關係,逐漸精確化到文件級甚至標識符級,在落地的過程中根據不同的精確度來逐漸滿足對精度要求不同的需求,使得整個過程都可獲得不同程度的收益和反饋,驅使我們不斷持續迭代和優化。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • "1. 對日期的操作" "2. 對數字的操作" 1、對日期的操作 2、對數字的操作 ...
  • 下載並解壓MySQL 下載mysql-8.0.17-win64 \https://dev.mysql.com/downloads/mysql/8.0.html // 這裡提供的是8.0以上x64版本 解壓到任意位置,譬如: C:\mysql-8.0.17-winx64 (註意!! 此處的路徑一定要弄 ...
  • 為什麼要優化 系統的吞吐量瓶頸往往出現在資料庫的訪問速度上,即隨著應用程式的運行,資料庫的中的數據會越來越多,處理時間會相應變慢,且數據是存放在磁碟上的,讀寫速度無法和記憶體相比 如何優化 設計資料庫時:資料庫表、欄位的設計,存儲引擎 利用好MySQL自身提供的功能,如索引,語句寫法的調優 MySQL ...
  • 在使用之前,請確認項目已經整合了Redis一、加入依賴 org.springframework.session spring-session-data-redis 二、加入註解在加了@SpringBootApplication註解的類加上以下註解@EnableRedisHttpSession沒了。。... ...
  • Master Note: Troubleshooting guide for Automatic Undo Management (Doc ID 1579081.1) APPLIES TO: Oracle Database - Enterprise Edition - Version 9.2.0.1 ...
  • 一、redis文件夾下的可執行文件(文章尾部有示例代碼) 可執行文件 作用 redis-server 啟動redis redis-cli redis命令行工具 redis-benchmark 基準測試工具 redis-check-aof AOP持久化文件檢測工具和修複工具 redis-check-d ...
  • sqlserver日誌處理不當而造成的隱患 事故背景:一大早還在路上,群里陸續有人反饋系統一直報錯 “Unknown error 258”,後來查詢日誌發現錯誤日誌 第一反應是不是資料庫連接不夠用了?導致超時?但是通過sql查詢當時連接也只有40個左右,於是繼續排查問題,發現dbserver機器這段 ...
  • 重建全庫索引: exec sp_msforeachtable 'DBCC DBREINDEX(''?'')' 更新全庫統計信息: --更新全部統計信息 exec sp_updatestats 實例反饋: 在實際項目中,出現過導入數據的存儲過程執行需要大概46分鐘,而通過更新全庫統計信息之後,只需要6 ...
一周排行
    -Advertisement-
    Play Games
  • Dapr Outbox 是1.12中的功能。 本文只介紹Dapr Outbox 執行流程,Dapr Outbox基本用法請閱讀官方文檔 。本文中appID=order-processor,topic=orders 本文前提知識:熟悉Dapr狀態管理、Dapr發佈訂閱和Outbox 模式。 Outbo ...
  • 引言 在前幾章我們深度講解了單元測試和集成測試的基礎知識,這一章我們來講解一下代碼覆蓋率,代碼覆蓋率是單元測試運行的度量值,覆蓋率通常以百分比表示,用於衡量代碼被測試覆蓋的程度,幫助開發人員評估測試用例的質量和代碼的健壯性。常見的覆蓋率包括語句覆蓋率(Line Coverage)、分支覆蓋率(Bra ...
  • 前言 本文介紹瞭如何使用S7.NET庫實現對西門子PLC DB塊數據的讀寫,記錄了使用電腦模擬,模擬PLC,自至完成測試的詳細流程,並重點介紹了在這個過程中的易錯點,供參考。 用到的軟體: 1.Windows環境下鏈路層網路訪問的行業標準工具(WinPcap_4_1_3.exe)下載鏈接:http ...
  • 從依賴倒置原則(Dependency Inversion Principle, DIP)到控制反轉(Inversion of Control, IoC)再到依賴註入(Dependency Injection, DI)的演進過程,我們可以理解為一種逐步抽象和解耦的設計思想。這種思想在C#等面向對象的編 ...
  • 關於Python中的私有屬性和私有方法 Python對於類的成員沒有嚴格的訪問控制限制,這與其他面相對對象語言有區別。關於私有屬性和私有方法,有如下要點: 1、通常我們約定,兩個下劃線開頭的屬性是私有的(private)。其他為公共的(public); 2、類內部可以訪問私有屬性(方法); 3、類外 ...
  • C++ 訪問說明符 訪問說明符是 C++ 中控制類成員(屬性和方法)可訪問性的關鍵字。它們用於封裝類數據並保護其免受意外修改或濫用。 三種訪問說明符: public:允許從類外部的任何地方訪問成員。 private:僅允許在類內部訪問成員。 protected:允許在類內部及其派生類中訪問成員。 示 ...
  • 寫這個隨筆說一下C++的static_cast和dynamic_cast用在子類與父類的指針轉換時的一些事宜。首先,【static_cast,dynamic_cast】【父類指針,子類指針】,兩兩一組,共有4種組合:用 static_cast 父類轉子類、用 static_cast 子類轉父類、使用 ...
  • /******************************************************************************************************** * * * 設計雙向鏈表的介面 * * * * Copyright (c) 2023-2 ...
  • 相信接觸過spring做開發的小伙伴們一定使用過@ComponentScan註解 @ComponentScan("com.wangm.lifecycle") public class AppConfig { } @ComponentScan指定basePackage,將包下的類按照一定規則註冊成Be ...
  • 操作系統 :CentOS 7.6_x64 opensips版本: 2.4.9 python版本:2.7.5 python作為腳本語言,使用起來很方便,查了下opensips的文檔,支持使用python腳本寫邏輯代碼。今天整理下CentOS7環境下opensips2.4.9的python模塊筆記及使用 ...