深度剖析 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 版本外掛,大部分功能已經可以正常使用,歡迎大家升級體驗。

由於公眾號連結限制,文章中提到的詳細程式碼均可在 https://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:https://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) {
// 程式碼見:https://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 (https://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 https://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 技術棧)活動中 ~ 賺取豐富的獎金及證書 !!