深度剖析 VS Code JavaScript Debugger 功能及實現原理

語言: CN / TW / HK

調試(Debugging)作為軟件開發環境中無法缺少的部分,長期以來都作為評價一款 IDE 產品優劣的重要指標,VS Code 在 1.47 版本 中廢棄了舊版本的 Node Debug、Debugger For Chrome 等插件集,正式採用了全新的 JavaScript Debugger 插件,用於滿足所有 JavaScript 場景下的調試需求,不僅提供了豐富的調試能力,還為我們帶了了嶄新的 JavaScript Debug Terminal ,  Profiling  以及更好的斷點和源文件映射等能力。

本文將從 VSCode JavaScript  Debugger  的功能入手,從源碼角度分析其實現對應功能所使用的技術手段及優秀的代碼設計,讓大家對其中的功能及實現原理有大致理解。

同時,在 2.18 版本的 OpenSumi 框架中,我們也適配了最新的 JavaScript  Debugger 1.67.2 版本插件,大部分功能已經可以正常使用,歡迎大家升級體驗。

由於公眾號鏈接限制,文章中提到的詳細代碼均可在 http://github.com/microsoft/vscode-js-debug 倉庫中查看

VS Code JavaScript Debugger 依舊是基於 DAP 實現的一款 JavaScript 調試器。其支持了 Node.js, Chrome, Edge, WebView2, VS Code Extension 等研發場景調試。

DAP 是什麼?

瞭解調試相關功能的實現,不得不提的就是 VS Code 早期建設的 DAP (Debug Adapter Protocol)方案,其摒棄了 IDE 直接與調試器對接的方案,通過實現 DAP 的方式,將於調試器適配的邏輯,承接在 Adapter(調試適配器) 之中,從而達到多個實現了同一套 DAP 協議的工具可以複用彼此調試適配器的效果,如下圖所示:

而上面圖示的適配器部分,一般組成了 VS Code 中調試插件中調試能力實現的核心。

目前支持 DAP 協議的開發工具列表見:Implementations Tools supporting the DAP:http://microsoft.github.io/debug-adapter-protocol/implementors/tools/ (OpenSumi 也在列表之中 ~)

多種調試能力

如上面介紹的,VS Code 中,調試相關的能力都是基於 DAP 去實現的,忽略建立鏈接的部分,在 JavaScript Debugger 中,所有的調試請求入口都在  adapter/debugAdapter.ts#L78  中處理,部分代碼如下所示:

// 初始化 Debugger
this.dap.on('initialize', params => this._onInitialize(params));
// 設置斷點
this.dap.on('setBreakpoints', params => this._onSetBreakpoints(params));
// 設置異常斷點
this.dap.on('setExceptionBreakpoints', params => this.setExceptionBreakpoints(params));
// 配置初始化完成事件
this.dap.on('configurationDone', () => this.configurationDone());
// 請求資源
this.dap.on('loadedSources', () => this._onLoadedSources());

通過對 DAP 的實現,使得 JavaScript Debugger 可以先暫時忽略 Debug Adaptor 與不同調試器的適配邏輯,將調試抽象為一個個具體的請求及函數方法。

以設置斷點的 setBreakpoints 為例,JavaScript Debugger 將具體設置斷點的能力抽象與 adapter/breakpoints.ts 文件中,如下:

public async setBreakpoints(
params: Dap.SetBreakpointsParams,
ids: number[],
): Promise<Dap.SetBreakpointsResult> {
// 安裝代碼 SourceMap 文件
if (!this._sourceMapHandlerInstalled && this._thread && params.breakpoints?.length) {
await this._installSourceMapHandler(this._thread);
}

// ... 省略部分參數訂正及等待相關進程初始化的過程
// ... 省略合併已有的斷點邏輯,同時移除未與調試進程綁定的斷點

if (thread && result.new.length) {
// 為調試器添加斷點
this.ensureModuleEntryBreakpoint(thread, params.source);

// 這裏的 Promise.all 結構是為了確保設置斷點過程中不會因為用户的某次 disabled 操作而丟失準確性
// 相當於取了當前時刻有效的一份斷點列表
const currentList = getCurrent();
const promise = Promise.all(
result.new
.filter(this._enabledFilter)
.filter(bp => currentList?.includes(bp))
// 實際斷點設置邏輯
.map(b => b.enable(thread)),
);
// 添加斷點設置 promise 至 this._launchBlocker, 後續調試器依賴對 `launchBlocker` 方法來確保斷點已經處理完畢
this.addLaunchBlocker(Promise.race([delay(breakpointSetTimeout), promise]));
await promise;
}

// 返回斷點設置的 DAP 消息
const dapBreakpoints = await Promise.all(result.list.map(b => b.toDap()));
this._breakpointsStatisticsCalculator.registerBreakpoints(dapBreakpoints);

// 更新當前斷點狀態
delay(0).then(() => result.new.forEach(bp => bp.markSetCompleted()));
return { breakpoints: dapBreakpoints };
}

接下來可以看到 adapter/breakpoints/breakpointBase.ts#L162 中實現的 enable 方法,如下:

  public async enable(thread: Thread): Promise<void> {
if (this.isEnabled) {
return;
}

this.isEnabled = true;
const promises: Promise<void>[] = [this._setPredicted(thread)];
const source = this._manager._sourceContainer.source(this.source);
if (!source || !(source instanceof SourceFromMap)) {
promises.push(
// 當不存在資源或非 SourceMap 資源時
// 根據斷點位置、代碼偏移量計算最終斷點位置後在調試器文件路徑下斷點
this._setByPath(thread, uiToRawOffset(this.originalPosition, source?.runtimeScriptOffset)),
);
}

await Promise.all(promises);
...
}

根據資源類型進一步處理斷點資源路徑,核心代碼如下(詳細代碼可見:adapter/breakpoints/breakpointBase.ts#L429):

  protected async _setByPath(thread: Thread, lineColumn: LineColumn): Promise<void> {
const sourceByPath = this._manager._sourceContainer.source({ path: this.source.path });

// ... 忽略對已經映射到本地的資源的處理

if (this.source.path) {
const urlRegexp =
await this._manager._sourceContainer.sourcePathResolver.absolutePathToUrlRegexp(
this.source.path,
);
if (!urlRegexp) {
return;
}
// 通過正則表達式設置斷點
await this._setByUrlRegexp(thread, urlRegexp, lineColumn);
} else {
const source = this._manager._sourceContainer.source(this.source);
const url = source?.url;

if (!url) {
return;
}
// 直接通過路徑設置斷點
await this._setByUrl(thread, url, lineColumn);
if (this.source.path !== url && this.source.path !== undefined) {
await this._setByUrl(thread, absolutePathToFileUrl(this.source.path), lineColumn);
}
}

最終在進程中設置斷點信息,部分核心代碼如下(詳細代碼可見:adapter/breakpoints/breakpointBase.ts#L513 ):

  protected async _setByUrlRegexp(
thread: Thread,
urlRegex: string,
lineColumn: LineColumn,
): Promise<void> {
lineColumn = base1To0(lineColumn);

const previous = this.hasSetOnLocationByRegexp(urlRegex, lineColumn);
if (previous) {
if (previous.state === CdpReferenceState.Pending) {
await previous.done;
}

return;
}
// 設置斷點
return this._setAny(thread, {
urlRegex,
condition: this.getBreakCondition(),
...lineColumn,
});
}

在 node-debug/node-debug2 等插件的以往實現中,到這一步一般是通過向調試器發送具體 “設置斷點指令” 的消息,執行相應命令,如 node/nodeV8Protocol.ts#L463 中下面的代碼:

 private send(typ: NodeV8MessageType, message: NodeV8Message) : void {
message.type = typ;
message.seq = this._sequence++;
const json = JSON.stringify(message);
const data = 'Content-Length: ' + Buffer.byteLength(json, 'utf8') + '\r\n\r\n' + json;
if (this._writableStream) {
this._writableStream.write(data);
}
}

而在 JavaScript Debugger 中,會將所有這類消息都抽象為統一的 CDP (Chrome Devtools Protocol) , 通過這種方式,抹平所有 JS 調試場景下的差異性,讓其擁有對接所有 JavaScript 場景調試場景的能力,繼續以 “設置斷點” 這一流程為例,此時 JavaScript Debugger 不再是發送具體命令,而是通過 CDP 鏈接,發送一條設置斷點的消息,部分核心代碼如下(詳細代碼可見:adapter/breakpoints/breakpointBase.ts#L581 ):

const result = isSetByLocation(args)
? await thread.cdp().Debugger.setBreakpoint(args)
: await thread.cdp().Debugger.setBreakpointByUrl(args);

通過這層巧妙的 CDP 鏈接,可以將所有用户操作指令統一為一層抽象的結構處理,後面只需要根據不同的調試器類型,選擇性處理 CDP 消息即可,如圖所示:

通過這層結構設計,能讓 JavaScript Debugger 輕鬆兼容三種模式調試 Node Launch, Node Attach, Chrome Devtools Attach, 從而實現對全 JavaScript 場景的調試能力。

瞭解詳細的 CDP 協議,可以查看文檔 CDP (Chrome Devtools Protocol)  ,在調試領域,Chrome Devtools 擁有更加全面的場景及能力支持,部分能力,如 DOMSnapshot 並不能在 Node 場景下使用,因此在實現過程中也需要選擇性處理。

同時,通過這樣的改造,也讓運行於 VS Code 中的調試進程可以通過 Chrome Devtools 或其他支持 CDP 協議的調試工具進行鏈接調試,如運行 extension.js-debug.requestCDPProxy  命令獲取調試信息,如下圖所示:

在 Chrome Devtools 中可以拼接為 chrome-devtools://devtools/custom/inspector.html?ws=ws://127.0.0.1:53591/273c30144bc597afcbefa2058bfacc4b0160647e  的路徑直接進行調試。

JavaScript Debug Terminal

如果要評選 JavaScript Debugger 中最好用的功能,那麼我一定投票給 JavaScript Debug Terminal   這一功能。

JavaScript Debug Terminal  為用户提供了一種無需關注調試配置,只需在終端運行腳本即可快速進行調試的能力,如下所示(OpenSumi 中的運行效果):

眾所周知,Node.js 在調試模式下提供了兩種 flag 選項,一個是 --inspect , 另一個則是 --inspect-brk  ,兩者都可以讓 Node.js 程序以調試模式啟動,唯一區別即是 --inspect-brk 會在調試器未被 attach 前阻塞 Node.js 腳本的執行,這個特性在老版本的 Node Debug 插件中被廣泛使用,用於保障在調試執行前設置斷點等。

而在 JavaScript Debugger 中,採用了一個全新的腳本運行模式,讓 Node.js 的調試可以不再依賴 --inspect-brk ,  其原理即是向在 JavaScript Debug Terminal 中運行的腳本注入 NODE_OPTIONS 選項,如下所示:

在傳入 NODE_OPTIONS:'--require .../vscode-js-debug/out/src/targets/node/bootloader.bundle.js' 的環境變量後,Node.js 在腳本執行前便會提前先去加載  bootloader.bundle.js 內的文件內容,而後再執行腳本,這中間就提供了大量可操作性。

進一步看這個 targets/node/bootloader.ts#L31 文件,裏面寫了一段自執行代碼,在全局創建一個 $jsDebugIsRegistered  對象, 通過程序內部構造的  VSCODE_INSPECTOR_OPTIONS 對象直接與調試進程進行 IPC 通信,配置格式如下所示:

{
// 調試進程 IPC 通信地址
"inspectorIpc":"/var/folders/qh/r2tjb8vd1z3_qtlnxy47b4vh0000gn/T/node-cdp.33805-2.sock",
// 一些配置
"deferredMode":false,
"waitForDebugger":"",
"execPath":".../node",
"onlyEntrypoint":false,
"autoAttachMode":"always",
// 文件回調地址,如果存在,在調試進程中的打印的日誌將會寫入到該文件中
"fileCallback":"/var/folders/qh/r2tjb8vd1z3_qtlnxy47b4vh0000gn/T/node-debug-callback-d2db3d91a6f5ae91"
}

在獲取到 inspectorIpc 等配置後,即會嘗試通過讀文件的方式確認 inspector 進程 的連通性,偽代碼如下(詳細代碼可見:targets/node/bootloader.ts#L246):

fs.readdirSync(path.dirname(inspectorIpc)).includes(path.basename(inspectorIpc));

在確定 inspector 進程 的連通性後,接下來就可以使用 inspector 庫, 獲取 inspector.url() 後進行鏈接操作,部分代碼如下(詳細代碼見:targets/node/bootloader.ts#L111):

(() => {
...
// 當進程執行時傳入了 `--inspect` 時,inspector.url() 可以獲取到當前的調試地址,命令行情況需要額外處理
const info: IAutoAttachInfo = {
ipcAddress: env.inspectorIpc || '',
pid: String(process.pid),
telemetry,
scriptName: process.argv[1],
inspectorURL: inspector.url() as string,
waitForDebugger: true,
ownId,
openerId: env.openerId,
};

// 當需要立即啟動調試時,執行 watchdog 程序監聽進程創建
if (mode === Mode.Immediate) {
// 代碼見:http://github.com/microsoft/vscode-js-debug/blob/b056fbb86ef2e2e5aa99663ff18411c80bdac3c5/src/targets/node/bootloader.ts#L276
spawnWatchdog(env.execPath || process.execPath, info);
}
...
})();


function spawnWatchdog(execPath: string, watchdogInfo: IWatchdogInfo) {
const p = spawn(execPath, [watchdogPath], {
env: {
NODE_INSPECTOR_INFO: JSON.stringify(watchdogInfo),
NODE_SKIP_PLATFORM_CHECK: process.env.NODE_SKIP_PLATFORM_CHECK,
},
stdio: 'ignore',
detached: true,
});
p.unref();

return p;
}

接下來就是執行下面的代碼 targets/node/watchdog.ts,  部分代碼如下:

const info: IWatchdogInfo = JSON.parse(process.env.NODE_INSPECTOR_INFO!);

(async () => {
process.on('exit', () => {
logger.info(LogTag.Runtime, 'Process exiting');
logger.dispose();

if (info.pid && !info.dynamicAttach && (!wd || wd.isTargetAlive)) {
process.kill(Number(info.pid));
}
}
);

const wd = await WatchDog.attach(info);
wd.onEnd(() => process.exit());
}
)();

實際上這裏又用了一個子進程去處理 CDP 通信的鏈接,最終執行到如下位置代碼 targets/node/watchdogSpawn.ts#L122,部分代碼如下:

class WatchDog {
...
// 鏈接本地 IPC 通信地址,即前面從環境變量中獲取的 inspectorIpc
public static async attach(info: IWatchdogInfo) {
const pipe: net.Socket = await new Promise((resolve, reject) => {
const cnx: net.Socket = net.createConnection(info.ipcAddress, () => resolve(cnx));
cnx.on('error', reject);
});

const server = new RawPipeTransport(Logger.null, pipe);
return new WatchDog(info, server);
}

constructor(private readonly info: IWatchdogInfo, private readonly server: ITransport) {
this.listenToServer();
}


// 鏈接 Server 後,發送第一條 `Target.targetCreated` 通知調試進程已經可以開始調試
private listenToServer() {
const { server, targetInfo } = this;
server.send(JSON.stringify({ method: 'Target.targetCreated', params: { targetInfo } }));
server.onMessage(async ([data]) => {
// Fast-path to check if we might need to parse it:
if (
this.target &&
!data.includes(Method.AttachToTarget) &&
!data.includes(Method.DetachFromTarget)
) {
// 向 inspectorUrl 建立的鏈接發送消息
this.target.send(data);
return;
}
// 解析消息體
const result = await this.execute(data);
if (result) {
// 向調試進程發送消息
server.send(JSON.stringify(result));
}
});

server.onEnd(() => {
this.disposeTarget();
this.onEndEmitter.fire({ killed: this.gracefulExit, code: this.gracefulExit ? 0 : 1 });
});
}
...
}

可以看到,在 Node.js 腳本被真正執行前,JavaScript Debug Terminal 為了讓 CDP 鏈接能夠正常初始化以及通信做了一系列工作,也正是這裏的初始化操作,讓即使是在終端被執行的腳本依舊可以與我們的調試進程進行 CDP 通信。

這裏忽略掉了部分終端創建的邏輯,實際上在創建終端的過程中,JavaScript Debugger 也採用了一些特殊的處理,如不直接通過插件進程創建終端的邏輯,而是通過 vscode.window.onDidOpenTerminal 去接收新終端的創建,見 ui/debugTerminalUI.ts#L197 。這些操作對於 Terminal 實例在插件進程的唯一性有一定要求,這也是前期插件適配工作的成本之一。

Automatic Browser Debugging

看完 JavaScript Debug Terminal 的實現原理,我們再來看一下另外一個重要特性的實現:Automatic browser debugging ,想要使用該功能,你需要在 JavaScript Debug Terminal 中使用,或手動配置debug.javascript.debugByLinkOptions 為 on 或  always ,開啟了該功能後,所有你在終端以調試模式打開的網址將都可以自動 Attach 上響應的調試進程。

link-debugging.gif

其核心原理即是通過 ui/terminalLinkHandler.ts 往 Terminal 中註冊鏈接點擊處理邏輯,實現 vscode.TerminalLinkProvider (http://code.visualstudio.com/api/references/vscode-api#TerminalLinkProvider) 的結構。

export class TerminalLinkHandler implements vscode.TerminalLinkProvider<ITerminalLink>, IDisposable {
// 根據給定的 Terminal 獲取其內容中可被點擊的 Link 數組,配置其基礎信息
public provideTerminalLinks(context: vscode.TerminalLinkContext): ITerminalLink[] {
switch (this.baseConfiguration.enabled) {
case 'off':
return [];
case 'always':
break;
case 'on':
default:
if (!this.enabledTerminals.has(context.terminal)) {
return [];
}
}

const links: ITerminalLink[] = [];


for (const link of findLink(context.line, 'url')) {
let start = -1;
while ((start = context.line.indexOf(link.value, start + 1)) !== -1) {
let uri: URL;
try {
uri = new URL(link.href);
} catch {
continue;
}

// hack for http://github.com/Soapbox/linkifyjs/issues/317
if (
uri.protocol === Protocol.Http &&
!link.value.startsWith(Protocol.Http) &&
!isLoopbackIp(uri.hostname)
) {
uri.protocol = Protocol.Https;
}

if (uri.protocol !== Protocol.Http && uri.protocol !== Protocol.Https) {
continue;
}

links.push({
startIndex: start,
length: link.value.length,
tooltip: localize('terminalLinkHover.debug', 'Debug URL'),
target: uri,
workspaceFolder: getCwd()?.index,
});
}
}

return links;
}

/**
* 處理具體點擊鏈接後的操作
*/

public async handleTerminalLink(terminal: ITerminalLink): Promise<void> {
if (!(await this.handleTerminalLinkInner(terminal))) {
vscode.env.openExternal(vscode.Uri.parse(terminal.target.toString()));
}
}
}

在鏈接被打開前,會進入 handleTerminalLinkInner 的邏輯進行調試進程的鏈接處理,如下:

向上檢索默認的瀏覽器信息,是否為 Edge,否則使用  pwa-chrome 調試類型啟動調試。

在找不到對應調試信息(即 DAP 消息)的情況下,輸出 Using the "preview" debug extension , 結束調試。

Profile

Profile 主要為開發者提供對進程性能及堆棧信息的分析能力,在 JavaScript Debugger 中,由於所有的通信均通過 CDP 協議處理,生成的報吿文件也自然的能通過 Chrome Devtools 中查看,VS Code 中默認僅支持基礎的報吿查看,你也可以通過安裝 ms-vscode.vscode-js-profile-flame  插件查看。

實現該功能依舊是通過 DAP 消息進行通信處理,與上面提到的 設置斷點 案例實際類似,DAP 通信中收到 startSefProfile 時開始向 CDP 鏈接發送 Profiler.enable ,Profiler.start 指令,進而在不同的調試器中處理該指令,在 DAP 通信中收到 stopSelfProfile  指令時,向 CDP 鏈接發送 Profiler.stop 指令,收集 profile 信息後寫入對應文件,詳細代碼可見:adapter/selfProfile.ts

Debug Console

JavaScript Debugger 在發佈日誌中着重標註了對於 Top-Level await  的支持,原有的 DebugConsole 對於變量執行的邏輯依舊是依賴 DAP 中接收 evaluate 指令(代碼見:adapter/debugAdapter.ts#L99) ,繼而轉化為 CDP 的 Runtime.evaluate 指令執行。由於不同調試器運行環境的差異性,變量或表達式最終的執行指令需要根據環境進行區分處理(詳細代碼可見:adapter/threads.ts#L394)。以在調試控制枱執行如下代碼為例:

const res = await fetch('http://api.github.com/orgs/microsoft');
console.log(await res.json());

當表達式為 Top-Level await 時,需要將表達式進行重寫

從上面的表達式轉化為可執行的閉包結構(解析邏輯可見:common/sourceUtils.ts#L67)同時在參數中標記 awaitPromise=true , 在部分調試器執行 Runtime.evalute 時,當參數中存在 awaitPromise=true 時,會將閉包執行的返回結果作為輸出值進行返回,轉化後的結果如下所示:

(async () => {
(res = await fetch('http://api.github.com/orgs/microsoft'));
return console.log(await res.json());
})();

最終執行結果就能夠正常輸出:

這樣便實現了對  Top-Level await 的支持。

以上整體上是針對部分功能實現的解析,部分功能的優化也依賴 DAP 及代碼邏輯的優化實現,如更好的代碼映射及 Return value interception 等,希望看完本文能讓你對 VS Code 的 JavaScript Debugger 有大致的理解,OpenSumi 近期也在 2.18.0 版本完成了對於 VS Code JavaScript Debugger 的初步適配,大部分功能都可以正常使用,也歡迎使用 OpenSumi 來搭建自己的 IDE 產品。

阿里巴巴編程之夏火熱進行中,歡迎高校的小夥伴好好利用假期時間,參加到我們 OpenSumi 項目(NodeJS 技術棧)活動中 ~ 賺取豐富的獎金及證書 !!