ChatGPT 打字機消息回覆實現原理

🔔概述:

相較於繁重的 WebSockets,SSE 無疑是 H5 簡單即時數據更新的輕量級代替方案。

1 背景

​ 在使用 ChatGPT 時,發現輸入 prompt 後,頁面是逐步給出回覆的,起初以爲使用了 WebSckets 持久化連接協議,查看其網絡請求,發現這個接口的通信方式並非傳統的 http 接口或者 WebSockets,而是基於 EventStream 的事件流,像打字機一樣,一段一段的返回答案。

​ ChatGPT 是一個基於深度學習的大型語言模型,處理自然語言需要大量的計算資源和時間,響應速度肯定比普通的讀數據庫要慢的多,普通 http 接口等待時間過長,顯然並不合適。對於這種單項對話場景,ChagtGPT 將先計算出的數據“推送”給用戶,邊計算邊返回,避免用戶因爲等待時間過長關閉頁面。而這,可以採用 SSE 技術。

2023-04-09 16.09.32

2 概述

​ Server-Sent Events 服務器推送事件,簡稱 SSE,是一種服務端實時主動向瀏覽器推送消息的技術。

​ SSE 是 HTML5 中一個與通信相關的 API,主要由兩部分組成:服務端與瀏覽器端的通信協議(HTTP 協議)及瀏覽器端可供 JavaScript 使用的 EventSource 對象。

​ 從“服務端主動向瀏覽器實時推送消息”這一點來看,該 API 與 WebSockets API 有一些相似之處。但是,該 API 與 WebSockers API 的不同之處在於:

Server-Sent Events API WebSockets API
基於 HTTP 協議 基於 TCP 協議
單工,只能服務端單向發送消息 全雙工,可以同時發送和接收消息
輕量級,使用簡單 相對複雜
內置斷線重連和消息追蹤的功能 不在協議範圍內,需手動實現
文本或使用 Base64 編碼和 gzip 壓縮的二進制消息 類型廣泛
支持自定義事件類型 不支持自定義事件類型
連接數 HTTP/1.1 6 個,HTTP/2 可協商(默認 100) 連接數無限制

3 服務端實現

3.1 協議

​ SSE 協議非常簡單,本質是瀏覽器發起 http 請求,服務器在收到請求後,返回狀態與數據,並附帶以下 headers:

 
js
複製代碼
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
  • SSE API規定推送事件流的 MIME 類型爲 text/event-stream
  • 必須指定瀏覽器不緩存服務端發送的數據,以確保瀏覽器可以實時顯示服務端發送的數據。
  • SSE 是一個一直保持開啓的 TCP 連接,所以 Connection 爲 keep-alive。

3.2 消息格式

​ EventStream(事件流)爲 UTF-8 格式編碼的文本或使用 Base64 編碼和 gzip 壓縮的二進制消息。

​ 每條消息由一行或多行字段(eventidretrydata)組成,每個字段組成形式爲:字段名:字段值。字段以行爲單位,每行一個(即以 \n 結尾)。以冒號開頭的行爲註釋行,會被瀏覽器忽略。

​ 每次推送,可由多個消息組成,每個消息之間以空行分隔(即最後一個字段以\n\n結尾)。

📢 注意:

  • 除上述四個字段外,其他所有字段都會被忽略。
  • 如果一行字段中不包含冒號,則整行文本將被視爲字段名,字段值爲空。
  • 註釋行可以用來防止鏈接超時,服務端可以定期向瀏覽器發送一條消息註釋行,以保持連接不斷。

3.2.1 event

​ 事件類型。如果指定了該字段,則在瀏覽器收到該條消息時,會在當前 EventSource 對象(見 4)上觸發一個事件,事件類型就是該字段的字段值。可以使用 addEventListener 方法在當前 EventSource 對象上監聽任意類型的命名事件。

​ 如果該條消息沒有 event 字段,則會觸發 EventSource 對象 onmessage 屬性上的事件處理函數。

3.2.2 id

​ 事件ID。事件的唯一標識符,瀏覽器會跟蹤事件ID,如果發生斷連,瀏覽器會把收到的最後一個事件ID放到 HTTP Header Last-Event-Id 中進行重連,作爲一種簡單的同步機制。

​ 例如可以在服務端將每次發送的事件ID值自動加 1,當瀏覽器接收到該事件ID後,下次與服務端建立連接後再請求的 Header 中將同時提交該事件ID,服務端檢查該事件ID是否爲上次發送的事件ID,如果與上次發送的事件ID不一致則說明瀏覽器存在與服務器連接失敗的情況,本次需要同時發送前幾次瀏覽器未接收到的數據。

3.2.3 retry

​ 重連時間。整數值,單位 ms,如果與服務器的連接丟失,瀏覽器將等待指定時間,然後嘗試重新連接。如果該字段不是整數值,會被忽略。

​ 當服務端沒有指定瀏覽器的重連時間時,由瀏覽器自行決定每隔多久與服務端建立一次連接(一般爲 30s)。

3.2.4 data

​ 消息數據。數據內容只能以一個字符串的文本形式進行發送,如果需要發送一個對象時,需要將該對象以一個 JSON 格式的字符串的形式進行發送。在瀏覽器接收到該字符串後,再把它還原爲一個 JSON 對象。

3.3 示例

​ 如下事件流示例,共發送了 4 條消息,每條消息間以一個空行作爲分隔符。

​ 第一條僅僅是個註釋,因爲它以冒號開頭。

​ 第二條消息只包含一個 data 字段,值爲 'this is second message'。

​ 第三條消息包含兩個 data 字段,其會被解析爲一個字段,值爲 'this is third message part 1\nthis is third message part 2'。

​ 第四條消息包含完整四個字段,指定了事件類型爲 'server-time',事件id 爲 '1',重連時間爲 '30000'ms,消息數據爲 JSON 格式的 '{"text": "this is fourth message", "time": "12:00:00"}'。

 
js
複製代碼
: this is first message\n\n

data: this is second message\n\n

data: this is third message part one\n
data this is third message part two\n\n

event: server-time\n
id: 1
retry: 30000\n
data: {"text": "this is fourth message", "time": "2023-04-09 12:00:00"}\n\n

4 瀏覽器 API

​ 在瀏覽器端,可以使用 JavaScript 的 EventSource API 創建 EventSource 對象監聽服務器發送的事件。一旦建立連接,服務器就可以使用 HTTP 響應的 'text/event-stream' 內容類型發送事件消息,瀏覽器則可以通過監聽 EventSource 對象的 onmessageonopenonerror 事件來處理這些消息。

4.1 建立連接

​ EventSource 接受兩個參數:URL 和 options。

​ URL 爲 http 事件來源,一旦 EventSource 對象被創建後,瀏覽器立即開始對該 URL 地址發送過來的事件進行監聽。

​ options 是一個可選的對象,包含 withCredentials 屬性,表示是否發送憑證(cookie、HTTP認證信息等)到服務端,默認爲 false。

 
js
複製代碼
const eventSource = new EventSource('http_api_url', { withCredentials: true })

​ 與 XMLHttpRequest 對象類型,EventSource 對象有一個 readyState 屬性值,具體含義如下表:

readyState 含義
0 瀏覽器與服務端尚未建立連接或連接已被關閉
1 瀏覽器與服務端已成功連接,瀏覽器正在處理接收到的事件及數據
2 瀏覽器與服務端建立連接失敗,客戶端不再繼續建立與服務端之間的連接

​ 可以使用 EventSource 對象的 close 方法關閉與服務端之間的連接,使瀏覽器不再建立與服務端之間的連接。

 
js
複製代碼
// 初始化 eventSource 等省略

// 關閉連接
eventSource.close()

4.2 監聽事件

​ EventSource 對象本身繼承自 EventTarget 接口,因此可以使用 addEventListener() 方法來監聽事件。EventSource 對象觸發的事件主要包括以下三種:

  • open 事件:當成功連接到服務端時觸發。
  • message 事件:當接收到服務器發送的消息時觸發。該事件對象的 data 屬性包含了服務器發送的消息內容。
  • error 事件:當發生錯誤時觸發。該事件對象的 event 屬性包含了錯誤信息。
 
js
複製代碼
// 初始化 eventSource 等省略

eventSource.addEventListener('open', function(event) {
  console.log('Connection opened')
})

eventSource.addEventListener('message', function(event) {
  console.log('Received message: ' + event.data);
})

// 監聽自定義事件
eventSource.addEventListener('xxx', function(event) {
  console.log('Received message: ' + event.data);
})

eventSource.addEventListener('error', function(event) {
  console.log('Error occurred: ' + event.event);
})

​ 當然,也可以採用屬性監聽(onopenonmessageonerror)的形式。

 
js
複製代碼
/ 初始化 eventSource 等省略

eventSource.onopen = function(event) {
  console.log('Connection opened')
}

eventSource.onmessage = function(event) {
  console.log('Received message: ' + event.data);
}

eventSource.onerror = function(event) {
  console.log('Error occurred: ' + event.event);
})

📢注意:

EventSource 對象的屬性監聽只能監聽預定義的事件類型(openmessageerror)。不能用於監聽自定義事件類型。如果要實現自定義事件類型的監聽,可以使用 addEventListener() 方法。

5 實踐

5.1 服務端

​ 使用 Node.js 實現 SSE 的簡單示例:

 
js
複製代碼
const http = require('http')
const fs = require('fs')

http.createServer((req, res) => {
  const url = req.url
  if (url === '/' || url === 'index.html') {
    // 如果請求根路徑,返回 index.html 文件
    fs.readFile('index.html', (err, data) => {
      if (err) {
        res.writeHead(500)
        res.end('Error loading')
      } else {
        res.writeHead(200, {'Content-Type': 'text/html'})
        res.end(data)
      }
    })
  } else if (url.includes('/sse')) {
    // 如果請求 /events 路徑,建立 SSE 連接
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*', // 允許跨域
    })

    // 每隔 1 秒發送一條消息
    let id = 0
    const intervalId = setInterval(() => {
      res.write(`event: customEvent\n`)
      res.write(`id: ${id}\n`)
      res.write(`retry: 30000\n`)
      const params = url.split('?')[1]
      const data = { id, time: new Date().toISOString(), params }
      res.write(`data: ${JSON.stringify(data)}\n\n`)
      id++
      if (id >= 10) {
        clearInterval(intervalId)
        res.end()
      }
    }, 1000)

    // 當客戶端關閉連接時停止發送消息
    req.on('close', () => {
      clearInterval(intervalId)
      id = 0
      res.end()
    })
  } else {
    // 如果請求的路徑無效,返回 404 狀態碼
    res.writeHead(404)
    res.end()
  }
}).listen(3000)

console.log('Server listening on port 3000')

5.2 瀏覽器

 
html
複製代碼
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSE Demo</title>
</head>
<body>
  <h1>SSE Demo</h1>
  <button onclick="connectSSE()">建立 SSE 連接</button>  
  <button onclick="closeSSE()">斷開 SSE 連接</button>
  <br />
  <br />
  <div id="message"></div>

  <script>
    const messageElement = document.getElementById('message')

    let eventSource

    // 建立 SSE 連接
    const connectSSE = () => {
      eventSource = new EventSource('http://127.0.0.1:3000/sse?content=xxx')

      // 監聽消息事件
      eventSource.addEventListener('customEvent', (event) => {
        const data = JSON.parse(event.data)
        messageElement.innerHTML += `${data.id} --- ${data.time} --- params參數:${JSON.stringify(data.params)}` + '<br />'
      })

      eventSource.onopen = () => {
        messageElement.innerHTML += `SSE 連接成功,狀態${eventSource.readyState}<br />`
      }

      eventSource.onerror = () => {
        messageElement.innerHTML += `SSE 連接錯誤,狀態${eventSource.readyState}<br />`
      }
    }

    // 斷開 SSE 連接
    const closeSSE = () => {
      eventSource.close()
      messageElement.innerHTML += `SSE 連接關閉,狀態${eventSource.readyState}<br />`
    }
  </script>
</body>
</html>

​ 將上面的兩份代碼保存爲 server.jsindex.html,並在命令行中執行 node server.js 啓動服務端,然後在瀏覽器中打開 http://localhost:3000 即可看到 SSE 效果。

2023-05-09 21.12.02

6 兼容性

​ 發展至今,SSE 已具有廣泛的的瀏覽器兼容性,幾乎除 IE 之外的瀏覽器均已支持。

image-20230409024847028

​ 對於不支持 EventSource 的瀏覽器,可以使用 polyfill 實現。判斷瀏覽器是否支持 EventSource:

 
js
複製代碼
if(typeof(EventSource) !== “undefined”) {
	// 支持
} else {
	// 不支持,使用 polyfill
}

7 Fetch 實現

​ 雖然使用 SSE 技術可以實現 ChatGPT 一樣的打字機效果,但是通過上文請求 type 對比可以發現,在使用 SSE 時,type 爲 eventSource,而 ChatGPT 爲 fetch。且受瀏覽器 EventSource API 限制,在使用 SSE 時不能自定義請求頭、只能發出 GET 請求,且在大多數瀏覽器中,URL 限制 2000個字符,也無法滿足 ChatGPT 參數傳遞需求。

​ 此時,可以使用 Fetch API 實現一個替代接口,用於模擬 SSE 實現。簡單實現如下:

7.1 服務端

 
js
複製代碼
const http = require('http')
const fs = require('fs')

http.createServer((req, res) => {
  const url = req.url
  if (url === '/' || url === 'index-fetch.html') {
    // 如果請求根路徑,返回 ndex-fetch.html 文件
    fs.readFile('index-fetch.html', (err, data) => {
      if (err) {
        res.writeHead(500)
        res.end('Error loading')
      } else {
        res.writeHead(200, {'Content-Type': 'text/html'})
        res.end(data)
      }
    })
  } else if (url.includes('/fetch-sse')) {
    // 如果請求 /events-fetch 路徑,建立連接
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*', // 允許跨域
    })
    let body = ''
    req.on('data', chunk => {
      body += chunk
    })
    
    // 每隔 1 秒發送一條消息
    let id = 0
    const intervalId = setInterval(() => {
      const data = { id, time: new Date().toISOString(), body: JSON.parse(body) }
      res.write(JSON.stringify(data))
      id++
      if (id >= 10) {
        clearInterval(intervalId)
        res.end()
      }
    }, 1000)

    // 當客戶端關閉連接時停止發送消息
    req.on('close', () => {
      clearInterval(intervalId)
      id = 0
      res.end()
    })
  } else {
    // 如果請求的路徑無效,返回 404 狀態碼
    res.writeHead(404)
    res.end()
  }
}).listen(3001)

console.log('Server listening on port 3001')

7.2 瀏覽器

 
html
複製代碼
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>fetchSSE Demo</title>
</head>
<body>
  <h1>fetchSSE Demo</h1>
  <button onclick="connectFetch()">建立 fetchSSE 連接</button>
  <button onclick="closeSSE()">斷開 fetchSSE 連接</button>
  <br />
  <br />
  <div id="message"></div>

  <script>
    const messageElement = document.getElementById('message')
    let controller

    // 建立 FETCH-SSE 連接
    const connectFetch = () => {
      controller = new AbortController()
      fetchEventSource('http://127.0.0.1:3001/fetch-sse', {
        method: 'POST',
        body: JSON.stringify({
          content: 'xxx'
        }),
        signal: controller.signal,
        onopen: () => {
          messageElement.innerHTML += `FETCH 連接成功<br />`
        },
        onclose: () => {
          messageElement.innerHTML += `FETCH 連接關閉<br />`
        },
        onmessage: (event) => {
          const data = JSON.parse(event)
          messageElement.innerHTML += `${data.id} --- ${data.time} --- body參數:${JSON.stringify(data.body)}` + '<br />'
        },
        onerror: (e) => {
          console.log(e)
        }
      })
    }

    // 斷開 FETCH-SSE 連接
    const closeSSE = () => {
      if (controller) {
        controller.abort()
        controller = undefined
        messageElement.innerHTML += `FETCH 連接關閉<br />`
      }
    }


    const fetchEventSource = (url, options) => {
      fetch(url, options)
        .then(response => {
          if (response.status === 200) {
            options.onopen && options.onopen()
            return response.body
          }
        })
        .then(rb => {
          const reader = rb.getReader()
            const push = () => {
              // done 爲數據流是否接收完成,boolean
              // value 爲返回數據,Uint8Array
              return reader.read().then(({done, value}) => {
                if (done) {
                  options.onclose && options.onclose()
                  return
                }
                options.onmessage && options.onmessage(new TextDecoder().decode(value))
                // 持續讀取流信息
                return push()
              })
            }
            // 開始讀取流信息
            return push()
        })
        .catch((e) => {
          options.error && options.error(e)
        })
    }
</script>

</html>

💡不同於 XMLHttpRequestfetch 並未原生提供終止操作方法,可以通過 DOM API [AbortController](https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController)AbortSignal 實現 fetch 請求終止操作。

​ 將上面的兩份代碼保存爲 server-fetch.jsindex-fetch.html,並在命令行中執行 node server-fetch.js 啓動服務端,然後在瀏覽器中打開 http://localhost:3001 即可看到 fetch 版 SSE 效果。

2023-05-09 21.14.46

8 總結

​ SSE 技術是一種輕量級的實時通信技術,基於 HTTP 協議,具有服務端推送、斷線重連、簡單輕量等優點。但是,SSE 技術也有一些缺點,如不能進行雙向通信、連接數受限、僅支持 get 請求等。

​ SSE 可以在 Web 應用程序中實現諸如股票在線數據、日誌推送、聊天室實時人數等即時數據推送功能。需要注意的是,SSE 並不是適用於所有的實時推送場景。在需要高併發、高吞吐量和低延遲的場景下,WebSockets 可能更加適合。而在需要更輕量級的推送場景下,SSE 可能更加適合。因此,在選擇即時更新方案時,需要根據具體的需求和場景進行選擇。

 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章