个人全栈聊天项目里面的核心功能是怎样实现的?

927 阅读14分钟

1、前言

better-chat是本人的一个前端基于vitereactts,后端基于expressmysql,并依赖websocketwebrtc实现的局域网实时聊天全栈项目,已收获 300+star⭐。该系统按照功能区别可以划分成三个模块:用户模块、好友模块以及聊天模块,本文旨在对项目里面的一些核心功能实现进行讲解,便于让读者跟着教程也可以从 0 到 1实现或者扩展该项目。

注意: 该项目文件相关的功能(本地文件系统的实现)可参考本人另一篇博客 《React+Nest 实现大文件分片上传、断点续传、秒传、重传》 进行实现,不在本文作出赘述。

2、登录鉴权

2-1 概述

登录功能是该系统的核心功能之一,隶属于用户模块,目的是实现用户鉴权,只有登录成功的用户才能进入系统使用其它模块的功能,相当于系统的“门户”。登录功能的核心实现逻辑可以概括为用户在前端页面输入用户名和密码,点击登录按钮后登录请求被发送到服务端并实现校验,然后返回校验结果给前端进行下一步操作,如若登录成功则进入系统首页,否则前端作出相应提示。

2-2 前端逻辑

当登录请求通过后,接口会返回生成的token和所登录的用户信息给前端。前端接收到之后,会将token和用户信息借助sessionStorage存储在浏览器本地便于后续使用,同时路由跳转到系统首页。

这里有四个点需要注意:

  • 一是为了便于操作sessionStorage,项目里面二次封装了原生的sessionStorage,置于项目的utils目录下;
  • 二是在统一封装的请求方法中,配置了请求头的token,取自sessionStorage,后续所有请求都会自动携带这个token,方便服务鉴权;
  • 三是系统的前端主要分为三个页面,当用户未登录时,只能展示登录页面或者注册页面,当登录之后展示主页面,此功能需要借助路由守卫实现;
  • 四是进入到系统主页之后,用户自动和服务器建立一个特定的WebSocket连接,方便后续接收来自服务器的消息以便作出相应操作。

路由守卫是一种在用户访问特定路由时,根据用户的状态(如是否登录)来决定是否允许访问或进行重定向的机制。在React中,由于React Router本身不提供类似Vue Router的钩子函数来直接实现路由守卫,因此通常需要借助高阶组件(Higher-Order Components, HOC)来实现这一功能。

高阶组件是一种函数,它接收一个组件作为参数,并返回一个新的组件。通过这种方式,我们可以在不修改原始组件代码的前提下,为组件添加额外的功能,如权限验证。核心伪代码逻辑如下所示:

// 从浏览器本地存储中获取用户的token
const authToken = tokenStorage.getItem();
if (authToken) {
	// 如果用户的token存在,则渲染系统首页
}else{
	// 如果用户的token不存在,则渲染登录页面
}

进入到主页之后,方便后续接收来自服务器的消息以便作出相应操作,包括重新加载好友列表、重新加载群聊列表、重新加载消息列表、打开响应音视频通话窗口等,核心伪代码逻辑如下:

// 进入到主页面时建立一个 websocket 连接
const initSocket = () => {
	const newSocket = new WebSocket();
	// 建立的websocket 连接要根据接收到的不同消息作出不同的处理
	newSocket.onmessage = async message => {
		switch (message) {
			case 'friendList':
				// 重新加载好友列表
				break;
			case 'groupChatList':
				// 重新加载群聊列表
				break;
			case 'chatList':
				// 重新加载消息列表
				break;
			case 'createRoom':
				// 打开响应音视频通话窗口
				break;
		}
	};
};

2-3 后端逻辑

后端逻辑可以概括为如下几步:

  1. 获取到前端传来的usernamepassword
  2. 查询数据库, 判断用户名和密码是否正确
  3. 正确后生成jwt, 判断redis中是否有该用户的token,没有则返回想要token给前端去保存

前面提到登录成功并进入到系统主页之后,用户会自动和服务器建立一个特定的WebSocket连接。这部分工作毫无疑问也需要服务端进行配合,伪代码逻辑如下:

// 定义全局登录用户房间
global.LoginRooms = {};

// 当前用户登录后,建立一个特定的websocket连接
const initUserNotification = async (ws, req) => {
	// 获取当前用户名
	const curUsername = params.get('username');
	// 将当前用户与服务器建立的websocket连接存储在全局
	LoginRooms[curUsername] = {
		ws: ws,
		status: false // 表示用户是否正在音视频通话中
	};
	// 当用户与服务器建立好websocket连接时,需要通知当前登录的所有好友进行好友状态刷新
	NotificationUser();
	// 当用户关闭页面或者退出登录时,websocket连接也随之关闭,要作出相应处理
	ws.on('close', () => {
		if (LoginRooms[curUsername]) {
			// 需要将该websocket连接从全局中删除
			delete LoginRooms[curUsername];
			// 通知当前登录的所有好友进行好友状态刷新
			NotificationUser();
		}
	});
};

2-4 其它扩展逻辑

  • 关于 记住密码 功能的逻辑概述: 在用户第一次登录时,会将后端返回的tokenuserInfo存储在sessionStorage中,同时判断记住密码是否勾选,如果已勾选,则将后端返回的userInfotoken加密存储在localStorage中,然后才跳转到首页。此后,用户刷新进入login页面时,如果本地localStorage含有tokenuserInfo,则解密userInfo拿到用户名自动填充到输入框(密码输入框是随机字符串)、解密token填充到sessionStorage中用于点击按钮直接可以路由跳转,同时勾选框会默认勾选上。如果取消勾选,则会清除本地localStoragetokenuserInfo,走正常的流程登录(向后台发起请求)。
  • 关于 防止用户重复登录 功能的逻辑概述:在用户登录时,生成的token会按照键值对的形式存储在redis中,其中键名为username,键值为token。其它用户登录时,后端会去redis中查找此时是否有该usernametoken,有则返回提示信息阻止登录,没有则允许登录。在用户点击“退出登录”按钮时,前端获取sessionStorage中存储的用户信息并携带向后端发起请求,后端负责清除redis中该用户的token,返回提示信息允许退出登录,本地同时清除相关的sessionStorage并跳转路由。
  • 关于 JWT鉴权 的逻辑概述:在用户成功登录之后,服务器会创建一个包含用户身份信息的JSON Web Token(JWT),并将其发送给客户端。客户端随后在每次请求中将这个JWT作为认证凭证附加上,服务器则通过检查JWT的签名来验证其有效性,从而确认用户的身份。JWT中的信息被加密并且被签名,保证信息的完整性和安全性。前面提到登录校验通过后会生成一个token,这个token就是用于后续接口鉴权的。接口鉴权需要通过一个JWT鉴权中间件实现,使得API路由处理需要先经过该中间件的校验。该中间件借助jsonwebtoken这个库实现,主要逻辑为先获取请求头的JWT字符串,然后调用内置函数进行校验,校验通过后才能进行下一步处理,否则返回。

3、前端路由系统

前面说到,React Router本身不提供类似Vue Router的钩子函数来直接实现路由守卫,因此我们选择手动实现路由守卫,并结合路由表实现页面路由映射。

我们的路由表定义如下:

// client\src\router\config.ts

export interface IRouter {
	name?: string;
	redirect?: string;
	path: string;
	children?: Array<IRouter>;
	component: React.ComponentType;
}

export const router: Array<IRouter> = [
	{
		path: '/',
		component: withPrivateRoute(lazy(() => import('@/pages/home'))), // 需要登录才能访问的页面
		children: [
			{
				path: 'chat',
				component: withPrivateRoute(lazy(() => import('@/pages/home')))
			},
			{
				path: 'address-book',
				component: withPrivateRoute(lazy(() => import('@/pages/home')))
			}
		]
	},
	{
		path: '/login',
		component: Login
	},
	{
		path: '/register',
		component: Register
	},
	{
		path: '*',
		component: lazy(() => import('@/pages/error')),
		redirect: '/'
	}
];

路由守卫定义如下:

// client\src\router\private.tsx

interface IPrivateRouteProps {
	element: React.ReactNode;
}

const PrivateRoute = (props: IPrivateRouteProps) => {
	const { element } = props;
	const authToken = tokenStorage.getItem();
	if (authToken) {
		return <>{element}</>;
	}
	return (
		<>
			<Navigate to="/login" />;
		</>
	);
};
// 高阶组件HOC,用于给需要登录才能访问的页面添加路由守卫
export const withPrivateRoute = (Component: React.ElementType) => {
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	const WrappedComponent = (props: any) => {
		return <PrivateRoute element={<Component {...props} />} />;
	};
	return WrappedComponent;
};

然后遍历路由表,递归地实现页面渲染:

// client\src\router\index.tsx

const CenteredSpin = () => (
	<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
		<Spin />
	</div>
);

const RouteRender = () => {
	// 递归地渲染路由
	const routeRender = (router: Array<IRouter>) => {
		return router.map(item => {
			return (
				<Route
					key={item.name || item.path}
					path={item.path}
					element={
						item.redirect ? (
							<Navigate to={item.redirect} />
						) : (
							<Suspense fallback={<CenteredSpin />}>
								<item.component />
							</Suspense>
						)
					}
				>
					{item.children && routeRender(item.children)}
				</Route>
			);
		});
	};

	// 使用 useMemo 来记忆化 router 映射的结果,避免每次渲染都重新计算
	const routes = useMemo(() => {
		return routeRender(router);
	}, [router]);

	return <Routes>{routes}</Routes>;
};

export default RouteRender;

最后将我们实现的RouteRender组件导入使用即可:

// client\src\main.tsx

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
	<BrowserRouter>
		<ConfigProvider
			theme={{
				token: {
					colorPrimary: '#28a770'
				}
			}}
			locale={zhCN}
		>
			<App>
				<RouteRender />
			</App>
		</ConfigProvider>
	</BrowserRouter>
);

4、建立聊天

4-1 概述

用户A在选择某个好友B发送信息时,就相当于进入了和该好友唯一的房间,此时首先获取该房间的所有历史消息记录,同时将所有的消息都标为已读,并刷新消息列表(使得消息列表上面显示的未读消息数量更新)。发送消息给好友B,即相当于将这条信息借助前面登录进去后建立的WebSocket连接同时发送给房间的双方,此时双方都要刷新消息列表(发送方A主动刷新即可,接收方是由服务端通知刷新)。

4-2 前端逻辑

前端页面中,选择在好友列表页选择某一个好友发送消息或者在消息列表页选择某一条记录时,相当于进入一个唯一的聊天房间,此时要建立一个新的WebSocket连接。(注意,由于是WebSocket连接,不是HTTP连接,因此建立连接的url是以ws: 开头,而不是以http: 开头)

前端伪代码逻辑如下所示:

// 进入聊天房间时建立 websocket 连接
const initSocket = (connectParams) => {
	const newSocket = new WebSocket();
	// 获取当前房间的历史消息记录
	newSocket.onmessage = e => {};
};

4-3 后端逻辑

建立WebSocket连接时,要在服务器的全局环境下存储当前所建立的WebSocket连接,方便后续消息的接收与发送:

const ChatRooms = {}; // 全局变量存储聊天室房间,每个房间是一个对象,对象的键是用户 id / 群聊 id,值是 WebSocket 实例

// 重置聊天房间
if (!ChatRooms[room]) {
	ChatRooms[room] = {};
}
ChatRooms[room][id] = ws;

建立聊天首先要获取当前房间的所有消息记录,其SQL语句为:

SELECT m.*, u.avatar
FROM (
  SELECT sender_id, receiver_id, content, room, media_type, file_size, message.created_at
  FROM message
  WHERE room = ?
  AND type = ?
) AS m
LEFT JOIN user AS u ON u.id = m.sender_id
ORDER BY created_at ASC

这句SQL语句主要作用为主要是从message表中获取消息数据,并通过左连接将用户信息与消息数据关联起来。其具体解释为:子查询从message表中选择sender_idreceiver_idcontentroommedia_typemessage.created_at字段,并根据给定的条件筛选数据,即room列等于指定的房间号,且type列等于指定的类型。LEFT JOIN user AS u ON u.id = m.sender_id这里主要是使用左连接来将查询结果与user表关联。通过将message表中的sender_iduser表中的id进行匹配,将两个表中的数据合并在一起。这样,就可以在查询结果中获取与消息相关的发送者的头像信息。最后将查询结果按created_at列(即消息创建时间)进行升序排序。

获取完所有的历史消息后,还要将所有未读消息变成已读:

UPDATE message SET status = 1 WHERE receiver_id = ? AND room = ? AND type = ? AND status = 0

当前端利用当前建立好的WebSocket连接进行消息发送时,对应的服务端会拿到所发送的消息,进行处理后(如果是文本类型的消息,则发送及数据库中存储的消息就是原文本消息;如果是图片/视频/文件等类型的消息,则将原文件存储在服务器后,会生成一个文件路径用于发送及存储,以便减少传输及存储压力)才发送给房间的其他人(利用全局ChatRooms能快速查找到当前房间所有人的WebSocket连接)。

5、音视频通话

5-1 概述

本系统实现音视频通话的主要逻辑可以概括为三步:

  1. 用户在前端页面发起音视频通话,与服务器建立用于音视频通话的WebSocket连接;
  2. WebSocket连接建立后,通知对话的对象接收音视频通话;
  3. 进入通话双方的WebRTC信令交互过程,建立WebRTC通道,此时双方可以互相发送和接收音视频流。

5-2 前端逻辑

音视频通话前端伪代码逻辑如下所示:

// 1、用户发起音视频通话,与服务器建立用于音视频通话的WebSocket连接
const newSocket = new WebSocket();
// 2、初始化本人的音视频和对方的WebRTC通道
initStream();
initPC();
// 3、WebSocket连接后,进入和通话对象的WebRTC信令交互环节
connectWebRTC();
// 4、可以利用建立好的WebRTC通道互相发送和接收音视频流

5-3 后端逻辑

后端首先需要存储用户和服务器建立的用于音视频通话的WebSocket连接,伪代码逻辑如下:

const ChatRTCRooms = {}; // 全局变量存储聊天室房间,每个房间是一个对象,对象的键是用户名 username,值是 WebSocket实例

// 重置聊天房间
if (!ChatRTCRooms[room]) {
	ChatRTCRooms[room] = {};
}
ChatRTCRooms[room][username] = ws;

建立好该WebSocket连接之后,便可利用该连接进行WebRTC信令转发的作用。

5-4 WebRTC信令交互逻辑

端与端之间进行的音视频流传输,需要进行一些信令交互以实现WebRTC通道连接,而信令交互需要借助WebSocket实现,用于转发信令交互的命令和信息。

建立WebRTC音视频通信的基本流程:

  1. 初始化WebRTC通道;
  2. 双方进行媒体协商;
  3. 建立网络通信;
  4. 实现音视频流的发送与接收。

其中,媒体协商过程示意图如下:

image.png

本系统实现音视频聊天逻辑则可以概括为下面几步:

  1. 邀请人点击音视频通话按钮,建立WebSocket连接并向对方发送create_room指令,判断能不能进行通话,能则通知好友打开音视频通话界面,不能则返回notConnect指令及原因;
  2. 被邀请人收到create_room指令后,打开音视频通话界面并建立自己的WebSocket连接,初始化WebRTC通道并向邀请人发送new_peer指令;
  3. 邀请人收到new_peer指令后,也初始化WebRTC通道,进入媒体协商环节,设置自己的媒体信息,发送offer指令和自己的媒体信息给对方;
  4. 被邀请人收到offer指令后,设置自己和邀请人的媒体信息,发送answer指令和自己的媒体信息给邀请人;
  5. 邀请人收到answer指令后,设置被邀请人的媒体信息,此时双方的媒体信息设置完毕,进入建立网络通信环节;
  6. 双方互相发送ice_candidate指令和网络信息,设置对方的网络信息;
  7. 双方的网络信息设置完毕,可以进行音视频通话;

群音视频聊天时,邀请人是向所有群友发送邀请(一对多),每个收到邀请的群友同意(即向房间内其它人发送new_peer指令,一对多)之后,之后的通话建立过程即重复上述的3到7步(一对一)。