基于go语言实现的聊天室

项目标题

聊天室

项目描述

聊天室项目是使用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,感谢!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值