前言
本文使用 WebSocket 实现实时通信案例。包括:
- 使用 Socket.IO 实现官方实时聊天应用示例。
- 在 Vue 中使用 Socket.IO
技术铺垫
WebSocket
Socket.IO
官网:https://socket.io/
HTML5 引入了很多新的特性,WebSocket 就是其中之一,它为浏览器端和服务端提供了一个基于 TCP 链接的双向通道,这样 Web 开发人员可以使用 WebSocket 构建真实的实时 Web 应用。
但是并不是所有的浏览器都支持 WebSocket 特性,在不支持 WebSocket 的浏览器中,我们可以使用一些其它的方法来实现实时通信,例如:轮询、长轮询、基于流或者 Flash Socket 的实现。
Socket.IO 的出现就是为了磨平浏览器的差异,为开发者提供一个统一的接口,在不支持 WebSocket 的浏览器中,Socket.IO 可以降级为其它通信方式来实现实时通信。
Socket.IO 在 WebSocket 之上还封装了一些服务,例如连接断开自动重连。
**注意:**Socket.IO 是包装了 WebSocket 实时通信的第三方框架,不仅提供了客户端的实现方案,还提供了服务端的实现方案。客户端要用 Socket.IO,服务端也要用 Socket.IO。
Socket.IO 官方聊天室示例
使用 Socket.IO 实现官方实时聊天应用示例。
文档地址:Get started | Socket.IO
示例地址:Socket.IO Chat Application Example
当前 Socket.IO 版本:4.x
服务端初始化
初始化项目
# 创建目录
mkdir socket-chat-server
cd socket-chat-server
# 初始化npm
npm init -y
# 安装依赖
npm i express socket.io
创建 index.html
粘贴官方文档中的 html 内容:
<!DOCTYPE html>
<html>
<head>
<title>Socket.IO chat</title>
<style>
body { margin: 0; padding-bottom: 3rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); }
#input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; }
#input:focus { outline: none; }
#form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages > li { padding: 0.5rem 1rem; }
#messages > li:nth-child(odd) { background: #efefef; }
</style>
</head>
<body>
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form id="form" action="">
<input id="input" autocomplete="off" /><button>Send</button>
</form>
</body>
</html>
创建 app.js
初始化 express 应用实例,响应 html 页面:
// app.js
const express = require('express')
const app = express()
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html')
})
app.listen(3000, () => {
console.log('listening on *:3000')
})
运行 app.js,访问 http://localhost:3000 查看效果
建立Socket 通信连接
创建 socket.io 服务端实例
一般很少单独使用 WebSocket 服务(单独提供一个处理 WebSocket 的后台服务),通常情况下会在一个 HTTP 服务里提供 WebSocket 协议请求处理。
Socket.IO 已经把这个事情封装好了,它可以和 express 这类 HTTP 的服务框架结合在一起来使用,从而在 HTTP 服务中提供 WebSocket 协议通信。
// app.js
const express = require('express')
const app = express()
const http = require('http')
const server = http.createServer(app)
const { Server } = require('socket.io')
const io = new Server(server)
// 处理 HTTP 协议的使用 Express 的 app 实例
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html')
})
// 处理 WebSocket 协议的使用 socket.io 的 io 实例
// 当使用 WebSocket 协议通信和服务端建立连接成功后触发 connection 事件
io.on('connection', socket => {
console.log('有一个用户连接成功')
// 监听连接断开事件(刷新页面可测试)
socket.on('disconnect', () => {
console.log('用户已断开连接')
})
})
server.listen(3000, () => {
console.log('listening on *:3000')
})
创建 socket.io 客户端实例
index.html (只列出了body部分):
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form id="form" action="">
<input id="input" autocomplete="off" />
<button>Send</button>
</form>
<!-- socket.io 的客户端实现 -->
<script src="/socket.io/socket.io.js"></script>
<script>
// 调用 io 建立连接
// 默认和当前网页地址(http://localhost:3000)建立连接
const socket = io()
socket.on('connect', () => {
console.log('客户端连接成功')
})
socket.on('disconnect', reason => {
if (reason === 'io server disconnect') {
console.log('服务器断开连接,手动尝试重连')
socket.connect()
} else {
console.log('连接断开')
}
})
socket.on('connect_error', () => {
console.log('连接失败')
})
</script>
socket.io 默认实现了
/socket.io/socket.io.js
的文件加载,目标指向node_modules/socket.io/client-dist/socket.io.js
,可拷贝出来作为本地文件加载。
从请求上看,首先加载了 html 和 js 文件,红框的部分是 Socket.IO 建立连接用的。
请求的地址都是 [http/ws]://localhost:3000/socket.io
,这个地址是 Socket.IO 实现的。
可以看到建立连接使用的 HTTP 协议,连接成功后收发消息使用的 WebSocket 协议。
可以在面板中查看通信记录,绿色是发出去的,红色是接收到的:
其中定时发送的 2
、3
之类的消息,是 Socket.IO 实现的心跳检测和断线重连机制,也就是定期进行一次通信确认连接状态。
收发消息
客户端和服务端建立连接后都会获得一个 socket 对象,socket 翻译过来是 “套接字”,它里面保存着对方的通信地址(例如 IP 地址),通过它可以和对方通信。
- 接收消息:
socket.on(消息类型, 数据 => { })
- 向当前 socket 用户发送消息:
socket.emit(消息类型, 数据)
- 向当前 socket 以外的在线用户发送消息:
socket.broadcast.emit(消息类型, 数据)
- 向所有用户发送消息:
io.emit('消息类型', 数据)
index.html
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form id="form" action="">
<input id="input" autocomplete="off" />
<button>Send</button>
</form>
<!-- socket.io 的客户端实现 -->
<script src="/socket.io/socket.io.js"></script>
<script>
// 调用 io 建立连接
// 默认和当前网页地址(http://localhost:3000)建立连接
const socket = io()
socket.on('connect', () => {
console.log('客户端连接成功')
})
socket.on('disconnect', reason => {
if (reason === 'io server disconnect') {
console.log('服务器断开连接,手动尝试重连')
socket.connect()
} else {
console.log('连接断开')
}
})
socket.on('connect_error', () => {
console.log('连接失败')
})
// 收发消息
const messages = document.getElementById('messages')
const form = document.getElementById('form')
const input = document.getElementById('input')
form.addEventListener('submit', function (e) {
e.preventDefault()
if (input.value) {
// 发送消息
socket.emit('chat message', input.value)
input.value = ''
}
})
// 接收消息
socket.on('chat message', data => {
const message = document.createElement('li')
message.innerHTML = data
messages.appendChild(message)
})
</script>
// app.js
const express = require('express')
const app = express()
const http = require('http')
const server = http.createServer(app)
const { Server } = require('socket.io')
const io = new Server(server)
// 处理 HTTP 协议的使用 Express 的 app 实例
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html')
})
// 处理 WebSocket 协议的使用 socket.io 的 io 实例
// 当使用 WebSocket 协议通信和服务端建立连接成功后触发 connection 事件
io.on('connection', socket => {
console.log('有一个用户连接成功')
// 监听连接断开事件(刷新页面可测试)
socket.on('disconnect', () => {
console.log('用户已断开连接')
})
// 接收消息
socket.on('chat message', data => {
console.log('message => ', data)
// 发送消息
// 发送给当前所有已连接的在线用户(可以打开多个页面建立多个连接)
io.emit('chat message', data)
// 发送给消息来源的客户端
// socket.emit('chat message', data + ' => 消息来源用户')
// 发送给除消息来源以外的所有用户
// socket.broadcast.emit('chat message', data + ' => 消息来源以外的所有用户')
})
})
server.listen(3000, () => {
console.log('listening on *:3000')
})
Vue 应用中使用 Socket.IO
在之前的聊天室基础上增加用户功能。
创建应用
使用 vue 脚手架创建 vue3.0 客户端应用:
vue create socket-chat-client
# ? Please pick a preset: Manually select features
# ? Check the features needed for your project: Choose Vue version, Babel, Router, Vuex, Linter
# ? Choose a version of Vue.js that you want to start the project with 3.x
# ? Use history mode for router? (Requires proper server setup for index fallback in production) No
# ? Pick a linter / formatter config: Standard
# ? Pick additional lint features: Lint on save, Lint and fix on commit
# ? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
# ? Save this as a preset for future projects? (y/N) No
cd socket-chat-client
# 安装 Socket.IO 客户端 npm 包 和 其它工具
npm i socket.io-client axios
# 运行
npm run serve
编写页面组件
App.vue:
<template>
<!-- 路由出口 -->
<router-view/>
</template>
Home.vue:
<template>
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form id="form" action="">
<input id="input" autocomplete="off" />
<button>Send</button>
</form>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
return {}
}
})
</script>
<style>
body { margin: 0; padding-bottom: 3rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
#form { background: rgba(0, 0, 0, 0.15); padding: 0.25rem; position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 3rem; box-sizing: border-box; backdrop-filter: blur(10px); }
#input { border: none; padding: 0 1rem; flex-grow: 1; border-radius: 2rem; margin: 0.25rem; }
#input:focus { outline: none; }
#form > button { background: #333; border: none; padding: 0 1rem; margin: 0.25rem; border-radius: 3px; outline: none; color: #fff; }
#messages { list-style-type: none; margin: 0; padding: 0; }
#messages > li { padding: 0.5rem 1rem; }
#messages > li:nth-child(odd) { background: #efefef; }
</style>
创建登录页 Login.vue:
<template>
<h1>用户登录</h1>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
setup () {
return {}
}
})
</script>
<style>
</style>
添加路由:
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/about',
name: 'About',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
引入 Socket.IO
与上面的服务端示例建立连接。
Home.vue:
<template>
<!-- 消息列表 -->
<ul id="messages"></ul>
<!-- 发送消息的表单 -->
<form id="form" action>
<input id="input" autocomplete="off" />
<button>Send</button>
</form>
</template>
<script>
import { defineComponent } from 'vue'
import { io } from 'socket.io-client'
export default defineComponent({
setup () {
const socket = io('http://localhost:3000', {
// 重新连接的最大延迟事件(秒)
reconnectionDelayMax: 10000,
// 传递身份信息
auth: {
token: '123'
},
// 自定义请求查询参数
query: {
'my-key': 'my-value'
},
// 添加请求头
extraHeaders: {}
})
socket.on('connect', () => {
console.log('连接建立成功')
})
socket.on('disconnect', () => {
console.log('连接断开')
})
socket.on('connect_error', () => {
console.log('连接失败')
})
return {}
}
})
</script>
<style>...</style>
配置 socket CORS
WebSocket 通信不受同源限制,但是建立 WebSocket 连接使用的 HTTP 协议,而 HTTP 受同源策略限制,跨域请求会失败。
当前示例建立连接是跨域请求所以会失败。
Socket.IO 配置 CORS:Handling CORS
修改服务端 IO 实例配置:
// app.js
const express = require('express')
const app = express()
const http = require('http')
const server = http.createServer(app)
const { Server } = require('socket.io')
// 配置 Socket cors
const io = new Server(server, {
cors: {
orgiin: '*'
}
})
...
修改客户端 Home.vue:
<template>
<!-- 消息列表 -->
<ul id="messages">
<li v-for="(message, index) in messages" :key="index">{{ message }}</li>
</ul>
<!-- 发送消息的表单 -->
<form id="form" @submit.prevent="sendMessage">
<input v-model="inputMessage" id="input" autocomplete="off" />
<button>Send</button>
</form>
</template>
<script>
import { defineComponent, ref } from 'vue'
import { io } from 'socket.io-client'
export default defineComponent({
setup () {
const socket = io('http://localhost:3000', {
// 重新连接的最大延迟事件(秒)
reconnectionDelayMax: 10000,
// 传递身份信息
auth: {
token: '123'
},
// 自定义请求查询参数
query: {
'my-key': 'my-value'
},
// 添加请求头
extraHeaders: {}
})
socket.on('connect', () => {
console.log('连接建立成功')
})
socket.on('disconnect', () => {
console.log('连接断开')
})
socket.on('connect_error', () => {
console.log('连接失败')
})
const inputMessage = ref('')
const messages = ref([])
// 发送消息
const sendMessage = () => {
if (inputMessage.value) {
socket.emit('chat message', inputMessage.value)
inputMessage.value = ''
}
}
// 接收消息
socket.on('chat message', message => {
messages.value.push(message)
})
return {
inputMessage,
messages,
sendMessage
}
}
})
</script>
<style>...</style>
用户登录注册
登录页面
Login.vue:
<template>
<h1>用户登录</h1>
<form @submit.prevent="handleSubmit">
<div>
<label for="">用户名</label>
<input v-model="user.username" type="text">
</div>
<div>
<label for="">密码</label>
<input v-model="user.password" type="text">
</div>
<div>
<button>登录/注册</button>
</div>
</form>
</template>
<script>
import { defineComponent, reactive } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
export default defineComponent({
setup () {
const router = useRouter()
const user = reactive({
username: '',
password: ''
})
const handleSubmit = async () => {
try {
const { data } = await axios.post('http://localhost:3000/api/login', user)
// 跳转首页
router.push('/')
console.log(data)
} catch (err) {
window.alert(err.message)
}
}
return {
user,
handleSubmit
}
}
})
</script>
<style>
</style>
数据库安装
本例使用 MongoDB 数据库,安装参考 《MongoDB v5.0.1 和 Robo 3T v1.4.3 安装》
服务端连接数据库和配置模型
在 socket-chat-server 目录下安装 mongoose 工具 npm i mongoose
。
mongoose 官网:Mongoose v6.0.13: Getting Started
创建 models 文件夹,在文件夹下创建 index.js:
// models/index.js
const mongoose = require('mongoose')
// 连接 MongoDB 数据库
mongoose
.connect('mongodb://localhost/chat')
.then(() => {
console.log('连接成功')
})
.catch(err => {
console.log('连接失败', err)
})
module.exports = {
User: mongoose.model('User', require('./user'))
}
在文件夹下创建 user.js:
// models/user.js
/**
* 用户模型
*/
const mongoose = require('mongoose')
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true
},
password: {
type: String,
required: true
}
})
module.exports = userSchema
编写路由接口
在根目录下创建 controllers/user.js:
// controllers/user.js
const { User } = require('../models')
exports.login = async (req, res, next) => {
try {
const { username, password } = req.body
// 查询用户
let user = await User.findOne({ username })
if (user) {
// 用户存在
if (user.password !== password) {
return res.status(400).json({
error: '密码不正确'
})
}
} else {
// 用户不存在,注册
user = await new User(req.body).save()
}
// 登录
res.status(200).json({
user: {
username: user.username,
token: 'xxx' // 用户身份
}
})
} catch (err) {
next(err)
}
}
在根目录下创建 router.js
// router.js
const express = require('express')
const router = express.Router()
const userController = require('./controllers/user')
router.post('/login', userController.login)
module.exports = router
配置接口
安装 cors 依赖:npm i cors
修改 app.js:
// app.js
const express = require('express')
const app = express()
// 配置解析 POST 请求体,会将解析的数据挂载到 req.body 上
app.use(express.json())
// 配置 express HTTP cors
const cors = require('cors')
app.use(cors())
// 配置路由
const router = require('./router')
app.use('/api', router)
const http = require('http')
const server = http.createServer(app)
const { Server } = require('socket.io')
// 配置 Socket cors
const io = new Server(server, {
cors: {
orgiin: '*'
}
})
...
身份认证
服务端生成 token
生成 token 一般使用 JWT(json web token),安装依赖 npm i jsonwebtoken
。
生成 token:
// controllers/user.js
const { User } = require('../models')
const jwt = require('jsonwebtoken')
exports.login = async (req, res, next) => {
try {
const { username, password } = req.body
// 查询用户
let user = await User.findOne({ username })
if (user) {
// 用户存在
if (user.password !== password) {
return res.status(400).json({
error: '密码不正确'
})
}
} else {
// 用户不存在,注册
user = await new User(req.body).save()
}
// 登录
res.status(200).json({
user: {
username: user.username,
// 用户身份(生成 token)
token: jwt.sign(
// 自定义数据
{
userId: user._id
},
// 私钥 (可以使用UUID,网上很多在线生成)
'5c0717dc-2996-4d9e-b166-619d0241abde',
// 过期设置
{
expiresIn: '24h'
}
)
}
})
} catch (err) {
next(err)
}
}
客户端存储用户信息
将用户信息存储到本地缓存和 Store 中。
Login.vue:
<template>
<h1>用户登录</h1>
<form @submit.prevent="handleSubmit">
<div>
<label for="">用户名</label>
<input v-model="user.username" type="text">
</div>
<div>
<label for="">密码</label>
<input v-model="user.password" type="text">
</div>
<div>
<button>登录/注册</button>
</div>
</form>
</template>
<script>
import { defineComponent, reactive } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
export default defineComponent({
setup () {
const router = useRouter()
const store = useStore()
const user = reactive({
username: '',
password: ''
})
const handleSubmit = async () => {
try {
const { data } = await axios.post('http://localhost:3000/api/login', user)
store.commit('setUser', data)
// 跳转首页
router.push('/')
} catch (err) {
window.alert(err.message)
}
}
return {
user,
handleSubmit
}
}
})
</script>
<style>
</style>
// src/store/index.js
import { createStore } from 'vuex'
export default createStore({
state: {
user: JSON.parse(window.localStorage.getItem('user'))
},
mutations: {
setUser (state, payload) {
state.user = payload.user
window.localStorage.setItem('user', JSON.stringify(payload.user))
}
},
actions: {
},
modules: {
}
})
路由鉴权
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
import store from '@/store'
const routes = [
{
path: '/',
name: 'Home',
component: Home,
meta: { requiresAuth: true }
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
const { user } = store.state
if (!user) {
next({
path: '/login',
query: {
redirect: to.fullPath
}
})
}
}
next()
})
export default router
socket 建立连接时传递 token
Home.vue
<template>
<!-- 消息列表 -->
<ul id="messages">
<li v-for="(message, index) in messages" :key="index">{{ message }}</li>
</ul>
<!-- 发送消息的表单 -->
<form id="form" @submit.prevent="sendMessage">
<input v-model="inputMessage" id="input" autocomplete="off" />
<button>Send</button>
</form>
</template>
<script>
import { defineComponent, ref } from 'vue'
import { io } from 'socket.io-client'
import { useStore } from 'vuex'
export default defineComponent({
setup () {
const store = useStore()
const socket = io('http://localhost:3000', {
// 重新连接的最大延迟事件(秒)
reconnectionDelayMax: 10000,
// 传递身份信息
auth: {
token: store.state.user.token
},
// 自定义请求查询参数
query: {
'my-key': 'my-value'
},
// 添加请求头
extraHeaders: {}
})
socket.on('connect', () => {
console.log('连接建立成功')
})
socket.on('disconnect', () => {
console.log('连接断开')
})
socket.on('connect_error', () => {
console.log('连接失败')
})
const inputMessage = ref('')
const messages = ref([])
// 发送消息
const sendMessage = () => {
if (inputMessage.value) {
socket.emit('chat message', inputMessage.value)
inputMessage.value = ''
}
}
// 接收消息
socket.on('chat message', message => {
messages.value.push(message)
})
return {
inputMessage,
messages,
sendMessage
}
}
})
</script>
<style>...</style>
socket 身份认证
Socket.IO 提供了中间件机制统一处理登录状态:Middlewares | Socket.IO
// app.js
const express = require('express')
const app = express()
// 配置解析 POST 请求体,会将解析的数据挂载到 req.body 上
app.use(express.json())
// 配置 express HTTP cors
const cors = require('cors')
app.use(cors())
// 配置路由
const router = require('./router')
app.use('/api', router)
const http = require('http')
const server = http.createServer(app)
const { Server } = require('socket.io')
// 配置 Socket cors
const io = new Server(server, {
cors: {
orgiin: '*'
}
})
const jwt = require('jsonwebtoken')
const { User } = require('./models')
// socket 通信中间件
// 每个连接只执行一次此函数(即使连接包含多个HTTP请求)
io.use((socket, next) => {
const token = socket.handshake.auth.token
// 验证token(注意私钥要一致)
jwt.verify(token, '5c0717dc-2996-4d9e-b166-619d0241abde', async (err, data) => {
if (err) {
return next(new Error('身份认证失败'))
}
// 验证成功
const user = await User.findById(data.userId)
console.log('认证通过')
// 将查到的用户挂载到 request 请求对象中,给后面的处理使用
socket.request.user = user
next()
})
})
// 处理 HTTP 协议的使用 Express 的 app 实例
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html')
})
// 处理 WebSocket 协议的使用 socket.io 的 io 实例
// 当使用 WebSocket 协议通信和服务端建立连接成功后触发 connection 事件
io.on('connection', socket => {
console.log('有一个用户连接成功')
// 监听连接断开事件(刷新页面可测试)
socket.on('disconnect', () => {
console.log('用户已断开连接')
})
// 接收消息
socket.on('chat message', data => {
console.log('message => ', data)
// 发送消息
// 发送给当前所有已连接的在线用户(可以打开多个页面建立多个连接)
io.emit('chat message', {
nickname: socket.request.user.username,
message: data
})
})
})
server.listen(3000, () => {
console.log('listening on *:3000')
})