为什么需要 Socket?

由于 HTTP 的限制,我们的 CatBook 的 Chat 在每次获取新的聊天时都需要刷新下网页才能加载。

image-20241218163406677

因此,我们需要Socket来教服务器发送会话

什么是 Socket?

Socket 是网络通信的一个抽象概念,它提供了服务器和客户端之间双向通信的能力。通过 Socket,客户端和服务器可以持续地交换数据,而不需要像传统 HTTP 请求那样频繁建立和关闭连接。

在 JavaScript 中,最常用的 Socket 技术是 WebSocket 和基于库的实现(如 Socket.IO)

Socket IO

image-20241218163810334

基本用法

1
socketManager.getIo().emit("event_name", data);

"event_name"是订阅的主题

1
socket.on("event_name", someFunction);

someFunction: 当你收到一个这样的 socket 事件时,应该做什么:

(data) => { do something with data }

image-20241218170007249

私聊

1
socketManager.getSocketFromUserID(“whiskers_id”).emit(“meow”, “FOOD”)

如果使用getIo()的话会导致所有人可见

image-20241218170433226

image-20241218170404976

详细流程解析


1. 用户连接到服务器

  • 当一个用户(如 Whiskers)与服务器建立连接时,服务器会通过 socketManager.addUser 将用户和 socket 进行绑定。

在客户端,用户初始化 socket 连接并向服务器发送初始化请求。

App.js

1
2
3
4
5
6
7
const handleLogin = (res) => {
const userToken = res.tokenObj.id_token;
post("/api/login", { token: userToken }).then((user) => {
setUserId(user._id);
post("/api/initsocket", { socketid: socket.id });
});
};

客户端代码 (Chatbook.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
useEffect(() => {
get("/api/activeUsers").then((data) => {
if (props.userId) {
setActiveUsers([ALL_CHAT].concat(data.activeUsers));
}
});
}, []);

useEffect(() => {
socket.on("message", addMessages);
return () => {
socket.off("message", addMessages);
};
}, [activeChat.recipient._id, props.userId]);

useEffect(() => {
const callback = (data) => {
setActiveUsers([ALL_CHAT].concat(data.activeUsers));
};
socket.on("activeUsers", callback);
return () => {
socket.off("activeUsers", callback);
};
}, []);

服务器端代码 (api.js):

1
2
3
4
5
6
7
8
9
router.post("/initsocket", (req, res) => {
if (req.user) {
socketManager.addUser(
req.user,
socketManager.getSocketFromSocketID(req.body.socketid)
);
}
res.send({});
});
  • 流程总结:
    1. 客户端向服务器发送请求初始化 socket 连接。
    2. 服务器调用 socketManager.addUser,将用户和对应的 socket 绑定。

2. 将用户与对应的 Socket 进行绑定

  • 服务器通过 socketManager.addUser 将用户的 ID 和 socket 进行映射,便于后续查找。

示例代码:

1
2
3
4
5
6
7
8
9
router.post("/initsocket", (req, res) => {
if (req.user) {
socketManager.addUser(
req.user,
socketManager.getSocketFromSocketID(req.body.socketid)
);
}
res.send({});
});
  • socketManager.addUser 是一个封装的函数,它负责记录用户和对应 socket 的关系。

3. 服务器端存储映射关系

  • socketManager 通过存储用户和 socket ID 的映射来实现快速查找。
  • 在客户端,用户的 socket 连接会持续更新 活跃用户列表

服务器端代码 (api.js):

1
2
3
router.get("/activeUsers", (req, res) => {
res.send({ activeUsers: socketManager.getAllConnectedUsers() });
});

客户端代码 (Chatbook.js):

1
2
3
4
5
6
7
8
useEffect(() => {
socket.on("activeUsers", (data) => {
setActiveUsers([ALL_CHAT].concat(data.activeUsers));
});
return () => {
socket.off("activeUsers");
};
}, []);

4. 校验用户信息

  • 当用户发送消息时,服务器通过 auth.ensureLoggedIn 校验用户的登录状态。

服务器端代码 (api.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
router.post("/message", auth.ensureLoggedIn, (req, res) => {
console.log(
`Received a chat message from ${req.user.name}: ${req.body.content}`
);

const message = new Message({
recipient: req.body.recipient,
sender: {
_id: req.user._id,
name: req.user.name,
},
content: req.body.content,
});
message.save();

if (req.body.recipient._id === "ALL_CHAT") {
socketManager.getIo().emit("message", message);
} else {
socketManager.getSocketFromUserID(req.user._id).emit("message", message);
if (req.user._id !== req.body.recipient._id) {
socketManager
.getSocketFromUserID(req.body.recipient._id)
.emit("message", message);
}
}
});
  • 流程总结:
    1. 客户端发送消息,服务器通过 auth.ensureLoggedIn 确保用户处于登录状态。
    2. 服务器将消息广播给目标用户或 ALL_CHAT

5. 实际数据通信过程

  • 用户发送消息,服务器接收并保存到数据库,同时通过 socket 向目标用户或聊天室广播消息。

客户端代码 (Chatbook.js):

1
2
3
4
5
6
7
8
9
10
11
12
13
const addMessages = (data) => {
setActiveChat((prevActiveChat) => ({
recipient: prevActiveChat.recipient,
messages: prevActiveChat.messages.concat(data),
}));
};

useEffect(() => {
socket.on("message", addMessages);
return () => {
socket.off("message", addMessages);
};
}, [activeChat.recipient._id, props.userId]);
  • 当消息发送给当前活跃用户,客户端会调用 addMessages 更新聊天界面的消息状态。

服务器端代码 (api.js):

1
2
3
4
5
6
7
8
9
10
if (req.body.recipient._id === "ALL_CHAT") {
socketManager.getIo().emit("message", message);
} else {
socketManager.getSocketFromUserID(req.user._id).emit("message", message);
if (req.user._id !== req.body.recipient._id) {
socketManager
.getSocketFromUserID(req.body.recipient._id)
.emit("message", message);
}
}
  • 流程总结:
    1. 用户通过 socket 发送消息。
    2. 服务器保存消息到数据库。
    3. 服务器向目标用户或所有用户广播消息。
    4. 客户端监听到 message 事件后更新消息列表。

6. 总结流程

  1. 用户连接到服务器:
    • 客户端初始化 socket 连接,并向服务器发送 initsocket 请求。
    • 服务器通过 socketManager.addUser 绑定用户与 socket。
  2. 获取活跃用户:
    • 服务器向客户端发送当前活跃用户列表。
    • 客户端监听 activeUsers 事件并更新列表。
  3. 消息发送与接收:
    • 客户端发送消息,服务器校验用户身份并保存消息。
    • 服务器通过 socket 广播消息给目标用户或聊天室。
    • 客户端接收消息并更新消息状态。
  4. 实时更新界面:
    • 通过监听 messageactiveUsers 事件,客户端实现实时聊天和活跃用户列表的更新。

流程图(总结)

  1. 用户初始化 socket → 服务器绑定用户与 socket
  2. 客户端请求活跃用户 → 服务器返回活跃用户列表
  3. 用户发送消息 → 服务器广播消息 → 客户端更新聊天界面

lab 解析

1. 客户端(Chatbook 组件)初始化:

在 React 前端中,Chatbook 组件负责管理聊天功能,用户状态以及与服务器进行通信。

代码片段:

1
2
import { socket } from "../../client-socket.js";
import { get } from "../../utilities";
  • 导入 socket 对象,用于与服务器进行 Socket.IO 通信
  • 导入 get 函数,用于向服务器发送 HTTP 请求,比如加载消息历史记录和获取活跃用户。

2. 加载历史消息:

Chatbook 在组件挂载或切换聊天用户时,会加载历史聊天记录。

代码片段:

1
2
3
4
5
6
7
8
const loadMessageHistory = (recipient) => {
get("/api/chat", { recipient_id: recipient._id }).then((messages) => {
setActiveChat({
recipient: recipient,
messages: messages,
});
});
};
  • 通过 /api/chat GET 请求,从服务器端获取指定 recipient 的历史聊天记录。
  • 将返回的消息列表存储到组件状态 activeChat 中。

3. 监听服务器消息推送:

通过 Socket.IO 监听来自服务器的新消息并更新聊天状态。

代码片段:

1
2
3
4
5
6
useEffect(() => {
socket.on("message", addMessages);
return () => {
socket.off("message", addMessages);
};
}, [activeChat.recipient._id, props.userId]);
  • 使用 socket.on("message", callback) 监听服务器发送的消息。
  • addMessages 函数将新的消息合并到当前聊天的消息列表中。

消息更新逻辑:

1
2
3
4
5
6
const addMessages = (data) => {
setActiveChat((prevActiveChat) => ({
recipient: prevActiveChat.recipient,
messages: prevActiveChat.messages.concat(data),
}));
};
  • 如果消息的 recipient 匹配当前的 activeChat,则将消息追加到 messages 中。

4. 获取活跃用户列表:

客户端通过 REST API 和 Socket.IO 获取和监听在线用户列表。

代码片段(初次加载活跃用户):

1
2
3
4
5
6
7
useEffect(() => {
get("/api/activeUsers").then((data) => {
if (props.userId) {
setActiveUsers([ALL_CHAT].concat(data.activeUsers));
}
});
}, []);
  • 通过 /api/activeUsers 获取当前所有在线用户列表。

代码片段(Socket 更新活跃用户):

1
2
3
4
5
6
7
8
9
useEffect(() => {
const callback = (data) => {
setActiveUsers([ALL_CHAT].concat(data.activeUsers));
};
socket.on("activeUsers", callback);
return () => {
socket.off("activeUsers", callback);
};
}, []);
  • 监听服务器推送的 activeUsers 事件,实时更新在线用户状态。

5. 服务端 API 与 Socket.IO 交互:

在服务端,通过 socketManager 管理用户与 Socket.IO 实例,并处理消息发送和用户连接。

初始化 Socket 连接:

当用户连接时,通过 /initsocket API 将用户与 Socket 关联。

1
2
3
4
5
6
7
8
router.post("/initsocket", (req, res) => {
if (req.user)
socketManager.addUser(
req.user,
socketManager.getSocketFromSocketID(req.body.socketid)
);
res.send({});
});
  • socketManager.addUser 关联用户 ID 与 socket 实例。

处理消息发送:

/message 路由中,接收用户发送的消息并通过 Socket.IO 广播。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
router.post("/message", auth.ensureLoggedIn, (req, res) => {
const message = new Message({
recipient: req.body.recipient,
sender: {
_id: req.user._id,
name: req.user.name,
},
content: req.body.content,
});
message.save();

if (req.body.recipient._id == "ALL_CHAT") {
socketManager.getIo().emit("message", message);
} else {
socketManager.getSocketFromUserID(req.user._id).emit("message", message);
if (req.user._id !== req.body.recipient._id) {
socketManager
.getSocketFromUserID(req.body.recipient._id)
.emit("message", message);
}
}
});
  • 消息保存到数据库
  • 如果消息的 recipientALL_CHAT,通过 getIo().emit 广播给所有用户。
  • 否则,只发送给发送者和接收者。

获取在线用户:

/activeUsers API 路由中,获取所有当前连接的用户列表。

1
2
3
router.get("/activeUsers", (req, res) => {
res.send({ activeUsers: socketManager.getAllConnectedUsers() });
});
  • 通过 socketManager.getAllConnectedUsers() 获取所有在线用户。

6. 客户端与服务端事件对接:

客户端事件 服务端监听/触发 描述
socket.on("message") socketManager.getIo().emit("message") 广播消息给所有连接的客户端。
socket.on("activeUsers") 手动触发 activeUsers 更新当前在线用户列表。
HTTP 请求 /api/chat MongoDB 查询 Message 获取历史消息记录。
HTTP 请求 /api/activeUsers socketManager.getAllConnectedUsers() 获取所有当前在线用户。
HTTP 请求 /initsocket socketManager.addUser 将用户与 socket 实例关联。

总结流程:

  1. 客户端启动,加载在线用户列表和历史聊天记录。
  2. 初始化 Socket 连接,客户端监听 messageactiveUsers 事件。
  3. 用户通过输入消息触发 /message API,服务端保存消息并通过 Socket.IO 推送给接收方。
  4. 服务端在用户上线或下线时,触发 activeUsers 事件,客户端实时更新在线用户状态。
  5. 客户端界面通过 React 状态管理实时更新聊天内容和用户列表。

通过以上流程,前端 React 组件和服务端 API 通过 Socket.IO 实现了双向实时通信,同时保证了数据的持久化存储和在线状态管理。

前后端交互逻辑

从用户角度看,当用户第一次登录网站、进入 ALL_CHAT(公共聊天)以及与他人进行私聊的整个流程,涉及一系列前后端交互,下面我会详细解释流程:


1. 第一次登录网站

前端:

  1. 用户打开网站

    • App组件的useEffect中,前端调用/api/whoami接口,检查当前用户是否已登录。

      1
      2
      3
      4
      5
      6
      7
      useEffect(() => {
      get("/api/whoami").then((user) => {
      if (user._id) {
      setUserId(user._id);
      }
      });
      }, []);
    • 如果用户未登录,返回空对象,用户无法使用功能;如果登录成功,用户信息会保存在 userId 状态中。

  2. 登录操作

    • 用户通过第三方登录(例如 Google)登录后,前端会调/api/login接口,将登录信息发送给后端。

      1
      2
      3
      4
      post("/api/login", { token: userToken }).then((user) => {
      setUserId(user._id);
      post("/api/initsocket", { socketid: socket.id });
      });
    • 登录后,前端初始化用户的 WebSocket 连接,调用 /api/initsocket

后端:

  1. /api/login 接收用户的登录请求,验证身份后保存用户会话信息。
  2. /api/initsocket 将用户和其 WebSocket 连接绑定,方便后续消息推送。

2. 进入 ALL_CHAT 公共聊天

前端:

  1. 用户进入 /chat/ 路由,渲染 Chatbook 组件。

    • 通过 useEffect调用 /api/activeUsers接口,获取所有当前活跃用户列表,包括 ALL_CHAT

      1
      2
      3
      4
      5
      get("/api/activeUsers").then((data) => {
      if (props.userId) {
      setActiveUsers([ALL_CHAT].concat(data.activeUsers));
      }
      });
  2. ChatList 渲染用户列表:

    • 用户列表通过 map 显示每个用户,SingleUser 组件负责显示单个用户的名称。
    • active 属性 用于标记当前选中的聊天对象,CSS 变化表示用户处于活跃状态。
    • 点击某个用户时,触发 props.setActiveUser(user)
  3. useEffect 中触发加载历史消息:

    • 当用户初始加载 ALL_CHAT或点击其他用户时,调用 loadMessageHistory,请求历史消息:

      1
      2
      3
      4
      5
      6
      7
      8
      const loadMessageHistory = (recipient) => {
      get("/api/chat", { recipient_id: recipient._id }).then((messages) => {
      setActiveChat({
      recipient: recipient,
      messages: messages,
      });
      });
      };

后端:

  1. /api/activeUsers 返回所有当前活跃的用户列表。

  2. /api/chat

    • 如果 recipient_id"ALL_CHAT",查询数据库中发送到 ALL_CHAT 的消息。
    • 返回这些消息给前端。

3. 与他人私聊

前端:

  1. 用户点击某个用户

    • ChatList中,点击用户触发 setActiveUser(user)

      1
      2
      3
      4
      5
      6
      7
      8
      const setActiveUser = (user) => {
      if (user._id !== activeChat.recipient._id) {
      setActiveChat({
      recipient: user,
      messages: [],
      });
      }
      };
    • useEffect 监听 activeChat.recipient._id 的变化,调用 loadMessageHistory,加载与该用户的历史聊天记录。

  2. 接收消息的实时更新

    • 前端通过 WebSocket 监听 "message"事件:

      1
      2
      3
      4
      5
      6
      useEffect(() => {
      socket.on("message", addMessages);
      return () => {
      socket.off("message", addMessages);
      };
      }, [activeChat.recipient._id, props.userId]);
    • 当新消息推送到前端时,addMessages 会将消息追加到当前活跃聊天的 messages 中。

  3. 发送消息(假设通过输入框发送)

    • 用户发送消息时,前端通过 /api/message向后端发送消息。

      1
      2
      3
      4
      post("/api/message", {
      recipient: activeChat.recipient,
      content: messageContent,
      });

后端:

  1. 存储和广播消息

    • /api/message保存消息到数据库,同时通过 WebSocket 发送消息给接收方:

      1
      2
      3
      4
      5
      6
      7
      if (req.body.recipient._id == "ALL_CHAT") {
      socketManager.getIo().emit("message", message);
      } else {
      socketManager
      .getSocketFromUserID(req.body.recipient._id)
      .emit("message", message);
      }
  2. 实时同步活跃用户

    • 通过 WebSocket 事件 "activeUsers" 推送当前在线用户列表。

4. 用户切换聊天对象

  1. 当用户在 ChatList 中点击其他用户时:

    • setActiveUser 更新当前活跃聊天对象,并清空消息。
    • useEffect 会自动调用 loadMessageHistory,加载与新用户的历史消息。
  2. 切换时的消息同步

    • 如果后台推送了新消息,addMessages会检查新消息的接收者是否与当前聊天对象匹配:

      1
      2
      3
      4
      setActiveChat((prevActiveChat) => ({
      recipient: prevActiveChat.recipient,
      messages: prevActiveChat.messages.concat(data),
      }));

总结流程

  1. 登录时:前端获取当前用户信息并初始化 WebSocket。

  2. 进入聊天页:加载活跃用户列表和 ALL_CHAT 历史消息。

  3. 点击用户:加载私聊历史消息并切换 activeChat

  4. 消息实时同步:

    • 前端监听 "message",将新消息追加到当前聊天中。
    • 后端通过 WebSocket 推送消息给接收方或广播给所有用户。
  5. 切换聊天setActiveUser 切换聊天对象,重新加载对应历史消息。


这样,整个前后端交互的逻辑就完整了!核心是通过 API 加载历史消息、WebSocket 实时同步新消息,以及 activeChat 状态控制当前活跃聊天对象。