项目标题
聊天室
项目描述
聊天室项目是使用go语言编写,通过redis实现异步消息队列功能,从而实现客户端和服务端的通信
功能
- proto包中有给消息进行编码和解码的代码,解决TCP粘包的问题
- 用户每发一条消息,活跃度加1 ,通过zset有序队列,实现活跃度排名的功能
- 使用list实现异步消息队列功能
- 心跳检测
- 客户端和服务端的安全退出
- 日志功能
项目结构

这个项目已经push到了github上
https://github.com/cxzgit/ChatRoom
服务端代码
package main
import (
"bufio"
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"goEnv/gocode/RedisExam/chatRoom/proto"
"goEnv/gocode/RedisExam/chatRoom/server/utils"
"io"
"log"
"net"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
)
type Message struct {
System bool
Username string
Content string
}
func NewMessage(system bool, username, content string) Message {
return Message{System: system, Username: username, Content: content}
}
func (m Message) ToMap() map[string]string {
return map[string]string{
"system": fmt.Sprintf("%t", m.System),
"username": m.Username,
"message": m.Content,
}
}
var (
clients *utils.ClientManager
config *utils.Config
//心跳超时时间
heartbeatTimeout time.Duration
//心跳间隔时间
heartbeatInterval time.Duration
//声明一个Redis客户端指针
rdb *utils.RedisClient
// 全局日志记录器
logger *log.Logger
)
// 在程序启动时初始化Redis客户端连接
func init() {
var err error
config, err = utils.LoadConfig()
// 加载配置文件
if err != nil {
log.Println("加载配置文件失败:", err)
}
// 将心跳超时和检测间隔转换为 time.Duration(单位:秒)
heartbeatTimeout = time.Duration(config.HeartbeatTimeout) * time.Second
heartbeatInterval = time.Duration(config.HeartbeatInterval) * time.Second
// 初始化 Redis 客户端
rdb = utils.NewRedisClient(config)
// 初始化日志文件
logger, err = utils.InitLogger(config.LogFile)
if err != nil {
fmt.Println("日志文件初始化失败:", err)
}
clients = utils.NewClientManager(logger)
}
// process 处理单个客户端连接
func process(conn net.Conn) {
var client *utils.ClientInfo
defer conn.Close()
reader := bufio.NewReader(conn)
var name string
// 循环读取用户名,确保用户名不重复
for {
recvName, err := proto.Decode(reader)
if err != nil {
logger.Println("读取用户名错误:", err)
return
}
// 去除用户名前后的空白字符
trimmedName := strings.TrimSpace(recvName)
// 检查用户名是否为空或仅包含空白字符
if trimmedName == "" {
errMsg, _ := proto.Encode("用户名不能为空,请重新输入")
conn.Write(errMsg)
continue
}
// 检查是否重复
duplicate := clients.IsExistUserName(trimmedName)
if duplicate {
// 发送错误消息,请求重新输入
errMsg, _ := proto.Encode("用户名已存在,请重新输入")
conn.Write(errMsg)
logger.Println("拒绝重复用户名:", recvName)
continue
} else {
name = recvName
// 发送欢迎消息作为用户名确认
welcomeMsg, _ := proto.Encode("欢迎" + name + " 进入聊天室~")
conn.Write(welcomeMsg)
//创建新用户
client = utils.NewClient(conn, name)
break
}
}
// 保存客户端信息
clients.AddClient(conn, client)
logger.Println("用户名接收成功:", name)
//添加用户到排行榜
rdb.IncrUserActivity(name)
welMes := "欢迎 " + name + " 进入聊天室"
messageWel := NewMessage(true, "SYSTEM", welMes)
welcomeData := messageWel.ToMap()
if err := rdb.PushMessage("chat_queue_sys", welcomeData); err != nil {
logger.Println("欢迎消息入队失败:", err)
}
// 循环接收客户端消息
for {
msg, err := proto.Decode(reader)
if err != nil {
if err == io.EOF {
logger.Println("客户端断开连接:", name)
} else {
logger.Println("读取消息错误:", err)
}
clients.RemoveClient(conn)
//当客户端断开连接或者读取消息的时候报错的话,将该用户的活跃度删除
rdb.RemoveUser(name)
return
}
// 如果收到心跳消息 "ping",更新最后心跳时间,不进行广播
if msg == "/PING" {
client.LastHeartbeat = time.Now()
fmt.Printf("更新完时间:%s,更新时间:%s\n", client.LastHeartbeat, time.Now())
continue
}
// 处理退出指令
if msg == "/EXIT" {
quitMes := name + " 退出聊天室"
messageQuit := NewMessage(true, "SYSTEM", quitMes)
quitData := messageQuit.ToMap()
logger.Println(name, "退出群聊")
if err := rdb.PushMessage("chat_queue_sys", quitData); err != nil {
logger.Println("退出消息入队失败:", err)
}
//从map集合中删除该用户
clients.RemoveClient(conn)
//将该用户的活跃度删除
rdb.RemoveUser(name)
return
}
if msg == "/RANK" {
rank := displayRank()
rankMsg, _ := proto.Encode(rank)
conn.Write(rankMsg)
continue
}
norMes := msg
messageNor := NewMessage(false, name, norMes)
messageDate := messageNor.ToMap()
//存入Redis队列中
if err := rdb.PushMessage("chat_queue_nor", messageDate); err != nil {
logger.Println("消息入队失败:", err)
}
}
}
func displayRank() string {
// 获取用户排名和分数
result, err := rdb.ShowRange()
if err != nil {
return fmt.Sprintf("rdb.ZRevRangeWithScores err:", err)
}
if len(result) == 0 {
return fmt.Sprintf("当前没有用户的活跃度数据哦!🌱")
}
// 排行榜消息
rankMessage := " 🏆 **聊天室活跃度排行榜** 🏆\n"
for k, v := range result {
rank := k + 1
user := v.Member.(string)
score := v.Score
// 添加不同排名的称号
var rankTitle string
switch rank {
case 1:
rankTitle = "🔥 超级活跃王"
case 2:
rankTitle = "⚡ 闪耀全场"
case 3:
rankTitle = "🌟 聊天小达人"
default:
rankTitle = "💬 活跃用户"
}
// 添加emoji🏆🥈🥉
emoji := "🎖️"
if rank == 1 {
emoji = "🥇"
} else if rank == 2 {
emoji = "🥈"
} else if rank == 3 {
emoji = "🥉"
}
// 组装消息
rankMessage += fmt.Sprintf("%s 第%d名: ** %s ** (%s) - 活跃度: %.0f 分\n", emoji, rank, user, rankTitle, score)
}
return rankMessage
}
// heartbeatChecker 定时检查客户端心跳
func heartbeatChecker(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Println("心跳检测 goroutine 停止")
return
case <-ticker.C:
clients.HeartbeatChecker(heartbeatTimeout)
}
}
}
// redisBroadcast 从 Redis 队列中取出系统消息并广播给所有在线客户端
func redisBroadcastSysMes(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
for {
// BRPop 阻塞等待消息,timeout 0 表示无限等待
sysMsg, err := rdb.PopMessage(ctx, "chat_queue_sys")
if err != nil {
// 如果是由于 ctx 取消导致的错误,也可以直接返回
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
logger.Println("系统消息广播 goroutine停止:", err)
return
}
if err == redis.Nil { // redis.Nil 表示没有消息
// 可以选择直接 continue,不记录错误日志
continue
}
logger.Println("BRPop 错误:", err)
continue
}
sysMessage := sysMsg["message"]
encodedSysMsg, _ := proto.Encode(sysMessage)
//广播系统消息
clients.BroadcastSysMessage(encodedSysMsg)
}
}
// redisBroadcast 从 Redis 队列中取出普通消息并广播给其他在线客户端
func redisBroadcastNorMsg(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
for {
// BRPop 阻塞等待消息,timeout 0 表示无限等待
messageData, err := rdb.PopMessage(ctx, "chat_queue_nor")
if err != nil {
// 如果是由于 ctx 取消导致的错误,也可以直接返回
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
logger.Println("普通消息广播 goroutine停止:", err)
return
}
if err == redis.Nil { // redis.Nil 表示没有消息
// 可以选择直接 continue,不记录错误日志
continue
}
logger.Println("BRPop 错误:", err)
continue
}
//获取用户名和消息
name := messageData["username"]
msg := messageData["message"]
logger.Println("发送者:", name, "消息:", msg)
//广播消息
//将消息进行编码
encodedMsg, _ := proto.Encode(fmt.Sprintf("%s:%s", name, msg))
//广播普通消息
clients.BroadcastNorMessage(encodedMsg, name)
//用户每发送一条消息活跃度加一
rdb.IncrUserActivity(name)
}
}
// 服务器安全退出进行资源清理
func shutdownServer() {
logger.Println("执行服务器安全退出...")
// 1. 通知所有客户端断开连接
encodedMsg, _ := proto.Encode("服务器即将关闭,所有连接将断开...")
//广播给所有用户
clients.BroadcastSysMessage(encodedMsg)
// 2. 清理 Redis 相关数据
// 删除活跃度排行榜
rdb.Del("chatRoom_user_activity")
// 清空系统消息队列
rdb.Del("chat_queue_sys")
// 清空普通消息队列
rdb.Del("chat_queue_nor")
// 3. 关闭 Redis 连接
rdb.Close()
logger.Println("服务器已安全退出。")
os.Exit(0)
}
func main() {
//创建上下文和waitGroup用于优雅的关闭goroutine
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// 监听地址采用配置文件中的端口
listenAddr := ":" + config.ServerPort
listener, err := net.Listen("tcp", listenAddr)
if err != nil {
logger.Println("监听失败:", err)
return
}
defer listener.Close()
logger.Println("服务器已启动,监听端口:", config.ServerPort)
// 启动心跳检查 goroutine
wg.Add(1)
go heartbeatChecker(ctx, &wg)
// 启动 Redis 队列消息消费与广播 欢迎和退出消息
wg.Add(1)
go redisBroadcastSysMes(ctx, &wg)
//启动 Redis 队列消息广播 其他客户端获取其他用户的消息
wg.Add(1)
go redisBroadcastNorMsg(ctx, &wg)
// 监听 OS 终止信号
signalChan := make(chan os.Signal, 1)
//主要用于监听 操作系统的信号(signals),
//特别是 SIGINT 和 SIGTERM,从而实现 优雅退出 机制。
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
//收到退出信号后退出循环
return
default:
logger.Println("接受连接错误:", err)
continue
}
}
logger.Printf("新客户端连接: %v\n", conn.RemoteAddr().String())
go process(conn)
}
}()
// 等待终止信号
<-signalChan
logger.Println("服务器即将关闭...")
//通知所有goroutine退出
cancel()
//关闭listener,防止连接阻塞
listener.Close()
//等待所有goroutine正常退出
wg.Wait()
// 调用安全退出函数
shutdownServer()
}
客户端代码
package main
import (
"bufio"
"context"
"fmt"
"goEnv/gocode/RedisExam/chatRoom/client/utils"
"goEnv/gocode/RedisExam/chatRoom/proto"
"io"
"log"
"net"
"os"
"strings"
"sync"
"time"
)
var (
//全局配置
config *utils.Config
//声明一个Redis客户端指针
//心跳间隔时间
heartbeatInterval time.Duration
)
// 在程序启动时初始化Redis客户端连接
func init() {
// 1. 加载 config.ini
var err error
config, err = utils.LoadConfig()
// 加载配置文件
if err != nil {
log.Println("加载配置文件失败:", err)
os.Exit(1)
}
// 将心跳超时和检测间隔转换为 time.Duration(单位:秒)
heartbeatInterval = time.Duration(config.HeartbeatInterval) * time.Second
}
func main() {
//创建上下文和waitGroup用于优雅的关闭goroutine
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
// 拼接服务器地址与端口
serverAddr := config.ServerAddress + ":" + config.ServerPort
conn, err := net.Dial("tcp", serverAddr)
if err != nil {
fmt.Println("net.Dial err:", err)
return
}
defer conn.Close()
// 用户名握手:循环输入直到服务器确认
stdinReader := bufio.NewReader(os.Stdin)
reader := bufio.NewReader(conn)
for {
fmt.Print("请输入你的名字: ")
name, _ := stdinReader.ReadString('\n')
name = strings.TrimSpace(name)
encodedName, err := proto.Encode(name)
if err != nil {
fmt.Println("proto.Encode err:", err)
continue
}
_, err = conn.Write(encodedName)
if err != nil {
fmt.Println("conn.Write err:", err)
return
}
// 等待服务器返回信息
serverResp, err := proto.Decode(reader)
if err != nil {
fmt.Println("等待服务器响应错误:", err)
return
}
// 判断服务器响应内容
if strings.HasPrefix(serverResp, "用户名已存在") {
fmt.Println("服务器提示:", serverResp)
// 继续循环让用户重新输入
continue
} else if strings.HasPrefix(serverResp, "用户名不能为空") {
fmt.Println("服务器提示:", serverResp)
} else if strings.HasPrefix(serverResp, "欢迎") {
// 用户名确认通过
//fmt.Println("服务器提示:", serverResp)
// 显示界面
printInterface()
break
}
}
// 启动一个 goroutine 用于接收服务器消息
wg.Add(1)
go recMag(conn, ctx, &wg)
//启动一个goroutine 用于心跳检测
wg.Add(1)
go heartbeat(conn, ctx, &wg)
// 循环读取用户输入的消息并发送
for {
//fmt.Print("请输入消息:")
input, err := stdinReader.ReadString('\n')
if err != nil {
fmt.Println("stdinReader.ReadString err:", err)
continue
}
input = strings.TrimSpace(input)
//消息不能为空
if input == "" {
fmt.Println("消息不能为空")
continue
}
//当用户输入的消息为exit的时候,退出客户端
if input == "/EXIT" {
encodedMsg, err := proto.Encode(input)
if err != nil {
fmt.Println("proto.Encode err:", err)
} else {
_, err = conn.Write(encodedMsg)
if err != nil {
fmt.Println("conn.Write err:", err)
}
}
fmt.Println("正在安全退出~")
cancel()
wg.Wait()
fmt.Println("退出~")
break
}
encodedMsg, err := proto.Encode(input)
if err != nil {
fmt.Println("proto.Encode err:", err)
continue
}
_, err = conn.Write(encodedMsg)
if err != nil {
fmt.Println("conn.Write err:", err)
continue
}
}
}
// 启动一个 goroutine 用于接收服务器消息
func recMag(conn net.Conn, ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
reader := bufio.NewReader(conn)
for {
select {
case <-ctx.Done():
fmt.Println("接收服务器消息关闭")
return
default:
msg, err := proto.Decode(reader)
if err != nil {
// 如果是 EOF,则可以认为连接已经关闭,直接退出
if err == io.EOF {
return
}
fmt.Println("proto.Decode err:", err)
return
}
fmt.Println("收到消息:", msg)
}
}
}
// 启动一个goroutine 用于心跳检测
func heartbeat(conn net.Conn, ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
//每隔5s返回一个新的Ticker,包含一个管道字段,每隔5s就向该通道发送当时的时间
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("客户端心跳关闭")
return
case <-ticker.C:
heartbeatMsg, err := proto.Encode("/PING")
if err != nil {
fmt.Println("心跳 proto.Encode err:", err)
continue
}
_, err = conn.Write(heartbeatMsg)
if err != nil {
fmt.Println("conn.Write err:", time.Now())
fmt.Println("心跳 conn.Write err:", err)
return
}
}
}
}
// printInterface 在控制台打印漂亮的界面
func printInterface() {
// 清屏(ANSI 转义序列)
fmt.Print("\033[2J\033[H")
// 打印顶部边框
fmt.Println("\033[36m****************************************\033[0m")
// 打印欢迎信息(蓝绿色)
fmt.Println("\033[36m 欢迎进入聊天室 \033[0m")
fmt.Println("\033[36m Chat Room v1.0 \033[0m")
fmt.Println("\033[36m****************************************\033[0m")
fmt.Println("\033[36m*********** HELP **************\033[0m")
fmt.Println("\033[36m******** 1.查看活跃度排行榜 /RANK *******\033[0m")
fmt.Println("\033[36m******** 2.退出聊天室 /EXIT *******\033[0m")
}
这里代码我只是给出了服务端和客户端的部分代码,完整代码,请从github中获取
地址:https://github.com/cxzgit/ChatRoom ,可以的话,帮我点个star,感谢!!!
6万+

被折叠的 条评论
为什么被折叠?



