初识 WebScoket

172 阅读4分钟

工作中时常会遇到需要和服务器实时交互的场景,或者服务器实时和客户端推送消息的场景,例如:实时查询天气预报或者聊天工具等。那么怎么实现呢?WebSocket 就登场了。

一、WebScoket 相关知识

1. 为什么需要 WebScoket ?

初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?

答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。

举例来说,我们想了解今天的天气,只能是客户端向服务器发出请求,服务器返回查询结果。HTTP 协议做不到服务器主动向客户端推送信息。

2. 什么是 WebScoket?

WebSocket 是 HTML5 规范提出的一种协议,是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。相较于经常需要使用推送实时数据到客户端甚至通过维护两个 HTTP 连接来模拟全双工连接的旧的轮询或长轮询来说,这就极大的减少了不必要的网络流量与延迟。

要使用 HTML5 WebSocket 从一个 Web 客户端连接到一个远程端点,你要创建一个新的 WebSocket 实例并为之提供一个 URL 来表示你想要连接到的远程端点。该规范定义了 ws:// 以及 wss:// 模式来分别表示WebSocket 和安全 WebSocket 连接,这就跟 http:// 以及 https:// 的区别是差不多的。一个 WebSocket 连接是在客户端与服务器之间 HTTP 协议的初始握手阶段将其升级到 Web Socket 协议来建立的,其底层仍是 TCP/IP 连接。

3. 特点

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

image.png

4. 应用场景

  • 弹幕
  • 媒体聊天
  • 协同编辑
  • 基于位置的应用
  • 体育实况更新
  • 股票基金报价实时更新
  • 等其他需要实时更新数据的场景

5. 为什么 Webscoket 连接可以实现全双工通信而 HTTP 连接不行呢?

实际上 HTTP 协议是建立在 TCP 协议之上的,TCP 协议本身就实现了全双工通信,但是 HTTP 协议的请求-应答机制限制了全双工通信。WebScoket 连接建立以后,其实只是简单规定了一下:接下来,咱们通信就不使用 HTTP 协议了,直接互相发数据吧。

二、支持情况

1. 浏览器支持

很显然,要支持 WebScoket 通信,浏览器得支持这个协议,这样才能发出 ws://xxxx 的请求。目前,支持 WebScoket 的主流浏览器如下:

  • Chrome
  • Firefox
  • IE>=10
  • Sarafi>=6
  • Android>=4.4
  • iOS>=6

具体支持情况可参考:caniuse WebSocket

2. 服务器支持

由于 WebScoket 是一个协议,服务器具体怎么实现,取决于所有编程语言和框架本身。Node.js 本身支持的协议包括 TCP 协议和 HTTP 协议,要支持 WebScoket 协议,需要对 Node.js 提供的 HTTPServer 做额外的开发。已经有若干基于 Node.js 的稳定可靠的 WebScoket 实现,我们直接用 npm 安装使用即可。例如市面上比较流行的 ws 和 scoket.io。

三、示例

1. 基于 ws 模块实现简单的聊天功能。

服务器端代码:

const WebSocket = require('ws')
const { WebSocketServer } = WebSocket

const wss = new WebSocketServer({ port: 9090 })

wss.on('connection', function connection(ws, req) {
  const myURL = new URL(req.url, 'http://localhost:8080/websocket')
  const user = myURL.searchParams.get('user')
  if (user) {
    ws.user = { user }
    ws.send(createMessage(WebsocketType.GroupChat, null, '欢迎来到聊天室'))
	// 给所有用户发送用户列表
    sendAll()
  } else {
    ws.send(createMessage(WebsocketType.Error, null, '没有登录'))
  }

  // 接收客户端发的消息
  ws.on('message', function message(data) {
    const { type, data: msgObjData, to } = JSON.parse(data)
    switch (type) {
      case WebsocketType.GroupList:
        ws.send(createMessage(WebsocketType.GroupList, null, JSON.stringify(Array.from(wss.clients).map(item => item.user))))
        break
      case WebsocketType.GroupChat:
        wss.clients.forEach(function each(client) {
          // WebSocket是否保持连接,并且不等于自己就发送消息
          if (client !== ws && client.readyState === WebSocket.OPEN) {
            client.send(createMessage(WebsocketType.GroupChat, ws.user, msgObjData), { binary: false })
          }
        })
        break
      case WebsocketType.SigleChat:
        wss.clients.forEach(function each(client) {
          // WebSocket是否保持连接,并且不等于自己就发送消息
          if (client.user.user === to && client.readyState === WebSocket.OPEN) {
            client.send(createMessage(WebsocketType.SigleChat, ws.user, msgObjData), { binary: false })
          }
        })
        break
    }
    sendAll()
  })

  ws.on('close', () => {
    wss.clients.delete(ws.user)
    sendAll()
  })

})

// 给所有用户发送用户列表
function sendAll() {
  wss.clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(createMessage(WebsocketType.GroupList, null, JSON.stringify(Array.from(wss.clients).map(item => item.user).filter(item => item))))
    }
  })
}

// Websocket 类型
const WebsocketType = {
  Error: 0, // 错误
  GroupList: 1, // 获取列表
  GroupChat: 2, // 群聊
  SigleChat: 3 // 私聊
}

// 创建消息
function createMessage(type, user, data) {
  return JSON.stringify({
    type,
    user,
    data
  })
}

客户端代码:

<template>
  <div>
    <h1>{{ userName }}的聊天室</h1>
    <input type="text" v-model="textValue">
    <button @click="sendMessage">发送消息</button>
    <select @change="selectChange">
      <option v-for="item in options" :value="item.name">{{ item.name }}</option>
    </select>
  </div>
</template>
<script>
export default {
  data() {
    return {
      userName: '',
      textValue: '', // input 输入的内容
      ws: null,
      selectedUser: 'all', // 用于判断群发还是私聊
      options: [
        {
          name: 'all'
        }
      ],
      WebsocketType: {
        Error: 0, // 错误
        GroupList: 1, // 获取列表
        GroupChat: 2, // 群聊
        SigleChat: 3 // 私聊
      }
    }
  },
  created() {
    this.createWs()
  },
  mounted() {
    this.userName = localStorage.getItem('user')
  },
  methods: {
    // 创建 ws
    createWs() {
      // 可通过给 ws 链接后面加参数给服务端传参
      const ws = new WebSocket(`ws://localhost:9090?user=${localStorage.getItem('user')}`)
      this.ws = ws

      ws.onopen = () => {
        console.log('连接成功')
      }

      // 接收服务端发来的消息
      ws.onmessage = (msgObj) => {
        const { type, data: msgObjData, user } = JSON.parse(msgObj.data)
        switch (type) {
          case this.WebsocketType.Error:
            localStorage.removeItem('user')
            // 跳转到登录页
            break
          case this.WebsocketType.GroupList:
            this.options = []
            const initOptions = [{
              name: 'all'
            }]
            const onlineList = JSON.parse(msgObjData)
            onlineList.forEach((item) => {
              initOptions.push({
                name: item.user
              })
            })
            this.options = initOptions
            break
          case this.WebsocketType.GroupChat:
            console.log((user ? user.user : '广播') + ' : ' + msgObjData)
            break
          case this.WebsocketType.SigleChat:
            console.log(user.user + ' : ' + msgObjData)
            break
        }
      }

      ws.onerror = (error) => {
        console.log(error)
      }
    },
    // 发送人变更
    selectChange(e) {
      this.selectedUser = e.target.value
    },
    // 发送消息
    sendMessage() {
      if (this.selectedUser === 'all') {
        // 群发
        this.ws.send(this.createMessage(this.WebsocketType.GroupChat, this.textValue))
      } else {
        // 私聊
        this.ws.send(this.createMessage(this.WebsocketType.SigleChat, this.textValue, this.selectedUser))
      }
    },
    // 创建消息
    createMessage(type, data, to) {
      return JSON.stringify({
        type,
        data,
        to
      })
    }
  }
}
</script>

2. 基于 scoket.io 模块实现简单的聊天功能。

服务端器代码,具体使用可参考:socket.io npm 包

const app = require('express')();
const server = require('http').createServer(app);
const io = require('socket.io')(server, {
  cors: {
    origin: '*' // 设置允许跨域
  }
});
io.on('connection', (socket) => {
  const user = socket.handshake.query.user
  if (user) {
    // 发送欢迎
    socket.emit(WebsocketType.GroupChat, createMessage(socket.user, '欢迎来到聊天室'))

    socket.user = { user }

    // 给所有用户发送用户列表
    sendAll()
  } else {
    socket.emit(WebsocketType.Error, createMessage(null, '用户信息不存在'))
  }

  // 群聊
  socket.on(WebsocketType.GroupChat, (msg) => {
    // 给所有人发
    io.sockets.emit(WebsocketType.GroupChat, createMessage(socket.user, msg.data))
    // 除了自己不发,其他人发
    // socket.broadcast.emit(WebsocketType.GroupChat, createMessage(socket.user, msg.data))
  })

  // 私聊
  socket.on(WebsocketType.SigleChat, (msgObj) => {
    Array.from(io.sockets.sockets).forEach(item => {
      if (item[1].user.user === msgObj.to) {
        item[1].emit(WebsocketType.SigleChat, createMessage(socket.user, msgObj.data))
      }
    })
  })

  // 断开连接
  socket.on('disconnect', () => {
    sendAll()
  })
});
server.listen(9090);

// 给所有用户发送用户列表
function sendAll() {
  io.sockets.emit(WebsocketType.GroupList, createMessage(null, Array.from(io.sockets.sockets).map(item => item[1].user).filter(item => item)))
}

// Websocket 类型
const WebsocketType = {
  Error: 0, // 错误
  GroupList: 1, // 获取列表
  GroupChat: 2, // 群聊
  SigleChat: 3 // 私聊
}

// 创建消息
function createMessage(user, data) {
  return {
    user,
    data
  }
}

客户端代码,具体使用可参考:socket.io-client npm 包

<template>
  <div>
    <h1>{{ userName }}的聊天室</h1>
    <input type="text" v-model="textValue">
    <button @click="sendMessage">发送消息</button>
    <select @change="selectChange">
      <option v-for="item in options" :value="item.name">{{ item.name }}</option>
    </select>
  </div>
</template>
<script>
import { io } from "socket.io-client"
export default {
  data() {
    return {
      userName: '',
      textValue: '', // input 输入的内容
      socket: null,
      selectedUser: 'all', // 用于判断群发还是私聊
      options: [
        {
          name: 'all'
        }
      ],
      WebsocketType: {
        Error: 0, // 错误
        GroupList: 1, // 获取列表
        GroupChat: 2, // 群聊
        SigleChat: 3 // 私聊
      }
    }
  },
  created() {
    this.createSocket()
  },
  mounted() {
    this.userName = localStorage.getItem('user')
  },
  methods: {
    // 创建 socket
    createSocket() {
      const socket = io(`ws://localhost:9090?user=${localStorage.getItem('user')}`)
      this.socket = socket

      // 群聊
      socket.on(this.WebsocketType.GroupChat, (msg) => {
        const { user, data } = msg
        console.log((user ? user.user : '广播') + ' : ' + data)
      })

      // 私聊
      socket.on(this.WebsocketType.SigleChat, (msg) => {
        const { user, data } = msg
        console.log(user.user + ' : ' + data)
      })

      // 出错
      socket.on(this.WebsocketType.Error, () => {
        localStorage.removeItem('user')
        // 跳转到登录页
      })

      // 用户列表
      socket.on(this.WebsocketType.GroupList, (msg) => {
        this.options = []
        const initOptions = [{
          name: 'all'
        }]
        const onlineList = msg.data
        onlineList.forEach((item) => {
          initOptions.push({
            name: item.user
          })
        })
        this.options = initOptions
      })
    },
    // 发送人变更
    selectChange(e) {
      this.selectedUser = e.target.value
    },
    // 发送消息
    sendMessage() {
      if (this.selectedUser === 'all') {
        // 群发
        this.socket.emit(this.WebsocketType.GroupChat, this.createMessage(this.textValue))
      } else {
        // 私聊
        this.socket.emit(this.WebsocketType.SigleChat, this.createMessage(this.textValue, this.selectedUser))
      }
    },
    // 创建消息
    createMessage(data, to) {
      return {
        data,
        to
      }
    }
  }
}
</script>

特点:更强大一些

  • socket.io 有用到 websocket 协议,但是对于不支持 websocket 的浏览器会回退到 http 的轮询,而且提供自动重连,而 ws 就没有此支持。
  • socket.io 模块的数据传输对象和字符串都可以,ws 模块数据传输只能为字符串。

参考文档:阮一峰老师WebSocket 教程