nodejs学习5:弄懂HTTP、TCP、UDP、RPC、webSocket的区别

3 阅读11分钟

HTTP

http 是 Node 内置的TCP + HTTP 协议封装,基于事件驱动

const http = require('http')

const server = http.createServer((req, res) => {
  // 每次请求都会进来
})

server.listen(3000)

这行回调本质就是:

server.on('request', (req, res) => { ... })

从客户端连进来 → 发请求 → 响应 → 断开,完整事件顺序如下:

    1. TCP 连接建立
server.on('connection', (socket) => { 
    // 客户端刚连上 TCP 
    // 参数只有:socket(net.Socket) 
})
    1. 收到 HTTP 请求
server.on('request', (req, res) => {
  // 每次浏览器/接口访问都会触发
  // 参数:req(请求), res(响应)
})
    1. req 身上的事件(接收数据)
let body = ''
req.on('data', (chunk) => {
  // 收到一段 body 数据
  body += chunk
})

req.on('end', () => {
  // 数据接收完毕
})

req.on('error', (err) => {})

req.on('aborted', () => {
  // 客户端中途断开
})

aborted:请求被中途取消 / 断开(客户端主动断连、网络断了、浏览器关掉);

error:请求底层出错(网络错误、解析错误、连接异常崩溃)

req:请求对象(IncomingMessage),可读流。

最常用属性有:

req.method // 请求方法:GET / POST / PUT... 
req.url // 请求地址:/api/user / /xxx 
req.headers // 所有请求头(对象) 
req.httpVersion // HTTP版本:1.1
    1. res 身上的事件(发送响应)
// 响应头 + 响应体 已经全部发送给客户端,Node 已经把数据交给操作系统内核,马上就到客户端
res.on('finish', () => {
  // 响应已经发给客户端
})

// 底层 TCP 连接关闭了(socket 关闭, keep-alive),可以是客户端中途断开,也可以是服务器主动关闭
res.on('close', () => {
  // 连接被关闭
})

res.on('error', (err) => {})

res:响应对象(ServerResponse)

最核心方法有:

  • res.writeHead (状态码,响应头)
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
  • res.end (数据) 结束响应,把数据返回给客户端(必须调用)
res.end('hello') 
res.end('<h1>Hi</h1>') 
res.end(JSON.stringify({ name: 'zs' }))
  • res.write (数据) 向客户端写数据(可多次调用,最后必须 end)
res.write('hello') 
res.write(' world') 
res.end()
  • res.setHeader(name, value) 单独设置响应头
res.setHeader('Content-Type', 'text/plain') 
res.setHeader('X-Custom', 'test')
  • res.statusCode 直接设置状态码(不用 writeHead)
res.statusCode = 404
    1. 协议升级(WebSocket 专用)
server.on('upgrade', (req, socket, head) => {
  // 客户端要从 HTTP 升级成 WebSocket
  // 参数:req, socket, head(已读缓冲)
})
    1. 错误事件(必写)
server.on('error', (err) => { // 端口被占用、服务异常 })
    1. 服务关闭
server.on('close', () => { // server.close() 后触发 })

HTTP 服务完整生命周期流程图:

image.png

const http = require('http')
const server = http.createServer()

// 1. TCP 连接
server.on('connection', (socket) => {
  console.log('客户端连接:', socket.remoteAddress)
})

// 2. HTTP 请求
server.on('request', (req, res) => {
  console.log('请求:', req.method, req.url)

  let body = ''
  req.on('data', (chunk) => { body += chunk })
  req.on('end', () => {
    res.end('hello')
  })
  
  // 执行res.end('hello')触发
  res.on('finish', () => {
    console.log('响应发送完成')
  })
})

// 3. WebSocket 升级
server.on('upgrade', (req, socket, head) => {
  console.log('升级协议')
})

// 4. 错误
server.on('error', (err) => {
  console.log('服务器错误', err)
})

server.listen(3000, () => {
  console.log('run on 3000')
})

TCP

Node.js 原生 TCP 模块就是net 模块。它的作用是:

  • 创建 TCP 服务器(监听连接)
  • 创建 TCP 客户端(连接服务器)
  • 实现长连接、双向通信(聊天室、游戏、WebSocket 底层都靠它)

HTTP 是基于 TCP 封装的协议,所以 http 模块底层 = net 模块。

TCP 通信里只有两个东西:

  1. 服务器:net.createServer()
  2. 连接(Socket):客户端和服务器之间的双向通道,Socket 既是可读流,又是可写流,能发数据、能收数据、能监听关闭、错误。

创建 TCP 服务器

const net = require('net')

// 1. 创建 TCP 服务器
const server = net.createServer((socket) => {
  console.log('客户端已连接')

  // 获取客户端信息
  console.log('客户端IP:', socket.remoteAddress)
  console.log('客户端端口:', socket.remotePort)

  // 2. 收到客户端发来的数据
  socket.on('data', (chunk) => {
    console.log('收到:', chunk.toString())

    // 3. 给客户端回消息
    socket.write('服务器已收到:' + chunk.toString())
  })

  // 4. 客户端断开连接
  socket.on('end', () => {
    console.log('客户端断开连接')
  })

  // 5. 连接错误
  socket.on('error', (err) => {
    console.log('客户端异常:', err.message)
  })
})

// 监听端口
server.listen(3000, () => {
  console.log('TCP 服务器已启动 :3000')
})

// 服务器自身错误
server.on('error', (err) => {
  console.log('服务器异常:', err.message)
})

TCP 客户端(连接 TCP 服务器)

const net = require('net')

// 连接服务器
const client = net.createConnection({ port: 3000 }, () => {
  console.log('已连接到服务器')

  // 发送数据
  client.write('Hello TCP Server!')
})

// 收到服务器返回的数据
client.on('data', (chunk) => {
  console.log('服务器说:', chunk.toString())
})

// 服务器断开
client.on('end', () => {
  console.log('服务器断开连接')
})

Socket(连接对象)

Socket = 客户端与服务端之间的通信通道

Socket 既是可读流,也是可写流,所以它是双工流(Duplex)。

从 API 上一眼就能看出来,可读流的方法(读数据):

socket.on('data', (chunk) => {}) 
socket.on('end', () => {}) 
socket.pause() 
socket.resume()

可写流的方法(发数据):

socket.write('hello') 
socket.end()

net 模块 4 个核心点

  1. net.createServer → 创建 TCP 服务
  2. connection → 客户端连上来,给你一个 socket
  3. socket.on('data') → 接收消息
  4. socket.write() → 发送消息

UDP

UDP = User Datagram Protocol,和 TCP 一样,都在传输层,基于IP 协议,发的是数据报(Datagram)无连接、不可靠、速度极快

TCP vs UDP 对比:

image.png

UDP 适合什么场景?

允许丢一点包,但必须快、实时性强的场景

  • 游戏实时同步
  • 语音通话 / 视频会议
  • 直播
  • DNS 查询
  • 心跳包
  • 实时监控数据
  • 局域网高速通信

不适合:

  • 接口请求
  • 文件传输
  • 支付
  • 登录
  • 微服务 RPC(除非自己做可靠性)

对于 DNS 查询的场景,DNS 是查询,不是传文件,不是支付,丢了就丢了,客户端等 200ms 没收到,再发一次就行,成本极低、体验几乎不受影响。

这种场景,UDP 的 “不可靠” 完全不是问题。

Node.js 中的 UDP:dgram 模块

UDP 不是 Stream,不是双工流,它是数据报

UDP 服务器:

const dgram = require('dgram')
const server = dgram.createSocket('udp4') // udp4 = IPv4

// 收到消息
server.on('message', (msg, remoteInfo) => {
  console.log('收到:', msg.toString())
  console.log('来自:', remoteInfo.address, remoteInfo.port)

  // 给客户端回消息
  server.send('已收到: ' + msg, remoteInfo.port, remoteInfo.address)
})

// 启动监听
server.bind(3000, () => {
  console.log('UDP 服务器启动 :3000')
})

客户端:

const dgram = require('dgram')
const client = dgram.createSocket('udp4')

const msg = Buffer.from('Hello UDP')

// 发消息
client.send(msg, 3000, 'localhost', (err) => {
  console.log('消息已发送')
})

// 接收服务器回复
client.on('message', (msg) => {
  console.log('服务器回:', msg.toString())
})
  • TCP Socket = Duplex 双工流(字节流)
  • UDP = 数据报(一包一包发,一包一包收)

UDP 没有:

  • data 事件
  • end 事件
  • finish 事件
  • pipe
  • write 持续写入

它只有:

  • message:收到一个包
  • send:发一个包
  • error
  • close

RPC

RPC = Remote Procedure Call(远程过程调用)

像调用本地函数一样,调用另一台机器上的函数。

举例:你在 A 机器:

js

const user = getUserById(100)

实际这个函数 在 B 机器上执行,但你写起来跟本地一样。

这就是 RPC 的核心思想:隐藏网络通信细节,让分布式调用像本地调用一样简单

要实现 “像本地函数一样调用”,RPC 框架必须做 4 件事:

  1. 序列化 / 反序列化:把参数、返回值转成二进制(Protobuf)

  2. 传输协议:TCP、HTTP、HTTP/2

  3. 协议格式(数据分包):解决 TCP 粘包,比如:长度 + 消息ID + 数据

  4. 服务寻址与路由:知道调用哪台机器、哪个服务、哪个方法

这 4 件事组合起来,才叫一个 RPC 框架。

RPC 执行流程:

image.png

全程你无感,只觉得调用了一个本地方法。

RPC 和 HTTP 的区别

HTTP:

  • 基于应用层协议
  • 文本协议,头信息大
  • 接口风格 RESTful
  • 通用、浏览器能直接调
  • 适合:对外接口、前端调用、跨公司对接

RPC:

  • 自定义应用协议
  • 一般是二进制协议,更小更快
  • 方法调用风格:服务名.方法名 (参数)
  • 长连接、高并发、低延迟
  • 适合:微服务内部调用、后端集群互相调用

一句话总结:

  • 对外 → HTTP
  • 对内微服务 → RPC

RPC 底层大部分都采用的是 TCP,也可用 HTTP。

都说 RPC 用纯 TCP 性能最好,那为什么王者 gRPC 非要跑在 HTTP/2 上?

这是因为 gRPC 选择 HTTP/2,是为了在 “TCP 的高性能” 和 “互联网通用标准” 之间做平衡。

它既拿到了 TCP 的速度,又拿到了 HTTP/2 的成熟特性,不用自己造轮子。

HTTP/2 并不是抛弃 TCP,而是在 TCP 之上做了极度优化的 HTTP 协议

所以:

  • 纯 TCP RPC = 自己实现所有高级特性
  • gRPC = 站在 HTTP/2 肩膀上,直接复用成熟能力

gRPC 用 HTTP/2 的真正原因:

  • 多路复用:HTTP/2 支持 一个 TCP 连接,同时并发多个请求,互不阻塞。纯 TCP RPC 要实现多路复用,必须自己造。

  • 成熟、标准化、全平台支持

  • 自带头部压缩、二进制分帧:HTTP/1.1 头大、慢。HTTP/2 是二进制协议,头压缩(HPACK),极快。gRPC 直接复用,不用自己实现压缩、帧格式。

  • TLS 天然支持: 互联网环境必须加密。HTTP/2 几乎总是带 TLS,gRPC 直接安全可用,不用自己处理加密。

等等优点...

手写一个极简 RPC

  1. 服务端(提供函数)
const net = require('net')
const server = net.createServer(socket => {
  socket.on('data', data => {
    const req = JSON.parse(data)

    // 模拟方法
    if (req.method === 'add') {
      const result = req.params.a + req.params.b
      socket.write(JSON.stringify({ result }))
    }
  })
})
server.listen(4000)
  1. 客户端(像调用本地函数)
const net = require('net')

// RPC 代理:像本地函数一样调用
function add(a, b) {
  return new Promise(resolve => {
    const socket = net.createConnection({ port: 4000 })
    socket.write(JSON.stringify({
      method: 'add',
      params: { a, b }
    }))
    socket.on('data', data => {
      const res = JSON.parse(data)
      resolve(res.result)
    })
  })
}

// 使用!像本地函数一样
async function test() {
  const sum = await add(3, 5)
  console.log(sum) // 8
}
test()

webSocket

HTTP 的协议格式我们很清楚,就是 header、body 这些。

那 WebSocket 的协议格式是什么样的呢?

WebSocket 是一种基于 TCP 的、浏览器支持的、全双工长连接协议。目的是:让客户端和服务器能随时互相发消息,不用再反复 HTTP 请求。

实时的双向数据通信,我们一般会用 WebSocket 来做。

HTTP 有个致命缺点:只能客户端主动问,服务器不能主动推。

以前要做实时消息 / 聊天 / 通知,只能用轮询:每秒发一次请求(浪费、延迟高)。

WebSocket 出来后:一次连接,永久双向通信,服务器想发就发。

WebSocket 核心特点:

  • 基于 TCP(可靠、不丢包)
  • 全双工:客户端 ↔ 服务器 同时收发
  • 长连接:连接一直保持,不用重复握手
  • 轻量级协议头:只有 2~10 字节,比 HTTP 轻太多
  • 浏览器原生支持:ws://、wss://
  • 能跨域(靠服务端配置)

下面我们手写一个一个websocket协议。

import EventEmitter from 'node:events'
import http from 'node:http'
import crypto from 'node:crypto'

const OPCODES = {
  CONTINUE: 0,
  TEXT: 1, // 文本
  BINARY: 2, // 二进制
  CLOSE: 8,
  PING: 9,
  PONG: 10,
}

function hashKey(key) {
  const sha1 = crypto.createHash('sha1')
  sha1.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
  return sha1.digest('base64')
}

function handleMask(maskBytes, data) {
  const payload = Buffer.alloc(data.length)
  for (let i = 0; i < data.length; i++) {
    payload[i] = maskBytes[i % 4] ^ data[i]
  }
  return payload
}

// 用于将数据打包成符合 WebSocket 协议格式的二进制帧
function encodeMessage(opcode, payload) {
  // payload.length < 126
  // WebSocket 帧头部固定 2 字节(对于小于 126 字节的负载)
  let bufferData = Buffer.alloc(payload.length + 2 + 0)
  // 设置 FIN 为 1
  let byte1 = parseInt('10000000', 2) | opcode
  let byte2 = payload.length

  bufferData.writeUInt8(byte1, 0)
  bufferData.writeUInt8(byte2, 1)

  // 将负载数据复制到 Buffer 的第 3 字节开始的位置(跳过 2 字节头部)
  payload.copy(bufferData, 2)

  return bufferData
}

export class MyWebsocket extends EventEmitter {
  constructor(options) {
    super(options)

    const server = http.createServer()

    server.listen(options.port || 8080)
    
    // 当客户端请求升级 HTTP 连接到 WebSocket 时触发此事件。
    server.on('upgrade', (req, socket) => {
      // 保存 socket 引用并启用 TCP keep-alive 保持连接活跃。
      this.socket = socket
      socket.setKeepAlive(true)

      const resHeaders = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' + hashKey(req.headers['sec-websocket-key']),
        '',
        '',
      ].join('\r\n')
      // 使用 101 Switching Protocols 状态码完成 HTTP → WebSocket 的协议切换。
      socket.write(resHeaders)
      
      // 处理客户端发来的数据,都是二进制数据
      socket.on('data', (data) => {
        console.log(data)
        // 解析二进制数据为人能看懂的字符
        this.processData(data)
      })
      socket.on('close', (error) => {
        this.emit('close')
      })
    })
  }

  processData(bufferData) {
    // 读取第一个字节
    const byte1 = bufferData.readUInt8(0)
    // byte1 & 0x0f 保留低 4 位,即提取 opcode(操作码)0x0f = 00001111
    let opcode = byte1 & 0x0f

    const byte2 = bufferData.readUInt8(1)
    // 转为二进制字符串'10001100'
    const str2 = byte2.toString(2)
    const MASK = str2[0]

    let curByteIndex = 2

    let payloadLength = parseInt(str2.substring(1), 2)
    console.log('====payloadLength', payloadLength)
    if (payloadLength === 126) {
      // 126:表示长度用接下来的 2 字节(16位)表示,最大 65535
      payloadLength = bufferData.readUInt16BE(2)
      curByteIndex += 2
    } else if (payloadLength === 127) {
      // 127:表示长度用接下来的 8 字节(64位)表示
      payloadLength = bufferData.readBigUInt64BE(2)
      curByteIndex += 8
    }

    let realData = null

    if (MASK) {
      const maskKey = bufferData.slice(curByteIndex, curByteIndex + 4)
      curByteIndex += 4
      const payloadData = bufferData.slice(
        curByteIndex,
        curByteIndex + payloadLength,
      )
      realData = handleMask(maskKey, payloadData)
    }

    this.handleRealData(opcode, realData)
  }

  handleRealData(opcode, realDataBuffer) {
    switch (opcode) {
      case OPCODES.TEXT:
        // 触发事件
        this.emit('data', realDataBuffer.toString('utf-8'))
        break
      case OPCODES.BINARY:
        this.emit('data', realDataBuffer)
        break
      default:
        this.emit('close')
        break
    }
  }
  
  // 把服务端的数据转化为符合websocket协议的二进制数据,并发送
  send(data) {
    let opcode
    let buffer
    if (Buffer.isBuffer(data)) {
      opcode = OPCODES.BINARY
      buffer = data
    } else if (typeof data === 'string') {
      opcode = OPCODES.TEXT
      buffer = Buffer.from(data, 'utf8')
    } else {
      console.error('暂不支持发送的数据类型')
    }
    this.doSend(opcode, buffer)
  }

  doSend(opcode, bufferDatafer) {
    this.socket.write(encodeMessage(opcode, bufferDatafer))
  }
}

上面创建了一个websocket的类,根据这个类就可以创建一个websocket服务:

import { MyWebsocket } from './ws.mjs'

const ws = new MyWebsocket({ port: 8080 })

// 因为继承了EventEmitter,所以可以监听事件
ws.on('data', (data) => {
  console.log('receive data:' + data)
  
  // 往客户端发送数据
  setInterval(() => {
    ws.send(data + ' ' + Date.now())
  }, 1000)
})

ws.on('close', (code, reason) => {
  console.log('close:', code, reason)
})

客户端代码:

<!doctype html>
<html>
  <body>
    <script>
      const ws = new WebSocket('ws://localhost:8080')

      ws.onopen = function () {
        ws.send('发送数据')
        // setTimeout(() => {
        //   ws.send('发送数据2')
        // }, 3000)
      }

      ws.onmessage = function (evt) {
        console.log(evt)
      }

      ws.onclose = function () {}
    </script>
  </body>
</html>