基於Service Worker實現WebRTC區域網大檔案傳輸能力

WindrunnerMax發表於2024-09-09

基於Service Worker實現WebRTC區域網大檔案傳輸能力

Service Worker是一種駐留在使用者瀏覽器後臺的指令碼,能夠攔截和處理網路請求,從而實現豐富的離線體驗、快取管理和網路效率最佳化。請求攔截是其關鍵功能之一,透過監聽fetch事件,Service Worker可以捕獲所有向網路發出的請求,並有選擇地處理這些請求,例如從快取中讀取響應,或者對請求進行修改和重定向,進而實現可靠的離線瀏覽和更快速的內容載入。

  • 線上體驗: https://webrtc.touchczy.top/
  • 專案地址: https://github.com/WindrunnerMax/FileTransfer

描述

在前段時間,在群裡看到有人提了一個問題,在從物件儲存下載檔案的時候,為什麼實現了攜帶了一個GitHub Pages的地址,理論上而言我們從物件儲存下載內容直接點連線就好了,然而這裡竟然看起來似乎還有一箇中間環節,像是需要被GitHub Pages攔截並中轉才下載到本地,連結地址類似於下面的內容。此時如果我們在下載頁面點選下載後,再開啟瀏覽器的下載管理功能,可以發現下載地址實際上會變成一個更加奇怪的地址,而這個地址我們實際上直接在瀏覽器開啟會響應404

<!-- 下載頁面 -->
https://jimmywarting.github.io/StreamSaver.js/examples/saving-a-blob.html

<!-- 瀏覽器下載管理 -->
https://jimmywarting.github.io/StreamSaver.js/jimmywarting.github.io/712864/sample.txt

從連結中我們可以明顯地看出這裡是使用了StreamSaver.js來作為下載檔案的中間環節,從README中我們可以看出StreamSaver.js是基於ServiceWorker的大檔案下載方案。於是前段時間有時間將其實現研究了一番,通常我們需要排程檔案下載時,可能會直接透過<a />標籤在瀏覽器中直接開啟目標連結便可以開始下載,然而這種方式有比較明顯的三個問題:

  • 如果直接開啟的資源是圖片、影片等瀏覽器能夠直接解析的資源,那麼此時瀏覽器不會觸發下載行為,而是會直接在瀏覽器中預覽開啟的資源,即預設的Content-Disposition值是inline,不會觸發值為attachment的下載行為。當然,使用<a />標籤的download可以解決這個問題,然而這個屬性只有在同源URLblob:data:協議下才會生效。
  • 如果我們上傳到物件儲存的檔案存在重名資源的問題,那麼為了防止檔案被覆蓋,我們可能會隨機生成資源名或者在資源後面加上時間戳,甚至直接將檔名生成不帶副檔名的HASH值。那麼在檔案下載的時候,我們就需要將檔名實際還原回來,然而這個過程仍然需要依賴響應的attachment; filename=,或者<a />標籤的download屬性來重新命名檔案。
  • 如果我們請求的資源是需要校驗許可權才能正常下載,那麼直接使用<a />標籤進行資源請求的時候則僅僅是發起了GET請求,而且將金鑰放置於請求的連結地址上顯然是做不到真正的許可權校驗的。當然透過簽發臨時的Token並返回GET請求地址當然是可行的,但如果涉及到更復雜一些的許可權控制以及審計追蹤時,生成臨時下載連結可能並不足以做到高安全性的要求,類似的問題在EventSource物件實現的SSE中更加明顯。

而在我們的專案中,恰好存在這樣的歷史遺留問題,我們的資原始檔都會儲存在OSS-Object Storage Service物件儲存中,並且為了防止資源重名的問題,預設的資源策略是完全不攜帶檔案的副檔名,而是直接將檔名生成HASH值,而且由於域名是基建自帶的CDN加速域名,不能透過配置CNAME來定義為我們站點的域名,也就是說我們的資源必然存在跨域的問題,這就相當於把所有的限制都觸及到了。

那麼在這種情況下,我們是需要將檔案重新命名為原本的資源名稱的,畢竟在不存在副檔名的情況下作業系統不能識別出檔案內容,而我們的CDN資源是不存在Content-Disposition響應頭以及原始資源名稱的,而且檔案也不是同域名下的資源。在這種情況下我們需要實現跨域情況下的資源重新命名,由此來支援使用者的下載行為,所以我們在這裡採取的方案是首先使用fetch將檔案下載到記憶體,然後透過createObjectURL將其建立為blob:協議的資源,由此來支援<a />標籤的download屬性。

透過這種方式下載檔案則又出現了另一個問題,將檔案全部下載後都存在記憶體中可能會存在OOM的現象,對於現代瀏覽器來說並沒有非常明確的單個Tab頁的記憶體限制,而是根據系統資源動態分配的,但是隻要在記憶體中下載足夠大的檔案,還是會觸發OOM導致瀏覽器頁面崩潰。那麼在這種情況下,透過將Service Worker作為中間人攔截下載請求,並且在響應的Header中加入Content-Disposition來支援檔案重新命名,並且可以透過Stream API來實現流式的下載行為,由此避免全部將檔案下載到記憶體當中。總結來說,在這裡我們透過這種方式解決了兩個問題:

  • 跨域資源的下載,透過劫持請求並增加相應頭的方式,解決了跨域資源的重新命名問題,並以此來直接排程瀏覽器IO來實現下載。
  • 避免記憶體溢位問題,透過Stream APIfetch請求的資料分片寫入檔案,以此來做到流式下載,避免將檔案全部寫入到記憶體中。

那麼除了在物件儲存下載檔案之外,這種資料處理方式還有很多應用場景,例如我們需要批次下載檔案並且壓縮時,可以主動fetch後透過ReadableStream讀,並且pipe到類似壓縮的實現中,例如zlib.createDeflateRaw的瀏覽器方案,再pipeWritableStream中類似FileSystemFileHandle.createWritable以此來實時寫入檔案,這樣就可以做到高效的檔案讀寫,而不需要將其全部持有在記憶體中。

恰好在先前我們基於WebRTC實現了區域網檔案傳輸,而透過WebRTC傳輸的檔案也會同樣需要面對大檔案傳輸的問題,並且由於其本身並不是HTTP協議,自然就不可能攜帶Content-Disposition等響應頭。這樣我們的大檔案傳輸就必須要藉助中間人的方式進行攔截,此時我們透過模擬HTTP請求的方式來生成虛擬的下載連結,並且由於本身就是分片傳輸,我們可以很輕鬆地藉助Stream API來實現流式下載能力。那麼本文就以WebRTC的檔案傳輸為基礎,來實現基於Service Worker的大檔案傳輸方案,文中的相關實現都在https://github.com/WindrunnerMax/FileTransfer中。

Stream API

瀏覽器實現的Stream API中存在ReadableStreamWritableStreamTransformStream三種流型別,其中ReadableStream用以表示可讀的流,WritableStream用以表示可寫的流,而TransformStream用以表示可讀寫的流。由於在瀏覽器中Stream的實現時間與機制並不相同,ReadableStream的相容性與Fetch API基本一致,而WritableStreamTransformStream的相容性則相對稍差一點。

資料流動

在最開始接觸Stream API的時候,我難以理解整個管道的資料流,針對於緩衝區以及背壓等問題本身是不難理解的,但是在實際將Stream應用的時候,我發現並不能理解整個流的模型的資料流動方向。在我的理解中,整個管道應該是以WritableStream起始用以寫入/生產資料,而後繼的管道則應該使用ReadableStream來讀取/消費資料,而整個連線過程則可以透過pipeTo連結起來。

const writable = new WritableStream();
const readable = new ReadableStream();
writable.pipeTo(readable); // TypeError: writable.pipeTo is not a function
const writer = writable.getWriter();
const reader = readable.getReader();
// ...
writer.write("xxx");
reader.read().then(({ value, done }) => {
  console.log(value, done);
});

當然這是個錯誤的示例,針對於流的理解我們應該參考Node.jsStream模組,以node:fscreateReadStreamcreateWriteStream為例,我們會更容易理解整個模型。我們的Stream模型是以ReadableStream為起始,即資料生產是以Node.js本身的IO為基礎的讀取檔案,將內容寫入到ReadableStream中,而我們作為資料處理者,則是在其本身的事件中進行資料處理,進而將處理後的資料寫入WritableStream來消費,即後繼的管道是以WritableStream為終點。

const fs = require("node:fs");
const path = require("node:path");

const sourceFilePath = path.resolve("./source.txt");
const destFilePath = path.join("./destination.txt");
const readStream = fs.createReadStream(sourceFilePath, { encoding: "UTF-8" });
const writeStream = fs.createWriteStream(destFilePath, { encoding: "UTF-8" });

readStream.on("data", chunk => {
  writeStream.write(chunk);
});
readStream.on("end", () => {
  writeStream.end();
});

那麼在瀏覽器中,我們的Stream API同樣是以ReadableStream為起始,Fetch APIResponse.body就是很好的示例,資料的起始同樣是以IO為基礎的網路請求。在瀏覽器中我們的ReadableStreamAPINode.js本身還是有些不同的,例如在瀏覽器ReadableStreamReader並不存在類似on("data", () => null)的事件監聽,而前邊的例子只是為了讓我們更好地理解整個流模型,在這裡我們當然是以瀏覽器的API為主。

聊了這麼多關於Stream API的問題,我們回到針對於WebRTC傳遞的資料實現,針對於類似Fetch的資料傳輸,是藉助瀏覽器本身的IO來控制ReadableStream的資料生產,而我們的WebRTC僅僅是傳輸通道,因此在管道的初始資料生產時,ReadableStream是需要我們自己來控制的,因此我們最開始想到的Writable -> Readable方式,則是為了適應這部分實現。而實際上這種方式實際上更契合於TransformStream的模型,其本身的能力是對資料流進行轉換,而我們同樣可以藉助TransformStream來實現流的讀寫。

const transformStream = new TransformStream<number, number>({
  transform(chunk, controller) {
    controller.enqueue(chunk + 1);
  },
});
const writer = transformStream.writable.getWriter();
const reader = transformStream.readable.getReader();
const process = (res: { value?: number; done: boolean }) => {
  const { value, done } = res;
  console.log(value, done);
  if (done) return;
  reader.read().then(process);
};
reader.read().then(process);
writer.write(1);
writer.write(2);
writer.close();

那麼在這裡我們就可以實現對於ReadableStream的資料處理,在基於WebRTC的資料傳輸實現中,我們可以獲取到DataChannel的資料流本身,那麼此時我們就可以透過ReadableStreamController來向緩衝佇列中置入資料,以此來實現資料的寫入,而後續的資料消費則可以使用ReadableStreamReader來實現,這樣我們就可以藉助緩衝佇列實現流式的資料傳輸。

const readable = new ReadableStream<number>({
  start(controller) {
    controller.enqueue(1);
    controller.enqueue(2);
    controller.close();
  },
});
const reader = readable.getReader();
const process = (res: { value?: number; done: boolean }) => {
  const { value, done } = res;
  console.log(value, done);
  if (done) return;
  reader.read().then(process);
};
reader.read().then(process);

背壓問題

那麼在這裡我們可以思考一個問題,如果此時我們的DataChannel的資料流的傳輸速度非常快,也就是不斷地將資料enqueue到佇列當中,而假如此時我們的消費速度非常慢,例如我們的硬碟寫入速度比較慢,那麼資料的佇列就會不斷增長,那麼就可能導致記憶體溢位。實際上這個問題有專業的術語來描述,即Back Pressure背壓問題,在ReadableStream中我們可以透過controller.desiredSize來獲取當前佇列的大小,以此來控制資料的生產速度,以此來避免資料的積壓。

const readable = new ReadableStream<number>({
  start(controller) {
    console.log(controller.desiredSize); // 1
    controller.enqueue(1);
    console.log(controller.desiredSize); // 0
    controller.enqueue(2);
    console.log(controller.desiredSize); // -1
    controller.close();
  }
});

而對於背壓問題, 我們可以很簡單地理解到,當我們的資料生產速度大於資料消費速度時,就會導致資料的積壓,那麼針對於ReadableStreamWritableStream,我們可以分別得到相關的排隊策略,實際上我們也能夠很容易理解到背壓所謂的壓力都是來自於緩衝佇列中未消費的塊,當然我們也可以預設比較大的緩衝佇列長度,只不過這樣雖然避免了desiredSize為負值,但是並不能解決背壓問題。

  • 對於ReadableStream,背壓來自於已入隊但尚未讀取的塊。
  • 對於WritableStream,背壓來自於已寫入但尚未由底層接收器處理的塊。

而在先前的ReadableStream實現中,我們可以很明顯地看到其本身並沒有攜帶背壓的預設處理機制,即使我們可以透過desiredSize來判斷當前內建佇列的壓力,但是我們並不能很明確地反饋資料的生產速度,我們更希望基於事件驅動來控制而不是類似於setTimeout來輪訓檢查,當然我們也可以透過pull方法來被動控制佇列的資料量。而在WritableStream中則存在內建的背壓處理方法即writer.ready,透過這個方法我們可以判斷當前佇列的壓力,以此來控制資料的生產速度。

(async () => {
  const writable = new WritableStream();
  const writer = writable.getWriter();
  await writer.write(1);
  await writer.write(1);
  await writer.write(1);
  console.log("written"); // written
  await writer.ready;
  await writer.write(1);
  console.log("written"); // Nil
})();

因此在我們的WebRTC資料傳輸中,為了方便地處理背壓問題,我們是透過TransformStreamwritable端來實現資料的寫入,而消費則是透過readable端來實現的,這樣我們就可以很好地控制資料的生產速度,並且可以在主執行緒中將TransformStream定義後,將readable端透過postMessage將其作為Transferable Object傳遞到Service Worker中消費即可。

// packages/webrtc/client/worker/event.ts
export class WorkerEvent {
  public static start(fileId: string, fileName: string, fileSize: number, fileTotal: number) {
    const ts = new TransformStream();
    WorkerEvent.channel.port1.postMessage(
      {
        key: MESSAGE_TYPE.TRANSFER_START,
        id: fileId,
        readable: ts.readable,
      } as MessageType,
      [ts.readable]
    );
  }

  public static async post(fileId: string, data: ArrayBuffer) {
    const writer = WorkerEvent.writer.get(fileId);
    if (!writer) return void 0;
    await writer.ready;
    return writer.write(new Uint8Array(data));
  }

  public static close(fileId: string) {
    WorkerEvent.channel?.port1.postMessage({
      key: MESSAGE_TYPE.TRANSFER_CLOSE,
      id: fileId,
    } as MessageType);
    const writer = WorkerEvent.writer.get(fileId);
    writer?.close();
  }
}

Fetch

Fetch APIResponse物件中,存在Response.body屬性用以獲取響應的ReadableStream,與上述物件一致同樣用以表示可讀的流。透過這個介面我們可以實現流式的讀取資料,而不需要一次性將所有資料讀取到記憶體中,以此來漸進式地處理資料,例如在使用fetch實現SSE - Server-Sent Events的響應時,便可以透過維持長連結配合ReadableStream來實現資料的響應。

針對於Fetch方法,在接觸Stream API之前我們可能主要的處理方式是呼叫res.json()等方法來讀取資料,實際上這些方法同樣會在其內部實現中隱式呼叫ReadableStream.getReader()來讀取資料。而在Stream API出現之前,如果我們想要處理某種資源例如影片、文字檔案等,我們必須下載整個檔案,等待它反序列化為合適的格式,然後直接處理所有資料。

因此在先前調研StreamSaver.js時,我比較費解的一個問題就是,既然我們請求的資料依然是需要從全部下載到記憶體中,那麼在這種情況下我們使用StreamSaver.js依然無法做到流式地將資料寫入硬碟,依然會存在瀏覽器Tab頁的記憶體溢位問題。而在瞭解到Fetch APIResponse.body屬性後,關於整個流的處理方式就變得清晰了,我們可以不斷地呼叫read()方法將資料傳遞到Service Worker排程下載即可。

因此排程檔案下載的方式大概與上述的WebRTC傳輸方式類似,在我們已經完成劫持資料請求的中間人Service Worker之後,我們只需要在主執行緒部分發起fetch請求,然後在響應資料時透過Iframe發起劫持的下載請求,然後透過Response.body.getReader()分片讀取資料,並且不斷將其寫入到TransformStreamWriter中即可,此外我們還可以實現一些諸如下載進度之類的效果。

const fileId = "xxxxxx";
const worker = await navigator.serviceWorker.getRegistration("./");
const channel = new MessageChannel();
worker.active.postMessage({ type: "INIT_CHANNEL" }, [channel.port2]);
const ts = new TransformStream();
channel.port1.postMessage(
  { key: "TRANSFER_START", id: fileId, readable: ts.readable, },
  [ts.readable]
);
 const src = `/${fileId}` + `?X-File-Id=${fileId}` +
      `&X-File-Size=42373` + `&X-File-Total=1` + `&X-File-Name=favicon.ico`;
const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = src;
iframe.id = fileId;
document.body.appendChild(iframe);
const writer = ts.writable.getWriter();
fetch("./favicon.ico").then(res => {
  const reader = res.body.getReader();
  const process = (res) => {
    const { value, done } = res;
    if (done) {
      writer.close();
      return;
    }
    writer.write(value);
    reader.read().then(process);
  };
  reader.read().then(process);
});

Service Worker

Service Worker作為一種執行在後臺的獨立執行緒,具備充當網路請求中間人的能力,能夠攔截、修改甚至完全替換網路請求和響應,從而實現高階功能如快取管理、提升效能、離線訪問、以及對請求進行細粒度的控制和最佳化。在這裡我們就可以藉助Service Worker為我們的請求響應加入Content-Disposition等響應頭,以此來觸發瀏覽器的下載能力,藉助瀏覽器的IO實現大檔案的下載。

環境搭建

在透過Service Worker實現中間人攔截網路請求之前,我們可以先看一下在Service Worker中搭建TS環境以及Webpack的配置。我們平時TS開發的環境的lib主要是domdom.iterableesnext,而由於Worker中的全域性變數以及持有的方法並不相同,因此其本身的lib環境需要改為WebWorkerESNext,且如果不主動引入或者匯出模組,TS會認為其是作為d.ts使用,因此即使我們在沒有預設匯入匯出的情況下也要預設匯出個空物件,而在有匯入的情況下則需要注意將其在tsconfiginclude相關模組。

// packages/webrtc/client/worker/index.ts
/// <reference lib="esnext" />
/// <reference lib="webworker" />
declare let self: ServiceWorkerGlobalScope;
export {};

Service Worker本身作為獨立的Js檔案,其必須要在同源策略下執行,這裡如果需要關注部署環境的路由環境的話,需要將其配置為獨立的路由載入路徑。而對於我們的靜態資源本身來說則需要將我們實現的獨立Worker作為入口檔案配置到打包工具中,並且為了方便處理SW是否註冊以及快取更新,通常我們都是將其固定為確定的檔名,以此來保證其在快取中的唯一性。

// packages/webrtc/rspack.client.js
/**
 * @type {import("@rspack/cli").Configuration}
 */
const Worker = {
  context: __dirname,
  entry: {
    worker: "./client/worker/index.ts",
  },
  devtool: isDev ? "source-map" : false,
  output: {
    clean: true,
    filename: "[name].js",
    path: path.resolve(__dirname, "build/static"),
  },
};

module.exports = [/** ... */, Worker];

Service Worker中,我們可以在其install事件和activate事件中分別處理其安裝與啟用的邏輯,通常新的Service Worker安裝完成後會進入等待階段,直到舊的Service Worker被完全解除安裝後再進行啟用,因此我們可以直接在onInstallskipWaiting,在onActive事件中,我們可以透過clients.claim在啟用後立即接管所有的客戶端頁面,無需等待頁面重新整理,這對於我們除錯SW的時候非常有用。

// packages/webrtc/client/worker/index.ts
self.addEventListener("install", () => {
  self.skipWaiting();
  console.log("Service Worker Installed");
});

self.addEventListener("activate", event => {
  event.waitUntil(self.clients.claim());
  console.log("Service Worker Activate");
});

請求攔截

接下來我們就要來研究一下Service Worker的攔截網路請求能力了,在MDN中存在對於Fetch Event的詳細描述,而且Fetch Event是僅能夠在Service Worker中使用的。而在這裡我們的攔截請求與響應則非常簡單,我們只需要從請求的地址中獲取相關資訊,即idnamesizetotal,然後透過ReadableStream構造Response作為響應即可,這裡主要需要關注的是Content-DispositionContent-Length兩個響應頭,這是我們觸發下載的關鍵配置。

// packages/webrtc/client/worker/index.ts
self.onfetch = event => {
  const url = new URL(event.request.url);
  const search = url.searchParams;
  const fileId = search.get(HEADER_KEY.FILE_ID);
  const fileName = search.get(HEADER_KEY.FILE_NAME);
  const fileSize = search.get(HEADER_KEY.FILE_SIZE);
  const fileTotal = search.get(HEADER_KEY.FILE_TOTAL);
  if (!fileId || !fileName || !fileSize || !fileTotal) {
    return void 0;
  }
  const transfer = map.get(fileId);
  if (!transfer) {
    return event.respondWith(new Response(null, { status: 404 }));
  }
  const [readable] = transfer;
  const newFileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, "%2A");
  const responseHeader = new Headers({
    [HEADER_KEY.FILE_ID]: fileId,
    [HEADER_KEY.FILE_SIZE]: fileSize,
    [HEADER_KEY.FILE_NAME]: newFileName,
    "Content-Type": "application/octet-stream; charset=utf-8",
    "Content-Security-Policy": "default-src 'none'",
    "X-Content-Security-Policy": "default-src 'none'",
    "X-WebKit-CSP": "default-src 'none'",
    "X-XSS-Protection": "1; mode=block",
    "Cross-Origin-Embedder-Policy": "require-corp",
    "Content-Disposition": "attachment; filename*=UTF-8''" + newFileName,
    "Content-Length": fileSize,
  });
  const response = new Response(readable, {
    headers: responseHeader,
  });
  return event.respondWith(response);
}

在這裡還有一件有趣的事情,在上面的實現中我們可以看到對於從請求地址中取得相關資訊的檢查,如果檢查不透過則返回undefined。這實際上是個很常見的攔截Case,即不符合條件的請求我們直接放行即可,而在之前我一直比較納悶的問題是,任何經過Service Worker攔截的請求都會在我們的Network控制檯皮膚中出現帶著齒輪符號的請求,也就是從Service Worker中發起的請求,這樣在除錯的時候會顯得非常混亂。

實際上這就單純是我們使用出現了問題,從提示資訊能夠明顯地看出來這是從Service Worker中發起的請求,而實際上這個請求我們直接讓其透過原本的鏈路請求即可,不需要從Service Worker中實際代理,而觸發這個請求條目的主要原因是我們呼叫了fetch方法,而無論是直接返回fetch還是透過event.respondWith(fetch)都會觸發這個請求條目,因此我們在攔截請求的時候,如果不符合條件則直接返回undefined即可。

// 會再次發起請求
return fetch(event.request);
return event.respondWith(fetch(event.request));

// 不會再次發起請求
return ;

那麼我們需要接著思考一個問題,應該如何觸發下載,這裡的Service Worker僅僅是攔截了請求,而在WebRTC的傳輸中並不會實際發起任何HTTP請求,因此我們需要主動觸發這個請求,得益於Service Worker可以攔截幾乎所有的請求,包括靜態資源、網路請求等,因此我們可以直接藉助建立Iframe的方式配合約定好的欄位名來實現下載,在這裡實際上就是我們最開始提到的那個比較奇怪的連結地址了。

// packages/webrtc/client/worker/event.ts
const src =
  `/${fileId}` +
  `?${HEADER_KEY.FILE_ID}=${fileId}` +
  `&${HEADER_KEY.FILE_SIZE}=${fileSize}` +
  `&${HEADER_KEY.FILE_TOTAL}=${fileTotal}` +
  `&${HEADER_KEY.FILE_NAME}=${fileName}`;
const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = src;
iframe.id = fileId;
document.body.appendChild(iframe);

在這裡我們可能會好奇一個問題,為什麼我們的請求資訊是從URL上獲取,而不是直接在原始請求的時候就構造完成相關的Header資訊,在Service Worker中直接將約定的響應頭直接轉發即可,也就是說為什麼要用Iframe而不是fetch請求並且攜帶請求頭的方式來實現下載。實際上這是因為即使存在了"Content-Disposition": "attachment; xxx"響應頭,fetch請求也不支援直接發起下載能力。

實際上在這裡我還研究了一下StreamSaver.js的實現,這同樣是個很有趣的事情,StreamSaver.js的執行環境本身就是個Iframemitm.html,那麼我們姑且將其稱為B.html,那麼此時我們的主執行緒稱其為A.html。此時我們在B中註冊名為B.jsService Worker,之後我們透過python3 -m http.server 9000等方式作為服務資源開啟A的地址,新器埠9001開啟B的地址,保證其存在跨域的情況。

<!-- A.html -->
<iframe src="http://localhost:9001/B.html" hidden></iframe>

<!-- B.html -->
<script>
    navigator.serviceWorker.register("./B.js", { scope: "./" });
</script>
// B.js
self.onfetch = (e) => {
  console.log(e.request.url);
  if (e.request.url.includes("ping")) {
    e.respondWith(new Response("pong"));
  }
};

此時我們在A.html中建立新的iframe地址localhost:9001/ping,也就是類似於在StreamSaver.js建立出的臨時下載地址那種,我們可以發現這個地址竟然可以被監聽到,即Service Worker可以攔截到這個請求,當時覺得這件事很神奇因為在不同域名的情況下理論上不可能被攔截的,本來以為發現了什麼iframe的特性,最後發現我們訪問的是9001的源地址,也就是相當於還是在B.html源下的資源,如果此時我們訪問的是9000的資源則不會有這個效果了。

const iframe = document.createElement("iframe");
iframe.hidden = true;
iframe.src = "http://localhost:9001/ping";
document.body.appendChild(iframe);

此外實際上如果我們在瀏覽器的位址列中直接開啟http://localhost:9001/ping也是同樣可以得到pong的響應的,也就是說Service Worker的攔截範圍是在註冊的scope範圍內,那麼實際上如果有必要的話,我們則完全可以基於SW來實現離線的PWA應用,而不需要依賴於伺服器響應的路由以及介面。此外,這個效果在我們的WebRTC實現的SW中也是存在的,而當我們再次點選下載連結無法得到響應,是由於我們檢查到transfer不存在,直接響應了404

const transfer = map.get(fileId);
if (!transfer) {
  return event.respondWith(new Response(null, { status: 404 }));
}

資料通訊

言歸正傳,接下來我們就需要實現與Service Worker的通訊方案了,這裡的實現就比較常規了。首先我們要註冊Service Worker,在同一個Scope下只能註冊一個Service Worker,如果在同一個作用域內註冊多個Service Worker,那麼後註冊的Service Worker會覆蓋先註冊的Service Worker,當然這個問題不存在WebWorker中。在這裡我們藉助getRegistrationregister分別來獲取當前活躍的Service Worker以及註冊新的Service Worker

// packages/webrtc/client/worker/event.ts
if (!navigator.serviceWorker) {
  console.warn("Service Worker Not Supported");
  return Promise.resolve(null);
}
try {
  const serviceWorker = await navigator.serviceWorker.getRegistration("./");
  if (serviceWorker) {
    WorkerEvent.worker = serviceWorker;
    return Promise.resolve(serviceWorker);
  }
  const worker = await navigator.serviceWorker.register(
    process.env.PUBLIC_PATH + "worker.js?" + process.env.RANDOM_ID,
    { scope: "./" }
  );
  WorkerEvent.worker = worker;
  return worker;
} catch (error) {
  console.warn("Service Worker Register Error", error);
  return Promise.resolve(null);
}

在與Service Worker資料通訊方面,我們可以藉助MessageChannel來實現。MessageChannel是一個雙向通訊的通道,可以在兩個不同的Context中傳遞訊息,例如在主執行緒與Worker執行緒之間進行資料通訊。我們只需要在主執行緒中建立一個MessageChannel,然後將其port2埠透過postMessage傳遞給Service Worker,而Service Worker則可以透過event.ports[0]獲取到這個port2,此後我們就可以藉助這兩個port直接通訊了。

或許我們會思考一個問題,為什麼我們可以將port2傳遞到Service Worker中,理論上而言我們的postMessage只能傳遞可序列化Structured Clone的物件,例如字串、數字等資料型別,而port2本身是作為不可序列化的物件存在的。那麼這裡就涉及到了Transferable objects的概念,可轉移的物件是擁有屬於自己的資源的物件,這些資源可以從一個上下文轉移到另一個,確保資源一次僅在一個上下文中可用,在傳輸後原始物件不再可用,其不再指向轉移後的資源,並且任何讀取或者寫入該物件的嘗試都將丟擲異常。

// packages/webrtc/client/worker/event.ts
if (!WorkerEvent.channel) {
  WorkerEvent.channel = new MessageChannel();
  WorkerEvent.channel.port1.onmessage = event => {
    console.log("WorkerEvent", event.data);
  };
  WorkerEvent.worker?.active?.postMessage({ type: MESSAGE_TYPE.INIT_CHANNEL }, [
    WorkerEvent.channel.port2,
  ]);
}

因為在這裡我們暫時不需要接收來自Service Worker的訊息,因此在這裡我們對於port1接收的訊息只是簡單地列印了出來。而在初始化CHANNEL的時候,我們將port2作為可轉移物件放置到了第二個引數中,以此在Service Worker中便可以接收到這個port2,由於我們以後的資訊傳遞都是由MessageChannel進行,因此這裡的onmessage作用就是很單純的接收port2物件埠。

// packages/webrtc/client/worker/index.ts
self.onmessage = event => {
  const port = event.ports[0];
  if (!port) return void 0;
};

那麼緊接著我們就需要使用TransformStream進行資料的讀寫了,由於TransformStream本身同樣是可轉移物件,因此我們可以將其直接定義在主執行緒中,然後在初始化檔案下載時,將readable端傳遞到Service Worker中,並將其作為下載的ReadableStream例項構造Response物件。那麼接下來在主執行緒建立iframe觸發下載行為之後,我們就可以在Fetch Event中從map中讀取readable了。

// packages/webrtc/client/worker/event.ts
const ts = new TransformStream();
WorkerEvent.channel.port1.postMessage(
  {
    key: MESSAGE_TYPE.TRANSFER_START,
    id: fileId,
    readable: ts.readable,
  } as MessageType,
  [ts.readable]
);
WorkerEvent.writer.set(fileId, ts.writable.getWriter());
// 構造 iframe 觸發下載行為
// ...

// packages/webrtc/client/worker/index.ts
port.onmessage = event => {
  const payload = event.data as MessageType;
  if (!payload) return void 0;
  if (payload.key === MESSAGE_TYPE.TRANSFER_START) {
    const { id, readable } = payload;
    map.set(id, [readable]);
  }
};
// 在觸發下載行為後 從 map 中讀取 readable
// ...

在主執行緒中,我們關注的是內容的寫入,以及內建的背壓控制,由於TransformStream本身內部實現的佇列以及背壓控制,我們就不需要太過於關注資料生產造成的問題,因為在先前我們實現的WebRTC下載的反饋鏈路是完善的,我們在這裡只需要藉助await控制寫入速度即可。在這裡有趣的是,即使TransformStreamreadablewritable兩端現在是執行在兩個上下文環境中,其依然能夠進行資料讀寫以及背壓控制。

// packages/webrtc/client/worker/event.ts
const writer = WorkerEvent.writer.get(fileId);
if (!writer) return void 0;
// 感知 BackPressure 需要主動 await ready
await writer.ready;
return writer.write(new Uint8Array(data));

那麼在資料塊的數量即total的最後一個塊完成傳輸後,我們就需要將整個傳輸行為進行回收。首先是TransformStreamwritable端需要關閉,這個Writer必須主動排程關閉方法,否則瀏覽器無法感知下載完成,會一直處於等待下載完成的狀態,其次就是我們需要將建立的iframebody上回收,在Service Worker中我們也需要將map中的資料進行清理,避免先前的連結還能夠響應等問題。

// packages/webrtc/client/worker/event.ts
const iframe = document.getElementById(fileId);
iframe && iframe.remove();
WorkerEvent.channel?.port1.postMessage({
  key: MESSAGE_TYPE.TRANSFER_CLOSE,
  id: fileId,
} as MessageType);
const writer = WorkerEvent.writer.get(fileId);
writer?.close();
WorkerEvent.writer.delete(fileId);

// packages/webrtc/client/worker/index.ts
port.onmessage = event => {
  const payload = event.data as MessageType;
  if (!payload) return void 0;
  if (payload.key === MESSAGE_TYPE.TRANSFER_CLOSE) {
    const { id } = payload;
    map.delete(id);
  }
};

相容考量

在現代瀏覽器中Service WorkerFetch APIStream API都已經得到了比較良好的支援,在這裡我們使用到的相對最新特性TransformStream的相容性也是不錯的,在2022年後釋出的瀏覽器版本基本得到了支援,然而如果我們在MDNTransformStream相容性中仔細觀察一下,則會發現TransformStream作為transferableSafari中至今還未支援。

那麼在這裡會造成什麼問題呢,我們可以注意到在先前TRANSFER_START的時候,我們是將TransformStreamreadable端作為Transferable Object傳遞到Service Worker中,那麼此時由於Safari不支援這個行為,我們的ReadableStream自然就無法傳遞到Service Worker中,因此我們後續的下載行為就無法繼續了,因此如果需要相容Safari的情況下,我們需要處理這個問題。

這個問題的原因是我們無法將ReadableStream轉移所有權到Service Worker中,因此可以想到的比較簡單的辦法就是直接在Service Worker中定義ReadableStream即可。也就是說,當傳輸開始時,我們例項化ReadableStream並且儲存其控制器物件,當資料傳遞的時候,我們直接將資料塊enqueue到緩衝佇列中,而在傳輸結束時,我們直接呼叫controller.close()方法即可,而這個readable物件我們就可以直接作為請求攔截的Response響應為下載內容。

let controller: ReadableStreamDefaultController | null = null;
const readable = new ReadableStream({
  start(ctr) {
    controller = ctr;
  },
  cancel(reason) {
    console.log("ReadableStream Aborted", reason);
  },
});
map.set(fileId, [readable, controller!, Number(fileTotal)]);

self.onmessage = event => {
  const data = event.data as BufferType;
  destructureChunk(data).then(({ id, series, data }) => {
    const stream = map.get(id);
    if (!stream) return void 0;
    const [, controller, size] = stream;
    controller.enqueue(new Uint8Array(data));
    if (series === size - 1) {
      controller.close();
      map.delete(id);
    }
  });
};

那麼在這裡我們就會意識到先前我們聊到的背壓問題,由於在這裡我們沒有任何背壓的反饋機制,而是僅僅將主執行緒的資料塊全部接收並且enqueueReadableStream中,那麼在資料傳輸速度比瀏覽器控制的下載IO速度快的情況下,很容易就會出現資料積壓的情況。因此我們就需要想辦法實現背壓的控制,那麼我們就可以比較容易地想到下面的方式。

  • 在例項化ReadableStream物件的時候,我們藉助CountQueuingStrategy建立足夠大的緩衝區,因為本身在傳輸的過程中我們已經得知了整個檔案的大小以及分塊的數量等資訊,因此建立足夠大的緩衝區是可行的。當然我們可能也沒必要建立等同於分塊數量大小的緩衝區,我們可以將其除2取整或者取對數都可以,畢竟下載的時候也透過寫硬碟在不斷消費的。
  • 在例項化ReadableStream時傳遞的underlyingSource物件中,除了start方法外實際上還有pull方法,當流的內部資料塊佇列未滿時將會被反覆呼叫,直到達到其高水印,我們則可以透過這個方法的呼叫作為事件驅動的機制來控制流的頻率,需要注意的是隻有在其至少入隊一個資料塊才會被反覆呼叫,如果在pull函式呼叫的時候沒有實際入隊塊,則不會被重複呼叫。

我們在這裡首先來看一下分配足夠大的緩衝佇列的問題,如果深入思考一下,即使分配了足夠大的緩衝區,我們實際上並沒有實現任何反饋機制去控制減緩資料的生產環節,那麼這個緩衝區即使足夠大也並沒有解決我們的記憶體溢位問題,雖然即使例項化時分配了足夠大的緩衝,也不會立即分配這麼大的記憶體。那麼此時即使我們不分配那麼大的緩衝區,以預設模式實現的佇列也是完全一樣的,只是其內部的desiredSize會變成比較大的負值,而資料也並沒有實際丟失,因為此時瀏覽器的流實現會將資料儲存在記憶體中,直到消費方讀取為止。

那麼我們再來看一下第二個實現,透過pull方法我們確實可以獲得來自ReadableStream的緩衝佇列反饋,那麼我們就可以簡單實現一個控制流的方式,考慮到我們會有兩種狀態,即生產大於消費以及消費大於生產,那麼我們就不能單純的在pull的時候再拉取資料,我們應該在內部再實現一個緩衝佇列,而我們的事件驅動置入資料應該有兩部分,分別是緩衝佇列置入資料時需要檢查是否上次拉取的資料沒有成功而是在等待,此時需要排程上次pull時未完成的Promise,也就是消費大於生產的情況,還有一個事件是pull時直接檢查緩衝佇列是否有資料,如果有則直接置入資料,也就是生產大於消費的情況。

const pending = new WeakMap<ReadableStream, (stream: string) => void>();
const queue = ["1", "2", "3", "4"];
const strategy = new CountQueuingStrategy({ highWaterMark: 3 });

const underlyingSource: UnderlyingDefaultSource<string> = {
  pull(controller) {
    if (!queue.length) {
      console.log("Pull Pending");
      return new Promise<void>(resolve => {
        const handler = (stream: string) => {
          controller.enqueue(stream);
          pending.delete(readable);
          console.log("Pull Restore", stream);
          resolve();
        };
        pending.set(readable, handler);
      });
    }
    const next = queue.shift();
    controller.enqueue(next);
    console.log("Pull", next);
    return void 0;
  },
};

const readable = new ReadableStream<string>(underlyingSource, strategy);
const write = (stream: string) => {
  if (pending.has(readable)) {
    console.log("Write Pending Pull", stream);
    pending.get(readable)!(stream);
  } else {
    console.log("Write Queue", stream);
    queue.push(stream);
  }
};

// 使得讀取任務後置 先讓 pull 將 Readable 緩衝佇列拉滿
setTimeout(async () => {
  // 此時 queue 佇列中還存在資料 生產大於消費
  const reader = readable.getReader();
  console.log("Read Twice");
  // 讀取後 queue 佇列中資料已經讀取完畢 消費等於生產
  console.log("Read", await reader.read());
  // 讀取後 queue 佇列為空 Readable 緩衝佇列未滿
  // 之後 Readable 仍然發起 pull 事件 消費大於生產
  console.log("Read", await reader.read());
  console.log("Write Twice");
  // 寫入掛起的 pull 任務 消費等於生產
  write("5");
  // 寫入 queue 佇列 生產大於消費
  write("6");
}, 100);

// Pull 1
// Pull 2
// Pull 3
// Read Twice
// Pull 4
// Read {value: '1', done: false}
// Pull Pending
// Read {value: '2', done: false}
// Write Twice
// Write Pending Pull 5
// Pull Restore 5
// Write Queue 6

看起來我們實現了非常棒的基於pull的緩衝佇列控制,但是我們仔細研究一下會發現我們似乎忽略了什麼,我們是不是僅僅是將ReadableStream內建的緩衝佇列提出來到了外邊,實際上我們還是會面臨記憶體壓力,只不過這裡的資料積壓的位置從ReadableStream轉移到了我們自己定義的陣列之後,我們似乎完全沒有解決問題。

那麼我們再來思考一下問題到底是出在哪裡,當我們使用TransformStream的時候我們的背壓控制似乎僅僅是await writer.ready就實現了,那麼這裡究竟意味著什麼,我們可以很明顯地思考出來這裡是攜帶者反饋機制的,也就是說當其認為內部的佇列承壓之後,會主動阻塞生產者的資料生產,而我們的實現中似乎並沒有從Service Worker到主執行緒的反饋機制,因此我們才沒有辦法處理背壓問題。

那麼我們再看得本質一些,我們的通訊方式是postMessage,那麼在這裡的問題是什麼呢,或者是說如果我們想在主執行緒使用await的方式直接控制背壓的話,我們缺乏的是什麼,很明顯是因為我們沒有辦法獲得傳輸後事件的響應,那麼在這裡因為postMessage是單向通訊的,我們沒有辦法做到postMessage().then()這樣的操作,甚至於我們可以在postMessage之後立即置ready為掛起的Promise,等待響應資料的resolve,由此就可以做到類似的操作了。

這個操作並不複雜,那麼我們可不可以將其做的更通用一些,類似於fetch的實現,當我們發起一個請求/推送後,我們可以藉助Promise在一定時間內甚至一直等待其對應的響應,而由於我們的postMessage是單向的資料傳輸,我們就需要在資料的層面上增加id標識,以便於我們可以得知當前的響應究竟應該resolve哪個Promise

考慮到這裡,我們就需要處理資料的傳輸問題,也就是說由於我們需要對原始的資料中追加標識資訊並不是一件容易的事,在postMessage中如果是字串資料我們可以直接再構造一層物件,然而如果是ArrayBuffer資料的話,我們就需要操作其本身的Buffer,這顯然是有些費勁的。因此我希望能夠有一些簡單的辦法將其序列化,然後就可以以字串的形式進行傳輸了,在這裡我考慮了BASE64Uint8ArrayUint32Array的序列化方式。

我們就以最簡單的8個位元組為例,分別計算一下序列化之後的BASE64Uint8ArrayUint32Array體積問題。如果我們此時資料的每位都是0的話,分別計算出的編碼結果為AAAAAAAAAAA=[0,0,0,0,0,0,0,0][0,0],佔用了12字元、17字元、5字元的體積。

const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(0);

const binaryStr = String.fromCharCode.apply(null, input);
console.log("BASE64", btoa(binaryStr)) ; // AAAAAAAAAAA=
const uint8Array = new Uint8Array(input);
console.log("Uint8Array", uint8Array); // Uint8Array(8) [0, 0, 0, 0, 0, 0, 0, 0]
const uint32Array = new Uint32Array(input.buffer);
console.log("Uint32Array", uint32Array); // Uint32Array(2) [0, 0]

在上邊的結果中我們看起來是Uint32Array的序列化結果最好,然而這是我們上述所有位都填充為0的情況,然而在實際的傳輸過程中肯定是沒有這麼理想的,那麼我們再舉反例,將其全部填充為1來測試效果。此時的結果就變得不一樣了,分別計算出的編碼結果為//////////8=[255,255,255,255,255,255,255,255][4294967295,4294967295],佔用了12字元、33字元、23字元的體積。

const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(0);

const binaryStr = String.fromCharCode.apply(null, input);
console.log("BASE64", btoa(binaryStr)) ; // //////////8=
const uint8Array = new Uint8Array(input);
console.log("Uint8Array", uint8Array); // Uint8Array(8) [255, 255, 255, 255, 255, 255, 255, 255]
const uint32Array = new Uint32Array(input.buffer);
console.log("Uint32Array", uint32Array); // Uint32Array(2) [4294967295, 4294967295]

這麼看起來,還是BASE64的序列化結果比較穩重,因為其本身就是按位的編碼方式,其會將每6 bits編碼共64按照索引取陣列中的字元,這樣就變成了每3個位元組即24 bits會編碼為4個字元變成32 bits,而此時我們有8個位元組也就是64 bits,不能夠被24 bits完全整除,那麼此時我們先處理前6個位元組,如果全位都是0的話,那麼前8個字元就全部是A,而此時我們還剩下16 bits,那麼我們就填充8 bits將其湊為24 bits,然後再編碼為4個字元(最後6 bits=填充),因此最終的結果就是12個字元。

然而在這裡我發現是我想多了,實際上我們並不需要考慮序列化的編碼問題,在我們的RTC DataChannel確實是必須要純字串或者是ArrayBuffer等資料,不能直接傳輸物件,但是在postMessage中我們可以傳遞的資料是由The Structured Clone Algorithm演算法控制的,而ArrayBuffer物件也是赫然在列的,而且也不需要藉助transfer能力來實現所有權問題,其會實際執行內建的序列化方法。在我的實際測試中ChromeFirefoxSafari都是支援這種直接的資料傳輸的,這裡的傳輸畢竟都是在同一瀏覽器中進行的,其資料傳輸可以更加寬鬆一些。

<!-- index.html -->
 <script>
    navigator.serviceWorker.register("./sw.js", { scope: "./" }).then(res => {
        window.sw = res;
    })
</script>
// sw.js
self.onmessage = (event) => {
  console.log("Message", event);
  self.message = event;
};

// 控制檯執行 觀察 SW 的資料響應以及值
const buffer = new ArrayBuffer(8);
const input = new Uint8Array(buffer);
input.fill(255);
sw.active.postMessage({ id: "test", buffer })

那麼我們對於需要從Service Worker響應的資料實現就簡單很多了,畢竟我們現在只需要將其當作普通的物件處理就可以了,也不需要考慮任何序列化的問題。此時我們就利用好Promise的特性,當接收到postMessage響應的時候,從全域性的儲存中查詢當前id對應的resolve,並且將攜帶的資料作為引數執行即可,至此我們就可以很方便地進行背壓的反饋了,我們同樣也可以加入一些超時機制等避免resolve的積壓。

// 模擬 onMessage 方法
let onMainMessage: ((event: { id: string; payload: string }) => void) | null = null;
let onWorkerMessage: ((event: { id: string; payload: string }) => void) | null = null;

// 模擬 postMessage 方法
const postToWorker = (id: string, payload: string) => {
  onWorkerMessage?.({ id, payload });
};
const postToMain = (id: string, payload: string) => {
  onMainMessage?.({ id, payload });
};

// Worker
(() => {
  onWorkerMessage = ({ id, payload }) => {
    console.log("Worker Receive", id, payload);
    setTimeout(() => {
      postToMain(id, "pong");
    }, 1000);
  };
})();

// Main
(() => {
  const map = new Map<string, (value: { id: string; payload: string }) => void>();
  onMainMessage = ({ id, payload }) => {
    const resolve = map.get(id);
    resolve?.({ id, payload });
    map.delete(id);
  };
  const post = (payload: string) => {
    const id = Math.random().toString(36).slice(2);
    return new Promise<{ id: string; payload: string }>(resolve => {
      map.set(id, resolve);
      postToWorker(id, payload);
    });
  };
  post("ping").then(res => {
    console.log("Main Receive", res.id, res.payload);
  });
})();

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/6844904029244358670
https://github.com/jimmywarting/StreamSaver.js
https://github.com/jimmywarting/native-file-system-adapter
https://developer.mozilla.org/zh-CN/docs/Web/API/FetchEvent
https://nodejs.org/docs/latest/api/stream.html#types-of-streams
https://developer.mozilla.org/en-US/docs/Web/API/TransformStream
https://help.aliyun.com/zh/oss/user-guide/map-custom-domain-names-5
https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a#download
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition
https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts#backpressure
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm

相關文章