571
社区成员
发帖
与我相关
我的任务
分享本次课程设计旨在开发一个依托于网页的的在线聊天室,且具备如下功能:
系统中用例如下图所示。

系统用户活动如下图所示。用户从已有房间列表中选择进入,或者自行创建新的房间。用户进入房间之后,获取当前房间的历史消息,可发送消息也可直接离开。

本系统是一个集中式服务,所有用户的浏览器客户端都需要与服务器进行消息传递,通信形式上有单播和广播。
系统使用Websocket收发事件实现实时聊天,系统中事件主要有以下几种类型:
| 事件名 | 事件类型 | 备注 |
|---|---|---|
| 创建房间 | room:create | 用户创建房间时触发,创建成功后向创建者发送完整的房间列表 |
| 加入房间 | room:join | 用户加入房间时触发,广播通知房间内其他用户,并下发当前房间在线人数 |
| 离开房间 | room:leave | 用户离开房间时触发,广播通知房间内的其他用户 |
| 追加消息 | message:append | 用户在房间中发送新消息时触发,广播通知房间内用户 |
| 事件名 | 事件类型 | 备注 |
|---|---|---|
| 初始化 | init | 服务器在用户连接之后,向用户下发初始化数据,包括房间列表 |
| 房间新消息 | message | 当房间中产生新消息时,服务器向用户广播此事件 |
| 房间历史消息结果 | message:fetch:result | 用户进入房间后,向服务器查询历史消息,此事件返回历史消息结果 |
| 创建房间结果 | room:create:result | 用户向服务器发送创建房间消息,此事件返回包含新房间在内的完整房间列表 |
以用户从创建房间开始到发送消息为例,服务器与客户端之间时间交互时序如下图所示。

系统采用开发框架和主要依赖包为:
initDB() 建立与数据库之间的连接。
const mongoose = require('mongoose')
const initDb = async () => {
const connection = await mongoose.connect(
'mongodb://chatroom:chatwithus@xxx.com:port/chatroom', {
useUnifiedTopology: true,
useNewUrlParser: true
})
if (connection) {
return connection.connection.db
}
}
module.exports = initDb
创建数据库对象关系映射模型。message是用户发送的消息,room是创建的房间,message通过roomId关联房间。
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const message = new Schema({
content: String,
sender: String,
sendAt: { type: Date, default: Date.now },
roomId: {
type: Schema.Types.ObjectId,
ref: 'Room' // 关联 Room
}
})
const room = new Schema({
name: String,
createAt: { type: Date, default: Date.now },
activeAt: { type: Date, default: Date.now },
})
module.exports = {
MessageModel: mongoose.model('Message', message),
RoomModel: mongoose.model('Room', room),
}
查看每个websocket连接是否有sessionId,如果没有就随机生成一个,用于用户身份表示。
const crypto = require("crypto")
const randomId = () => crypto.randomBytes(8).toString("hex")
const SessionMiddleware = async (socket, next) => {
const sessionId = socket.handshake.auth.sessionId
if (sessionId) {
socket.sessionId = sessionId
} else {
socket.sessionId = randomId()
}
next()
}
module.exports = {
SessionMiddleware,
}
onConnect是websocket连接事件处理方法,其中为聊天事件注册处理方法,并向客户端发送初始化消息。
onRoomCreate是当用户请求创建房间时,后端新建房间,写入数据库,向创建者发送成果结果。
onRoomJoin当用户进入房间时,向在房间内的用户广播一则提示消息,消息中携带了当前房间在线人数。
onRoomLeave当用户离开房间时,向在房间内的其他用户广播一则提示消息。
onDisconnect当用户断开websocket连接时,将用户从房间中移除。
onMessageAppend当用户发送来一则新消息,广播给房间内的其他用户。
onMessageFetch当用户请求拉取历史消息,从数据库中查找并发向该用户。
const crypto = require("crypto")
const randomId = () => crypto.randomBytes(32).toString("hex")
let socketIO
const { RoomModel, MessageModel } = require("./model")
const onConnect = io => async (socket) => {
socketIO = io
socket.on('room:create', onRoomCreate)
socket.on('room:join', onRoomJoin)
socket.on('room:leave', onRoomLeave)
socket.on('message:append', onMessageAppend)
socket.on('message:fetch', onMessageFetch)
socket.on('disconnecting', onDisconnect)
// 查询群聊
const rooms = await RoomModel.find({})
socket.emit('init', {
sessionId: socket.sessionId,
rooms,
})
//
// socket.join(socket.sessionId)
}
const onRoomCreate = async function (payload) {
const socket = this
const { name } = payload
if (name) {
const room = new RoomModel({ name })
await room.save()
socket.emit('room:create:result', room)
}
}
const onRoomJoin = async function (payload) {
const socket = this
const { roomId, sessionId } = payload
if (roomId) {
socket.join(roomId)
const sockets = await socketIO.in(roomId).fetchSockets()
// 使用 socketIO,消息的发送者也会收到此消息,使用 socket 则会排除发送者
socketIO.to(roomId).emit('message', {
type: 'join',
content: `${sessionId} 进入了房间`,
roomId,
sender: sessionId,
sendAt: Date.now(),
_id: randomId(),
onlineCount: sockets.length,
})
}
}
const onRoomLeave = async function (payload) {
const socket = this
const { roomId, sessionId } = payload
if (roomId) {
socket.leave(roomId)
// 使用 socketIO,消息的发送者也会收到此消息,使用 socket 则会排除发送者
socketIO.to(roomId).emit('message', {
type: 'leave',
content: `${sessionId} 离开了房间`,
roomId,
sender: sessionId,
sendAt: Date.now(),
_id: randomId(),
})
}
}
const onDisconnect = async function () {
const socket = this
socket.leave(socket.rooms)
}
const onMessageAppend = async function (payload) {
const socket = this
const { roomId, sessionId, content } = payload
if (sessionId) {
const message = new MessageModel({ content, sender: sessionId, roomId })
await message.save()
socketIO.to(roomId).emit('message', message)
}
}
const onMessageFetch = async function (payload) {
const socket = this
const { roomId } = payload
if (roomId) {
const messages = await MessageModel.find({ roomId }).limit(200)
socket.emit('message:fetch:result', {
messages,
roomId,
})
}
}
module.exports = {
onConnect,
}
项目入口文件,整合以上所有模块,连接数据库,注册websocket事件处理方法,启动http服务器,设置跨域。
const express = require('express')
const cors = require('cors')
const { Server } = require("socket.io")
const { onConnect } = require("./chat.event")
const { SessionMiddleware } = require("./middleware")
const app = express()
const origin = [
'http://127.0.0.1:3000',
'https://npmdev.com',
]
app.use(cors({
origin,
}))
const server = require('http').createServer(app)
const io = new Server(server, {
path: '/socket.io/',
cors: {
origin,
}
})
// 建立数据库连接
require('./db')()
io.use(SessionMiddleware)
io.on('connection', onConnect(io))
server.listen(4000, '0.0.0.0')
当聊天室组件挂载时,通过createScoket()建立websocket连接,并注册事件处理方法。
当组件卸载时,websocket断开连接。
onMounted(() => {
socket = createSocket()
socket.on('init', onSocketInit)
socket.on('message', onSocketMessage)
socket.on('message:fetch:result', onMessageFetchResult)
socket.on('room:create:result', onRoomCreateResult)
})
onUnmounted(() => {
if (socket) {
socket.disconnect()
}
})
使用 chatroomList 保存房间列表。
onSocketInit 处理连接上服务端后获得的初始化信息。
// 处理服务端向客户端发送的初始化信息
const onSocketInit = (payload) => {
console.log(payload)
// 设置为服务端返回的 sessionId 信息
const { rooms, sessionId: id } = payload
if (id) {
localStorage.setItem(SESSION_ID_KEY, id)
sessionId = id
}
if (rooms) {
chatroomList.value = rooms
}
}
inputRoomName 为创建房间时输入的用户名。
createRoom 向服务端发送创建房间消息,onRoomCreateResult 处理创建结果。
const chatroomList = ref([])
const inputRoomName = ref('')
// 创建房间
const createRoom = () => {
if (inputRoomName.value !== '') {
socket.emit('room:create', {
name: inputRoomName.value,
})
inputRoomName.value = ''
}
}
// 处理房间创建成功的消息
const onRoomCreateResult = (payload) => {
chatroomList.value = [payload, ...chatroomList.value]
}
使用 chatMessages 保存群聊消息。
onRoomClicked 当用户在房间列表中点击房间时,进入房间,并向服务端发送拉取历史消息请求。
const chatMessages = ref([])
const onRoomClicked = room => {
chatActive.value = true
const { _id: roomId, name } = room
if (currentRoom.id !== roomId) {
// 离开原先房间
if (currentRoom.id) {
socket.emit('room:leave', {
roomId: currentRoom.id,
sessionId,
})
}
// 切换到新的房间
currentRoom.id = roomId
currentRoom.name = name
// 加入房间,服务端进行广播
socket.emit('room:join', {
roomId,
sessionId,
})
// 拉取历史消息
socket.emit('message:fetch', {
roomId,
})
}
}
// 处理消息历史拉取结果
const onMessageFetchResult = (payload) => {
const { roomId, messages } = payload
console.log(payload)
chatMessages.value = []
if (roomId === currentRoom.id) {
if (messages) {
chatMessages.value = messages
}
}
}
onMessage 处理群聊消息,其中包括文字消息,还有成员加入退出的提示消息。
对 chatMessage 的变动进行监听,发生变化时(收到新消息),UI 方面自动跳转到消息末尾。
// 处理一般群聊消息
const onSocketMessage = (payload) => {
console.log(payload)
const { roomId, type, content, sender, sendAt, _id, onlineCount: count } = payload
if (roomId === currentRoom.id) {
chatMessages.value = [
...chatMessages.value,
{
type,
content,
sender,
_id,
sendAt,
},
]
// 当是成员加入消息时,服务端会返回当前房间在线人数
if (type === 'join') {
onlineCount.value = count
} else if (type === 'leave') {
// 成员离开
onlineCount.value -= 1
}
}
}
// 监听消息列表,当有新消息时跳到末尾
watch(chatMessages, () => {
setTimeout(() => {
const container = document.querySelector('.chat-message')
container.scrollTop = container.scrollHeight
}, 200) // 延时不立即执行,待 DOM 更新之后
}, { immediate: false })
onBackClicked 当用户返回退出房间时,向服务端发送离开房间消息。
const onBackClicked = () => {
chatActive.value = false
// 离开房间
if (currentRoom.id) {
socket.emit('room:leave', {
roomId: currentRoom.id,
sessionId,
})
}
currentRoom.id = null
currentRoom.name = null
}
完整的 ChatRoom.vue 代码为如下,其中使用的其他 Vue 组件不再介绍。
<template>
<Window :config="config">
<div class="chatroom-body" :class="{'chat-content-active':chatActive}">
<div class="group-contacts">
<div class="create-group">
<input v-model="inputRoomName" type="text" placeholder="请输入房间名称">
<button @click="createRoom">
创建
</button>
</div>
<div class="group-list">
<span class="group-list-tip">群聊列表</span>
<div v-for="room in chatroomList" :key="room._id" class="group-item" @click="onRoomClicked(room)">
<span class="group-name">{{ room.name }}</span>
</div>
</div>
</div>
<div v-show="currentRoom.id && chatActive" class="chat-content">
<div class="chat-title">
<li class="icon-button go-back-btn" @click="onBackClicked">
<i class="iconfont icon-arrow-left"></i>
</li>
<div class="chat-info">
<span class="room-name">{{ currentRoom.name }}</span>
<span class="online-count"> [在线人数:{{ onlineCount }}]</span>
</div>
</div>
<div class="chat-message">
<ChatMessage
v-for="message in chatMessages" :key="message._id" :from="message.sender"
:msg="message.content" :type="message.type || 'chat'" :left="sessionId!==message.sender"
/>
</div>
<div class="chat-editor">
<textarea
v-model="inputMsg" placeholder="在此输入...Enter 发送" aria-multiline="true"
@keyup.enter="sendMsg"
/>
<div class="send-btn" @click="sendMsg">
发送
</div>
</div>
</div>
</div>
</Window>
</template>
<script setup>
import Window from '../../components/Window.vue'
import ChatMessage from '../../components/ChatMessage.vue'
import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { createSocket, SESSION_ID_KEY } from '../../common/chatroom-socket'
defineProps({
config: Object,
})
let socket = null
let sessionId = localStorage.getItem(SESSION_ID_KEY)
const chatActive = ref(false)
const chatroomList = ref([])
const chatMessages = ref([])
const inputMsg = ref('')
const inputRoomName = ref('')
const onlineCount = ref(0)
// 当前房间
const currentRoom = reactive({
id: null,
name: null,
})
const onRoomClicked = room => {
chatActive.value = true
const { _id: roomId, name } = room
if (currentRoom.id !== roomId) {
// 离开原先房间
if (currentRoom.id) {
socket.emit('room:leave', {
roomId: currentRoom.id,
sessionId,
})
}
// 切换到新的房间
currentRoom.id = roomId
currentRoom.name = name
// 加入房间,服务端进行广播
socket.emit('room:join', {
roomId,
sessionId,
})
// 拉取历史消息
socket.emit('message:fetch', {
roomId,
})
}
}
const onBackClicked = () => {
chatActive.value = false
// 离开房间
if (currentRoom.id) {
socket.emit('room:leave', {
roomId: currentRoom.id,
sessionId,
})
}
currentRoom.id = null
currentRoom.name = null
}
// 处理服务端向客户端发送的初始化信息
const onSocketInit = (payload) => {
console.log(payload)
// 设置为服务端返回的 sessionId 信息
const { rooms, sessionId: id } = payload
if (id) {
localStorage.setItem(SESSION_ID_KEY, id)
sessionId = id
}
if (rooms) {
chatroomList.value = rooms
}
}
// 处理一般群聊消息
const onSocketMessage = (payload) => {
console.log(payload)
const { roomId, type, content, sender, sendAt, _id, onlineCount: count } = payload
if (roomId === currentRoom.id) {
chatMessages.value = [
...chatMessages.value,
{
type,
content,
sender,
_id,
sendAt,
},
]
// 当是成员加入消息时,服务端会返回当前房间在线人数
if (type === 'join') {
onlineCount.value = count
} else if (type === 'leave') {
// 成员离开
onlineCount.value -= 1
}
}
}
// 处理消息历史拉取结果
const onMessageFetchResult = (payload) => {
const { roomId, messages } = payload
console.log(payload)
chatMessages.value = []
if (roomId === currentRoom.id) {
if (messages) {
chatMessages.value = messages
}
}
}
// 监听消息列表,当有新消息时跳到末尾
watch(chatMessages, () => {
setTimeout(() => {
const container = document.querySelector('.chat-message')
container.scrollTop = container.scrollHeight
}, 200) // 延时不立即执行,待 DOM 更新之后
}, { immediate: false })
// 发送群聊消息
const sendMsg = () => {
if (inputMsg.value !== '') {
socket.emit('message:append', {
roomId: currentRoom.id,
content: inputMsg.value,
sessionId,
})
inputMsg.value = ''
}
}
// 创建房间
const createRoom = () => {
if (inputRoomName.value !== '') {
socket.emit('room:create', {
name: inputRoomName.value,
})
inputRoomName.value = ''
}
}
// 处理房间创建成功的消息
const onRoomCreateResult = (payload) => {
chatroomList.value = [payload, ...chatroomList.value]
}
onMounted(() => {
socket = createSocket()
socket.on('init', onSocketInit)
socket.on('message', onSocketMessage)
socket.on('message:fetch:result', onMessageFetchResult)
socket.on('room:create:result', onRoomCreateResult)
})
onUnmounted(() => {
if (socket) {
socket.disconnect()
}
})
</script>
<style scoped lang="scss">
@import "../../assets/style/mixin";
$border: 1px solid rgba(black, .05);
::v-deep(.window-body) {
position: relative;
}
.group-item {
width: 100%;
height: 48px;
padding: 12px;
background-color: white;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
border-top: $border;
cursor: pointer;
&:hover {
background-color: whitesmoke;
}
}
.chatroom-body {
@include gen-absolute(0, 0, 0, 0);
display: flex;
flex-direction: row;
align-items: stretch;
.group-contacts {
flex: 0 0 256px;
background-color: whitesmoke;
border-right: $border;
.create-group {
display: flex;
flex-direction: row;
justify-content: center;
margin-top: 4px;
padding: 12px;
height: 56px;
input {
flex: 1;
outline: none;
border: none;
border-bottom-left-radius: 12px;
border-top-left-radius: 12px;
text-indent: 12px;
}
button {
flex: 0 0 56px;
border: none;
border-bottom-right-radius: 12px;
border-top-right-radius: 12px;
background-color: #2683F5;
color: white;
}
}
.group-list {
overflow-y: scroll;
&::-webkit-scrollbar {
width: 0;
}
&-tip {
padding: 0 12px;
font-size: 14px;
}
}
}
.chat-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
.chat-title {
height: 48px;
border-top: $border;
border-bottom: $border;
background-color: white;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
padding: 0 12px;
.go-back-btn {
display: none;
}
}
.online-count {
font-size: 14px;
}
.chat-message {
flex: 1;
overflow-y: scroll;
&::-webkit-scrollbar {
width: 0;
}
}
.chat-editor {
flex: 0 0 120px;
border-top: $border;
background-color: white;
position: relative;
textarea {
width: 100%;
height: 100%;
border: none;
outline: none;
padding: 12px;
}
.send-btn {
position: absolute;
right: 24px;
bottom: 24px;
width: 96px;
height: 30px;
border: $border;
text-align: center;
line-height: 30px;
cursor: pointer;
background-color: white;
border-radius: 4px;
&:hover {
background-color: whitesmoke;
}
}
}
}
}
@include media('<desktop') {
.chat-content-active {
transform: translateX(-100%);
}
.chatroom-body {
flex-wrap: nowrap;
transition: all .2s ease-out;
.group-contacts {
flex: 0 0 100%;
background-color: whitesmoke;
}
.chat-content {
flex: 0 0 100%;
.chat-title {
padding: 0;
.go-back-btn {
display: inline-block !important;
}
}
}
}
}
</style>
聊天室前端部分被集成到一个更大的前端项目中。
聊天室初始界面如下,显示出了当前已经创建的房间。

当用户点击其中一项,进入房间,展示历史消息以及当前在线人数。

尝试发送消息。

当另一个用户进入房间,在线人数变为2,并且有提示用户加入的提示消息。

当用户刷新页面后,前端从localStorage 中查找之前保存的 sessionId,使得用户能够继续使用之前的身份。
作者:NP212