Nodejs教程20:WebSocket之二:用原生實現WebSocket應用

閱讀更多系列文章請訪問個人GitHub博客,示例代碼請訪問這裏

原生實現WebSocket應用

上一節使用了Socket.io實現WebSocket,也是開發中經常使用的方式。html

但這樣不利於瞭解其原理,這一節使用Nodejs的Net模塊和Web端的WebSocket API實現WebSocket服務器。前端

示例代碼:/lesson20/server.js,/lesson20/index.htmlgit

1. 服務端建立一個Net服務器

// 引入net模塊
const net = require('net')

// 使用net模塊建立服務器,返回的是一個原始的socket對象,與Socket.io的socket對象不一樣。
const server = net.createServer((socket) => {
  
})

server.listen(8080)
複製代碼

2. Web端建立一個WebSocket連接

建立一個WebSocket鏈接,此時控制檯的Network模塊能夠看到一個處於pending狀態的HTTP鏈接。github

這個鏈接是一個HTTP請求,與普通HTTP請求的請求你頭相比,增長了如下內容:web

Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits // 擴展信息算法

Sec-WebSocket-Key: O3PKSb95qaSB7/+XfaTg7Q== // 發送一個Key到服務端,用於校驗服務端是否支持WebSocket數組

Sec-WebSocket-Version: 13 // WebSocket版本bash

Upgrade: websocket // 告知服務器通訊協議將會升級到WebSocket若服務器支持則繼續下一步服務器

const ws = new WebSocket('ws://localhost:8080/')
複製代碼

3. 服務端使用socket.once,觸發一次data事件處理HTTP請求頭數據

socket.once('data', (buffer) => {
  // 接收到HTTP請求頭數據
  const str = buffer.toString()
  console.log(str)
})
複製代碼

打印結果以下:websocket

GET / HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Origin: file://
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
like Gecko) Chrome/72.0.3626.121 Safari/537.36
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: _ga=GA1.1.1892261700.1545540050; _gid=GA1.1.774798563.1552221410; io=7X0VY8jhwRTdRHBfAAAB
Sec-WebSocket-Key: JStOineTIKaQskxefzer7Q==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

複製代碼

將回車符轉換爲\r\n顯示,結果以下:

GET / HTTP/1.1\r\nHost: localhost:8080\r\nConnection: Upgrade\r\nPragma: no-cache\r\nCache-Control: no-cache\r\nUpgrade: websocket\r\nOrigin: file://\r\nSec-WebSocket-Version: 13\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36\r\nAccept-Encoding: gzip, deflate, br\r\nAccept-Language: zh-CN,zh;q=0.9\r\nCookie: _ga=GA1.1.1892261700.1545540050; _gid=GA1.1.774798563.1552221410; io=7X0VY8jhwRTdRHBfAAAB\r\nSec-WebSocket-Key: dRB1xDJ/vV+IAGnG7TscNQ==\r\nSec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n
複製代碼

經過觀察請求頭數據,能夠發現數據是以key: value的形式顯示,能夠經過字符串切割,將其轉換爲對象格式。

4. 將請求頭字符串轉換爲對象

建立一個parseHeader方法處理請求頭。

function parseHeader(str) {
  // 將請求頭數據按回車符切割爲數組,獲得每一行數據
  let arr = str.split('\r\n').filter(item => item)

  // 第一行數據爲GET / HTTP/1.1,能夠丟棄。
  arr.shift()

  console.log(arr)
  /* 
    處理結果爲:

    [ 'Host: localhost:8080',
      'Connection: Upgrade',
      'Pragma: no-cache',
      'Cache-Control: no-cache',
      'Upgrade: websocket',
      'Origin: file://',
      'Sec-WebSocket-Version: 13',
      'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36',
      'Accept-Encoding: gzip, deflate, br',
      'Accept-Language: zh-CN,zh;q=0.9',
      'Cookie: _ga=GA1.1.1892261700.1545540050; _gid=GA1.1.774798563.1552221410; io=7X0VY8jhwRTdRHBfAAAB',
      'Sec-WebSocket-Key: jqxd7P0Xx9TGkdMfogptRw==',
      'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits' ]
  */

  let headers = {}  // 存儲最終處理的數據

  arr.forEach((item) => {
    // 須要用":"將數組切割成key和value
    let [name, value] = item.split(':')

    // 去除無用的空格,將屬性名轉爲小寫
    name = name.replace(/^\s|\s+$/g, '').toLowerCase()
    value = value.replace(/^\s|\s+$/g, '')

    // 獲取全部的請求頭屬性
    headers[name] = value
  })

  return headers
}
複製代碼

打印結果以下:

{ host: 'localhost',
  connection: 'Upgrade',
  pragma: 'no-cache',
  'cache-control': 'no-cache',
  upgrade: 'websocket',
  origin: 'file',
  'sec-websocket-version': '13',
  'user-agent':
   'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36',
  'accept-encoding': 'gzip, deflate, br',
  'accept-language': 'zh-CN,zh;q=0.9',
  cookie:
   '_ga=GA1.1.1892261700.1545540050; _gid=GA1.1.585339125.1552405260',
  'sec-websocket-key': 'TipyPZNW+KNvV3fePNpriw==',
  'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits' }
複製代碼

5. 根據請求頭參數,判斷是否WebSocket請求

根據headers['upgrade'] !== 'websocket',判斷該HTTP鏈接是否可升級爲WebSocket,若能夠升級,表示爲WebSocket請求。

根據headers['sec-websocket-version'] !== '13',判斷WebSocket的版本是否爲13,以避免由於版本不一樣出現兼容問題。

socket.once('data', (buffer) => {
  // 接收到HTTP請求頭數據
  const str = buffer.toString()
  console.log(str)

  // 4. 將請求頭數據轉爲對象
  const headers = parseHeader(str)
  console.log(headers)

  // 5. 判斷請求是否爲WebSocket鏈接
  if (headers['upgrade'] !== 'websocket') {
    // 若當前請求不是WebSocket鏈接,則關閉鏈接
    console.log('非WebSocket鏈接')
    socket.end()
  } else if (headers['sec-websocket-version'] !== '13') {
    // 判斷WebSocket版本是否爲13,防止是其餘版本,形成兼容錯誤
    console.log('WebSocket版本錯誤')
    socket.end()
  } else {
    // 請求爲WebSocket鏈接時,進一步處理
  }
})
複製代碼

6. 校驗Sec-WebSocket-Key,完成鏈接

根據協議規定的方式,向前端返回一個請求頭,完成創建WebSocket鏈接的過程。

可參考:tools.ietf.org/html/rfc645…

若客戶端校驗結果正確,在控制檯的Network模塊能夠看到HTTP請求的狀態碼變爲101 Switching Protocols,同時客戶端的ws.onopen事件被觸發。

socket.once('data', (buffer) => {
  // 接收到HTTP請求頭數據
  const str = buffer.toString()
  console.log(str)

  // 4. 將請求頭數據轉爲對象
  const headers = parseHeader(str)
  console.log(headers)

  // 5. 判斷請求是否爲WebSocket鏈接
  if (headers['upgrade'] !== 'websocket') {
    // 若當前請求不是WebSocket鏈接,則關閉鏈接
    console.log('非WebSocket鏈接')
    socket.end()
  } else if (headers['sec-websocket-version'] !== '13') {
    // 判斷WebSocket版本是否爲13,防止是其餘版本,形成兼容錯誤
    console.log('WebSocket版本錯誤')
    socket.end()
  } else {
      // 6. 校驗Sec-WebSocket-Key,完成鏈接
      /* 
        協議中規定的校驗用GUID,可參考以下連接:
        https://tools.ietf.org/html/rfc6455#section-5.5.2
        https://stackoverflow.com/questions/13456017/what-does-258eafa5-e914-47da-95ca-c5ab0dc85b11-means-in-websocket-protocol
      */
      const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
      const key = headers['sec-websocket-key']
      const hash = crypto.createHash('sha1')  // 建立一個簽名算法爲sha1的哈希對象

      hash.update(`${key}${GUID}`)  // 將key和GUID鏈接後,更新到hash
      const result = hash.digest('base64') // 生成base64字符串
	  const header = `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-Websocket-Accept: ${result}\r\n\r\n` // 生成供前端校驗用的請求頭

      socket.write(header)  // 返回HTTP頭,告知客戶端校驗結果,HTTP狀態碼101表示切換協議:https://httpstatuses.com/101。
      // 若客戶端校驗結果正確,在控制檯的Network模塊能夠看到HTTP請求的狀態碼變爲101 Switching Protocols,同時客戶端的ws.onopen事件被觸發。
      console.log(header)
      
      // 處理聊天數據
    }
  })
複製代碼

7. 創建鏈接後,經過data事件接收客戶端的數據並處理

鏈接開始後,能夠在控制檯的Network模塊看到,該鏈接會一直保留在pending狀態,直到鏈接斷開。

此時能夠經過data事件處理客戶端的數據,但此時雙方通訊的數據爲二進制,須要按照其格式進行處理後才能夠正常使用。

格式以下:

處理收到的數據:

function decodeWsFrame(data) {
  let start = 0;
  let frame = {
    isFinal: (data[start] & 0x80) === 0x80,
    opcode: data[start++] & 0xF,
    masked: (data[start] & 0x80) === 0x80,
    payloadLen: data[start++] & 0x7F,
    maskingKey: '',
    payloadData: null
  };

  if (frame.payloadLen === 126) {
    frame.payloadLen = (data[start++] << 8) + data[start++];
  } else if (frame.payloadLen === 127) {
    frame.payloadLen = 0;
    for (let i = 7; i >= 0; --i) {
      frame.payloadLen += (data[start++] << (i * 8));
    }
  }

  if (frame.payloadLen) {
    if (frame.masked) {
      const maskingKey = [
        data[start++],
        data[start++],
        data[start++],
        data[start++]
      ];

      frame.maskingKey = maskingKey;

      frame.payloadData = data
        .slice(start, start + frame.payloadLen)
        .map((byte, idx) => byte ^ maskingKey[idx % 4]);
    } else {
      frame.payloadData = data.slice(start, start + frame.payloadLen);
    }
  }

  console.dir(frame)
  return frame;
}
複製代碼

處理髮出的數據:

function encodeWsFrame(data) {
  const isFinal = data.isFinal !== undefined ? data.isFinal : true,
    opcode = data.opcode !== undefined ? data.opcode : 1,
    payloadData = data.payloadData ? Buffer.from(data.payloadData) : null,
    payloadLen = payloadData ? payloadData.length : 0;

  let frame = [];

  if (isFinal) frame.push((1 << 7) + opcode);
  else frame.push(opcode);

  if (payloadLen < 126) {
    frame.push(payloadLen);
  } else if (payloadLen < 65536) {
    frame.push(126, payloadLen >> 8, payloadLen & 0xFF);
  } else {
    frame.push(127);
    for (let i = 7; i >= 0; --i) {
      frame.push((payloadLen & (0xFF << (i * 8))) >> (i * 8));
    }
  }

  frame = payloadData ? Buffer.concat([Buffer.from(frame), payloadData]) : Buffer.from(frame);

  console.dir(decodeWsFrame(frame));
  return frame;
}
複製代碼

對聊天數據進行處理:

socket.once('data', (buffer) => {
  // 接收到HTTP請求頭數據
  const str = buffer.toString()
  console.log(str)

  // 4. 將請求頭數據轉爲對象
  const headers = parseHeader(str)
  console.log(headers)

  // 5. 判斷請求是否爲WebSocket鏈接
  if (headers['upgrade'] !== 'websocket') {
    // 若當前請求不是WebSocket鏈接,則關閉鏈接
    console.log('非WebSocket鏈接')
    socket.end()
  } else if (headers['sec-websocket-version'] !== '13') {
    // 判斷WebSocket版本是否爲13,防止是其餘版本,形成兼容錯誤
    console.log('WebSocket版本錯誤')
    socket.end()
  } else {
      // 6. 校驗Sec-WebSocket-Key,完成鏈接
      /* 
        協議中規定的校驗用GUID,可參考以下連接:
        https://tools.ietf.org/html/rfc6455#section-5.5.2
        https://stackoverflow.com/questions/13456017/what-does-258eafa5-e914-47da-95ca-c5ab0dc85b11-means-in-websocket-protocol
      */
      const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
      const key = headers['sec-websocket-key']
      const hash = crypto.createHash('sha1')  // 建立一個簽名算法爲sha1的哈希對象

      hash.update(`${key}${GUID}`)  // 將key和GUID鏈接後,更新到hash
      const result = hash.digest('base64') // 生成base64字符串
	  const header = `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-Websocket-Accept: ${result}\r\n\r\n` // 生成供前端校驗用的請求頭

      socket.write(header)  // 返回HTTP頭,告知客戶端校驗結果,HTTP狀態碼101表示切換協議:https://httpstatuses.com/101。
      // 若客戶端校驗結果正確,在控制檯的Network模塊能夠看到HTTP請求的狀態碼變爲101 Switching Protocols,同時客戶端的ws.onopen事件被觸發。
      console.log(header)
      
      // 7. 創建鏈接後,經過data事件接收客戶端的數據並處理
      socket.on('data', (buffer) => {
        const data = decodeWsFrame(buffer)
        console.log(data)
        console.log(data.payloadData && data.payloadData.toString())

        // opcode爲8,表示客戶端發起了斷開鏈接
        if (data.opcode === 8) {
          socket.end()  // 與客戶端斷開鏈接
        } else {
          // 接收到客戶端數據時的處理,此處默認爲返回接收到的數據。
          socket.write(encodeWsFrame({ payloadData: `服務端接收到的消息爲:${data.payloadData ? data.payloadData.toString() : ''}` }))
        }
      })
    }
  })
複製代碼

這樣,一個簡單的基於WebSocket的聊天應用就建立完成了,在啓動服務器後,能夠打開index.html看到效果。

相關文章
相關標籤/搜索