服務端SSE資料代理與基於fetch的EventSource實現

WindRunnerMax發表於2024-11-12

服務端SSE資料代理與基於fetch的EventSource實現

Server-Sent Events(SSE)是一種由伺服器單向推送實時更新到客戶端的方案,基本原理是客戶端透過HTTP請求開啟與服務端的持久連線,服務端可以透過該連線連續傳送事件資料。SSE適用於需要持續更新資料的應用,如實時通知、訊息推送和動態內容更新,相比於WebSocket的資料通訊方案更加輕量,SSE更易於實現且更適合簡單的單向資料流場景。

描述

SSE本質上是使用HTTP長連結以及ReadableStream實現的一種單向資料流方案,客戶端可以保持與伺服器的單向連線,並且能夠持續接收服務端推送的實時事件,而不需要客戶端不斷地向伺服器傳送請求來獲取資料更新。而在瀏覽器中實現了基本的EventSource物件,可以很方便地處理服務端的響應,服務端自然也可以透過不停地對Response物件寫資料來實現流式響應。而在我們實際的業務需求中,無論是服務端和客戶端都不是那麼理想的場景:

  • 服務端預處理響應,在實現類似流式對話的需求過程中,通常我們都是將LLM推理的資料透過服務端轉發到客戶端,而在服務端處理過程中我們就需要對資料進行過濾、審查等操作,因此我們就需要在服務端接受流式響應,進行資料預處理之後再流式響應到客戶端。
  • 服務端資料直接轉發,在不需要進行資料預處理的情況下,如果在服務端接收資料流式響應再將其轉發到客戶端則顯得比較麻煩,因此我們可以直接將請求作為HTTP長連線代理到目標的請求地址,而不需要實際實現接收響應後再轉發到客戶端。
  • 基於fetch請求資料,EventSource物件只能發起GET請求,且無法定義請求頭以及攜帶請求體,這在需要鑑權的情況下就需要將所有的內容編碼到URL上,多數瀏覽器對URL長度上都限制在2000字元,因此基於fetch實現SSE資料請求則可以解決上述問題。

在這裡我們首先來透過EventSource物件來實現基本的SSE,由於EventSource物件是瀏覽器實現的API,是屬於客戶端的實現,因此我們在這裡還需要先使用Node.js實現服務端的資料流式響應,文中涉及的DEMO都在https://github.com/WindRunnerMax/webpack-simple-environment中。

在服務端中實現基本的流式資料響應比較方便,我們首先需要將響應頭設定為text/event-stream;,注意響應頭是需要在響應體之前設定的,否則在執行res.writeHead之前後執行res.write的話會導致響應ERR_INVALID_CHUNKED_ENCODING

// packages/fetch-sse/server/modules/ping.ts
const ping = (req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream; charset=utf-8",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive",
  });
}

SSE實際上是一種協議,那麼既然是協議自然就需要有固定的格式,在text/event-stream的響應格式中,每組資料都是以\n\n分隔的,而在組中的資料如果需要傳遞多種型別,則需要以\n分隔,例如我們需要同時傳遞ideventdata欄位的資料:

id: 1
event: message
data: hello world

id: 2
event: custom
data: hello
data: world

Server-Sent Events事件中,自帶了自動重連與事件id管理方法,當然這些處理都是在瀏覽器預設的EventSource來實現的,如果我們使用fetch來實現則需要自行管理。但是在我們當前的基本示例中是可以生效的,此外我們還可以透過自定義事件名來傳遞訊息,如果僅傳遞:xxx\n的格式也可以作為註釋使用,因此我們在建立連線時可以宣告相關資訊:

// packages/fetch-sse/server/modules/ping.ts
res.write("retry: 10000\n");
res.write("id: -1\n");
res.write("event: connect\n");
res.write("data: " + new Date() + "\n\n");

那麼在客戶端則需要透過EventSource物件建立連線,然後透過自定義事件來接收上述服務端的資料,而實際上如果不指定具體的事件名,即上述的connect事件,則會預設預設為message事件,也就是說這裡的事件名並不是必須的。

// packages/fetch-sse/client/components/ping.tsx
const onConnect = useMemoFn((e: MessageEvent<string>) => {
  prepend("Start Time: " + e.data);
});
const source = new EventSource("/ping");
source.addEventListener("connect", onConnect);

針對於預設的message事件,我們同樣在服務端將其輸出,我們先前也提到了只要我們不呼叫res.end則會導致整個連線處於掛起狀態,那麼在這裡如果我們希望保持連線的話則只需要透過定時器不斷地向客戶端傳送資料即可。

// packages/fetch-sse/server/modules/ping.ts
let index = 0;
const interval = setInterval(() => {
  res.write("id: " + index++ + "\n");
  res.write("data: " + new Date() + "\n\n");
}, 1000);

而在客戶端我們可以為source物件新增onmessage事件繫結,也可以直接addEventListener(message)來繫結事件。此外,當我們成功透過EventSource物件建立連線後,我們可以在瀏覽器控制檯的Network皮膚看到EventStream的資料傳輸皮膚,我們定義的idtypedatatime都會在此處顯示。

// packages/fetch-sse/client/components/ping.tsx
const prepend = (text: string) => {
  const el = ref.current;
  if (!el) return;
  const child = document.createElement("div");
  child.textContent = text;
  el.prepend(child);
};

const onMessage = (e: MessageEvent<string>) => {
  prepend("Ping: " + e.data);
};

const source = new EventSource("/ping");
source.onmessage = onMessage;

在服務端我們還需要注意的是,當使用者的客戶端連線關閉我們同樣也需要關閉服務端的請求,以此來避免額外資源的佔用,當然在我們這裡的定時器中如果不關閉的話就是記憶體洩漏而不僅僅是額外的資源佔用了。

req.socket.on("close", () => {
  console.log("[ping] connection close");
  clearInterval(interval);
  res.end();
});

此外,當不透過HTTP/2建立連線時,SSE對於單個域名會受到最大連線數的限制,這在開啟多個選項卡時會比較麻煩,該限制是瀏覽器針對資料請求設計的,並且被設定為一個非常低的6個連線數量。此限制是針對每個域的請求,因此這意味著我們可以跨所有選項卡開啟6SSE連線到www.example1.com,以及同時開啟6SSE連線到www.example2.com,而使用HTTP/2時,同一時間內HTTP最大連線數由伺服器和客戶端之間協商,預設為100

服務端

在服務端處理資料轉發與代理之前,我們自然需要定義整個事件的資料來源。在這裡我們並沒有必要實際對接例如OpenAICoze的流式響應,只需要模擬一下即可,那麼在這裡我們就先定義/stream介面來模擬流式輸出。在這裡需要注意的一點是,通常我們的輸出都是Markdown的格式,那麼這裡自然會有\n的符號,而在SSE協議中\n是需要作為關鍵字使用的,因此我們就需要對其編碼/解碼,以此來避免\n關鍵字,那麼無論是使用JSON.stringify抑或是encodeURIComponent都是可以的,在這裡我們簡單一些,直接將\n替換為\\n

// packages/fetch-sse/server/modules/stream.ts
const content = `# 出師表

- 諸葛亮 

先帝創業未半而中道崩殂,今天下三分,益州疲弊,此誠危急存亡之秋也。然侍衛之臣不懈於內,忠志之士忘身於外者,蓋追先帝之殊遇,欲報之於陛下也。誠宜開張聖聽,以光先帝遺德,恢弘志士之氣,不宜妄自菲薄,引喻失義,以塞忠諫之路也。

...

今當遠離,臨表涕零,不知所言。`.replace(/\n/g, " \\n");

在此處設定響應頭等處理就不再過多敘述了,在實際模型推理的過程中可能會有兩種輸出,一種是將本次對話的所有內容都輸出,類似於對字串的slice永遠是從0開始,另一種則是隻輸出最新內容delta,類似於slice時記錄了上次輸出的end作為下次輸出的start。在這裡我們簡單一些,選用第一種方式將內容從0開始輸出不斷推送到客戶端。

由於是模擬流式輸出,因此我們直接設定一個定時器,並且隨機生成本次輸出的步進長度,然後將其作為新的start記錄下來,緊接著將內容輸出到客戶端,這裡我們直接使用預設的message事件即可,當輸出到最後時則關閉定時器並且關閉連線。當然,我們也不能忽略當連線的客戶端關閉時,需要主動清理當前的定時器避免服務端計算資源浪費。

// packages/fetch-sse/server/modules/stream.ts
res.write("event: connect\n");
res.write("data: " + Date.now() + "\n\n");

let start = 0;
const interval = setInterval(() => {
  const slice = Math.floor(Math.random() * 30) + 1;
  start = start + slice;
  res.write("event: message\n");
  res.write("data: " + content.slice(0, start) + "\n\n");
  if (start >= content.length) {
    clearInterval(interval);
    res.end();
  }
}, 500);

req.socket.on("close", () => {
  console.log("[stream] connection close");
  clearInterval(interval);
  res.end();
});

資料轉發

當定義好資料來源介面之後,我們就可以開始實現資料轉發的功能,用以實現服務端預處理響應,也就是在這裡我們可以對資料進行過濾、審查等操作。因此我們就需要在服務端接受流式響應,進行資料預處理之後再流式響應到客戶端。那麼在這個轉發介面中首先我們就需要對資料來源介面發起請求,在這裡我們直接使用node-fetch來發起請求。

// packages/fetch-sse/server/modules/transfer.ts
import fetch from "node-fetch";
const response = await fetch("http://127.0.0.1:8800/stream")

使用node-fetch的過程中需要注意我們是直接使用ts-node啟動的服務,因此還是如果CJS混入ESM的話會導致丟擲異常,因此這裡我們需要選擇2.x版本。此外,我們還需要定義好AbortController,以便在客戶端關閉連線時及時終止請求,在node-fetchres.body依然可以讀取ReadableStream,以此來處理轉發的SSE響應。

// packages/fetch-sse/server/modules/transfer.ts
const ctrl = new AbortController();
const response = await fetch("http://127.0.0.1:8800/stream", {
  signal: ctrl.signal as AbortSignal,
});
const readable = response.body;
if (!readable) return null;

req.socket.on("close", () => {
  console.log("[transfer] connection close");
  ctrl.abort();
  res.end();
});

在服務端我們是沒有EventSource物件來接收資料的,那麼我們自然只能根據SSE協議來自行解析資料,而既然我們是透過ReadableStream來實現的資料讀取,那麼我們就需要流式地處理二進位制資料,而不能直接解析分隔。因此在這裡我們實現StreamParser,當接收到Uint8Array二進位制資料後,我們首先將其合併為新的buffer,然後遍歷當前資料,當遇到\n時則排程到onLine方法來處理資料。

// packages/fetch-sse/server/utils/steam-parser.ts
export class StreamParser {
  private compose(data: Uint8Array) {
    const buffer = new Uint8Array(this.buffer.length + data.length);
    buffer.set(this.buffer);
    buffer.set(data, this.buffer.length);
    this.buffer = buffer;
    return buffer;
  }

  public onBinary(bytes: Uint8Array) {
    const buffer = this.compose(bytes);
    const len = buffer.length;
    let start = 0;

    for (let i = 0; i < len; i++) {
      if (buffer[i] === 10) {
        this.onLine(buffer.slice(start, i));
        start = i + 1;
      }
    }
    this.buffer = buffer.slice(start);
  }
}

當處理到onLine時,我們就需要根據SSE協議來按行解析資料了,我們將要處理的資料格式將是x: xxx;,在我們的處理下\n是作為末尾節點不會被傳參,那麼此時如果我們的資料傳遞長度為0,那麼就需要發起onMessage事件,將事件名與資料全部傳遞帶預設的事件處理函式中。在其後我們就可以使用TextDecoder來解析為字串,然後就可以根據:來分隔與解析資料了。

// packages/fetch-sse/server/utils/steam-parser.ts
export class StreamParser {
  private onLine(bytes: Uint8Array) {
    if (bytes.length === 0) {
      if (this.onMessage && this.message.event) {
        this.message.data = this.message.data || "";
        this.onMessage(this.message as Message);
      }
      this.message = {};
      return;
    }
    const decoder = new TextDecoder();
    const line = decoder.decode(bytes);
    const [field, ...rest] = line.split(":");
    const value = rest.join(":").trim();
    switch (field) {
      case "id":
        this.message.id = value;
        break;
      case "event":
        this.message.event = value;
        break;
      case "data":
        this.message.event = this.message.event || "message";
        this.message.data = value;
        break;
      default:
        break;
    }
  }
}

這裡需要注意的是,在Node中的ReadableStream與瀏覽器實現的ReadableStream函式簽名是不一樣的,因此這裡我們直接方便地使用await迭代資料即可,當然使用on("data") on("end")來接收資料與結束響應即可。我們還需要繫結onMessage事件來接收解析好的資料,並且將資料響應到目標客戶端即可。

// packages/fetch-sse/server/utils/steam-parser.ts
const parser = new StreamParser();
parser.onMessage = message => {
  res.write(`event: ${message.event}\n`);
  res.write(`data: ${message.data}\n\n`);
};

for await (const chunk of readable) {
  const buffer = chunk as Buffer;
  const uint = new Uint8Array(buffer);
  parser.onBinary(uint);
}

res.end();

請求代理

當不需要進行資料預處理的情況下,我們可以直接將請求作為HTTP長連線代理到目標的請求地址,而不需要實際實現接收響應後再轉發到客戶端。在這裡我們可以直接藉助http模組來實現轉發,首先需要node:url模組來解析目標地址,然後就可以透過http.request來發起請求,當建立連線之後就可以直接將資料pipe到目標的Response物件中,當然使用proxyRes.on("data") + res.write也是可以的。

// packages/fetch-sse/server/modules/proxy.ts
const targetUrl = new URL("http://127.0.0.1:8800/stream");
const options: http.RequestOptions = {
  hostname: targetUrl.hostname,
  port: targetUrl.port,
  path: targetUrl.pathname,
  method: req.method,
  headers: req.headers,
};
const proxyReq = http.request(options, proxyRes => {
  res.writeHead(proxyRes.statusCode || 404, proxyRes.headers);
  proxyRes.pipe(res);
});

這裡我們自然還需要處理一些特殊情況,首先是對於POST請求的body資料處理,我們需要將請求的所有資料同樣轉發到新的請求上,這裡同樣也可以使用req.on("data") + proxyReq.write來實現。而對異常處理我們也需要將響應錯誤資訊傳遞到客戶端,這裡的錯誤碼響應還是比較重要的,並且將對目標的請求關閉。當客戶端的請求關閉之後,同樣需要關閉目標的請求,以及結束響應。

req.pipe(proxyReq);

proxyReq.on("error", error => {
  console.log("proxy error", error);
  res.writeHead(502, { "Content-Type": "text/plain" });
  res.end("Bad Gateway");
});

req.socket.on("close", () => {
  console.log("[proxy] connection close");
  res.end();
  proxyReq.destroy();
});

其實在這裡還有個問題,如果使用req.on("close")來監聽客戶端的連線關閉,那麼在POST請求中會出現問題。我們可以直接執行下面的node程式,然後就可以使用curl來發起請求,之後主動斷開連結,然後就可以發現req.on("close")會觸發地過早,而不是在我們主動斷開請求之後才會執行。

echo "
const http = require('http');
const server = http.createServer((req, res) => {
  req.on('close', () => {
    console.log('close');
  });
  req.on('data', (chunk) => {
    console.log('data:', new TextDecoder().decode(chunk));
  });
  setTimeout(() => res.end('end'), 10000);
});
server.listen(8001);
" | node;
curl -X POST http://127.0.0.1:8001 \
-H "Content-Type: application/json"  \
-d '{"key1":"value1", "key2":"value2"}'

實際上在這裡我們的請求中存在req.on("close")res.on("close")req.socket.on("close")這三個事件,在req的事件會被上述攜帶body的資料所影響,因此此處可以使用ressocket上的事件來監聽客戶端的連線關閉,為了方便我們的事件觸發,在此處我們直接使用socket上的事件來監聽客戶端的連線關閉,此外socket屬性在node16前的屬性名為connection

echo "
const http = require('http');
const server = http.createServer((req, res) => {
  res.on('close', () => {
    console.log('res close');
  });
  req.socket.on('close', () => {
    console.log('socket close');
  });
  req.on('data', (chunk) => {
    console.log('data:', new TextDecoder().decode(chunk));
  });
  setTimeout(() => res.end('end'), 10000);
});
server.listen(8001);
" | node;
curl -X POST http://127.0.0.1:8001 \
-H "Content-Type: application/json"  \
-d '{"key1":"value1", "key2":"value2"}'

客戶端

在客戶端我們則需要基於fetch實現SSE,透過fetch可以傳遞請求頭與請求體,並且可以傳送POST等型別的請求,避免僅能傳送GET請求而需要將所有內容編碼到URL上的問題。如果連線中斷,我們還可以控制重試策略,對於EventSource物件瀏覽器將默默地為您重試幾次然後停止,這對於任何型別的強大應用程式來說都不夠好。如果需要在解析事件源之前進行一些自定義驗證與處理,也可以訪問響應物件,這對於應用服務端程式前的API閘道器等設計非常有效。

fetch實現

基於fetch的實現實際上還是比較簡單的,我們首先需要建立一個AbortController物件,以便在客戶端關閉連線時及時終止請求,然後就可以透過fetch來發起請求,當請求成功後我們就可以透過res.body來讀取ReadableStream

// packages/fetch-sse/client/components/fetch.tsx
const signal = new AbortController();
fetch("/proxy", { method: "POST", signal: signal.signal })
  .then(res => {
    onOpen(res);
    const body = res.body;
    if (!body) return null;
  })

對於資料的流式處理,與在服務端實現的StreamParser的方法是一致的,先前我們也提到了由於ReadableStream的函式簽名不同,在這裡我們就使用Promise的鏈式呼叫來處理,而對於Uint8Array資料的處理,則與先前保持一致。在這裡實際上還有個有趣的事情,使用EventSource物件在瀏覽器控制檯的Network中是可以看到EventStream的資料傳輸皮膚,而使用fetch的資料交換則是無法記錄的。

// packages/fetch-sse/client/components/fetch.tsx
const reader = body.getReader();
const parser = new StreamParser();
parser.onMessage = onMessage;
const process = (res: ReadableStreamReadResult<Uint8Array>) => {
  if (res.done) return null;
  parser.onBinary(res.value);
  reader
    .read()
    .then(process)
    .catch(() => null);
};
reader.read().then(process);

流式互動

當我們的資料傳輸方案實現之後,我們就可以在客戶端實現流式的互動。當我們藉助StreamParser方法來解析出行資料之後,就需要進行解碼操作,這個方法與上述的編碼方案是相反的,此處只需要將\\n替換為\n即可。然後在這裡我們設定兩種速度的輸出互動,如果未輸出的文字內容過多,則10ms來輸出一個文字,否則就以50ms的速度輸出文字。

// packages/fetch-sse/client/components/stream.tsx
const onMessage = useMemoFn((e: Message) => {
  if (e.event !== "message") return null;
  setPainting(true);
  const data = e.data;
  const text = data.replace(/\\n/g, "\n");
  const start = currentIndex.current;
  const len = text.length;
  const delay = len - start > 50 ? 10 : 50;
  const process = () => {
    currentIndex.current++;
    const end = currentIndex.current;
    append(text.slice(0, end));
    if (end < len) {
      timer.current = setTimeout(process, delay);
    }
    if (!transmittingRef.current && end >= len) {
      setPainting(false);
    }
  };
  setTimeout(process, delay);
});

當我們將資料解析出來後,就需要將其應用到DOM結構上,這裡需要注意的一點是,如果我們全量重新整理整個DOM內容的話,會導致我們無法選中先前輸出的內容來複制,也就是說我們不能一邊輸出內容一邊選中內容。因此在這裡我們需要將更新的內容精細化,最簡單的方案就是按行更新,我們可以記錄上次渲染的行索引,更新範圍則是上次索引到當前索引。

// packages/fetch-sse/client/components/stream.tsx
const append = (text: string) => {
  const el = ref.current;
  if (!el) return null;
  const mdIt = MarkdownIt();
  const textHTML = mdIt.render(text);
  const dom = new DOMParser().parseFromString(textHTML, "text/html");
  const current = currentDOMIndex.current;
  const children = Array.from(el.children);
  for (let i = current; i < children.length; i++) {
    children[i] && children[i].remove();
  }
  const next = dom.body.children;
  for (let i = current; i < next.length; i++) {
    next[i] && el.appendChild(next[i].cloneNode(true));
  }
  currentDOMIndex.current = next.length - 1;
};

在這裡還有個滾動互動的問題需要處理,當使用者自由滾動內容的時候,我們則不能將使用者滾動的位置強制拉回到底部,因此我們需要記錄使用者是否滾動過,當使用者滾動過的時候我們就不再自動滾動,如果el.scrollHeight - el.scrollTopel.clientHeight的差值小於1的話,則認為應該自動滾動,此外這裡還需要注意scrollTo不能使用smooth的滾動效果,這樣會導致我們的onScroll滾動計算不準確。

const append = (text: string) => {
  isAutoScroll.current && el.scrollTo({ top: el.scrollHeight });
};

useEffect(() => {
  const el = ref.current;
  if (!el) return;
  el.onscroll = () => {
    if (el.scrollHeight - el.scrollTop - el.clientHeight <= 1) {
      isAutoScroll.current = true;
    } else {
      isAutoScroll.current = false;
    }
  };
  return () => {
    el.onscroll = null;
  };
}, []);

在這裡的流式輸出中,我們還可以實現游標閃爍效果,這個效果比較簡單,我們可以直接使用CSS的動畫與偽類來實現,這裡需要注意的是如果不使用偽類來實現的話,則會導致我們先前的DOM節點追加需要處理的問題則需要多一些。此外,由於處理Markdown實際上是會存在節點的巢狀的,因此對於節點的處理則需要:not來具體化處理。

// packages/fetch-sse/client/styles/stream.m.scss
@keyframes blink {
  0% { opacity: 1; }
  50% { opacity: 0; }
  100% { opacity: 1; }
}

.textarea {
  &.painting > *:last-child:not(ol):not(ul),
  &.painting > ol:last-child > li:last-child,
  &.painting > ul:last-child > li:last-child {
    &::after {
      animation: blink 1s infinite;
      background-color: #000;
      content: '';
      display: inline-block;
      height: 1em;
      margin-top: -2px;
      vertical-align: middle;
      width: 1px;
    }
  }
}

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://github.com/Azure/fetch-event-source
https://developer.mozilla.org/zh-CN/docs/Web/API/EventSource
https://www.ruanyifeng.com/blog/2017/05/server-sent_events.html
https://nodejs.org/docs/latest-v20.x/api/http.html#messagesocket
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
https://stackoverflow.com/questions/7348736/how-to-check-if-connection-was-aborted-in-node-js-server
https://stackoverflow.com/questions/76115409/why-does-node-js-express-call-request-close-on-post-request-with-data-before-r

相關文章