构建一个简单的网络聊天室:从零到运行
一、项目概述
我们的聊天室项目主要包含以下几个核心功能:
- 用户注册与登录:用户通过输入唯一的网名进入聊天室,如果网名已被占用,则提示重新输入。
- 实时消息传输:用户可以发送和接收消息,所有消息都会实时显示在聊天窗口中。
- 心跳机制:客户端定期向服务器发送心跳信号,以保持连接状态。
- 排行榜功能:用户可以查询当前聊天室内的排行榜信息。
- 优雅断开连接:用户离开时,系统会自动更新状态并通知其他用户。
整个项目分为客户端和服务器端两部分。客户端负责用户交互,服务器端负责处理用户连接、消息转发和状态管理。
二、技术栈
- 编程语言:Go
- 网络通信:TCP 协议
- 数据编码:自定义二进制协议
- 并发处理:Go 协程
- 存储:Redis(用于排行榜功能)
三、客户端实现
客户端是用户与聊天室交互的入口。它负责处理用户输入、发送消息、接收消息以及与服务器的通信。
(一)网名注册流程
用户首次进入聊天室时,需要输入一个唯一的网名。客户端将网名发送到服务器进行验证。如果网名已被占用,服务器会返回提示信息,客户端会要求用户重新输入。
for {
fmt.Printf("输入你的网名: ")
text := bufio.NewReader(os.Stdin)
nickname, _ := text.ReadString('\n')
nickname = strings.TrimSpace(nickname)
if nickname == "" {
fmt.Println("网名不能为空,请重新输入!")
continue
}
encodedNickname, err := proto.Encode(nickname)
if err != nil {
fmt.Println("Error encoding nickname:", err)
return
}
if _, err = conn.Write(encodedNickname); err != nil {
fmt.Println("Error sending nickname:", err)
return
}
reader := bufio.NewReader(conn)
response, err := proto.Decode(reader)
if err != nil {
fmt.Println("Error reading response:", err)
return
}
if response == "网名已被占用,请重新输入。\n" {
fmt.Println("网名已被占用,请重新输入。")
continue
}
fmt.Println("成功进入")
break
}
(二)心跳机制
为了保持客户端与服务器的连接状态,客户端会定期发送心跳信号。心跳信号是一个简单的字符串 "HEARTBEAT"
,每隔 15 秒发送一次。
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
heartbeat, err := proto.Encode("HEARTBEAT")
if err != nil {
fmt.Println("心跳编码错误:", err)
return
}
if _, err := conn.Write(heartbeat); err != nil {
fmt.Println("发送心跳失败,连接可能已关闭")
return
}
}
}
}()
(三)消息接收与发送
客户端通过协程实时接收服务器发送的消息,并将其显示在聊天窗口中。同时,用户输入的消息会被编码并发送到服务器。
// 消息接收协程
go func() {
reader := bufio.NewReader(conn)
for {
message, err := proto.Decode(reader)
if err != nil {
fmt.Println("\n与服务器的连接已断开")
os.Exit(1)
}
fmt.Print(message)
}
}()
// 消息发送循环
for {
text, _ := bufio.NewReader(os.Stdin).ReadString('\n')
if strings.TrimSpace(text) == "exit" {
fmt.Println("退出聊天室")
return
}
if strings.TrimSpace(text) == "/ranking" {
// 发送查询命令
proto.EncodeAndSend(conn, "/ranking")
continue
}
if strings.TrimSpace(text) == "" {
continue
}
encodedMsg, err := proto.Encode(text)
if err != nil {
fmt.Println("消息编码错误:", err)
continue
}
if _, err := conn.Write(encodedMsg); err != nil {
fmt.Println("消息发送失败:", err)
return
}
}
四、服务器端实现
服务器端是聊天室的核心,负责处理客户端的连接、消息转发和状态管理。服务器端的代码较为复杂,涉及多个功能模块,包括用户管理、消息广播、排行榜维护等。以下是服务器端的关键代码片段:
(一)用户连接管理
服务器端使用一个 clients
映射来管理所有连接的客户端。每当有新的客户端连接时,将其添加到映射中;客户端断开连接时,从映射中移除。
func handleDisconnect(nickname string, err error) {
mu.Lock()
delete(clients, nickname)
mu.Unlock()
// 删除排行榜中的用户数据
if _, zremErr := rdb.ZRem(ctx, RankingKey, nickname).Result(); zremErr != nil {
fmt.Printf("删除用户 %s 排行榜信息失败: %v\n", nickname, zremErr)
}
// 构造离开消息
leaveMsg := ChatMessage{
Nickname: "系统",
Content: fmt.Sprintf("%s 离开了聊天室", nickname),
SentAt: time.Now().Unix(),
}
if msgData, err := json.Marshal(leaveMsg); err == nil {
rdb.LPush(ctx, "chat_messages", msgData)
}
fmt.Printf("%s 断开连接: %v\n", nickname, err)
}
(二)消息广播
服务器端接收客户端发送的消息,并将其广播给所有连接的客户端。同时,服务器会将消息存储到 Redis 中,以便后续查询。
func broadcastMessage(msg ChatMessage) {
msgData, err := json.Marshal(msg)
if err != nil {
fmt.Println("消息编码失败:", err)
return
}
rdb.LPush(ctx, "chat_messages", msgData)
for _, client := range clients {
if _, err := client.Write(msgData); err != nil {
fmt.Println("消息发送失败:", err)
}
}
}
(三)排行榜功能
服务器端使用 Redis 的有序集合(Sorted Set)来维护排行榜信息。每当用户发送消息时,服务器会更新用户的排名。
func updateRanking(nickname string) {
score, err := rdb.ZScore(ctx, RankingKey, nickname).Result()
if err != nil {
fmt.Printf("获取用户 %s 排名失败: %v\n", nickname, err)
return
}
newScore := score + 1
if _, err := rdb.ZAdd(ctx, RankingKey, &redis.Z{
Score: newScore,
Member: nickname,
}).Result(); err != nil {
fmt.Printf("更新用户 %s 排名失败: %v\n", nickname, err)
}
}
五、数据传输协议
为了高效地传输数据,我们设计了一个简单的二进制协议。协议的格式如下:
- 长度字段:4 字节,表示消息内容的长度。
- 消息内容:可变长度,实际消息内容。
编码和解码函数如下:
func Encode(message string) ([]byte, error) {
msgBytes := []byte(message)
length := int32(len(msgBytes))
pkg := new(bytes.Buffer)
if err := binary.Write(pkg, binary.LittleEndian, length); err != nil {
return nil, err
}
if err := binary.Write(pkg, binary.LittleEndian, msgBytes); err != nil {
return nil, err
}
return pkg.Bytes(), nil
}
func Decode(reader *bufio.Reader) (string, error) {
lengthBytes, err := reader.Peek(4)
if err != nil {
return "", err
}
lengthBuff := bytes.NewBuffer(lengthBytes)
var length int32
if err := binary.Read(lengthBuff, binary.LittleEndian, &length); err != nil {
return "", err
}
if int32(reader.Buffered()) < 4+length {
return "", errors.New("数据不完整")
}
packet := make([]byte, 4+length)
if _, err := reader.Read(packet); err != nil {
return "", err
}
return string