实践刘丹冰的即时通讯系统

7 阅读28分钟

实践刘丹冰的即时通讯系统

《8小时转职Golang工程师》偏入门级,主要是针对后端想快速低成本掌握Golang开发人群学习,如您已经掌握Golang请绕行。

项目案例-《Golang即时通信系统》.png

V0.1-基础服务构建

本版本聚焦于即时通讯系统的最底层基础 ——TCP 服务端的搭建,核心实现:

  1. 基于 Go 语言的 TCP 服务端创建;
  2. 监听指定 IP 和端口,持续接收客户端连接;
  3. 为每个新连接启动独立协程处理,保证并发连接能力;
  4. 基础的连接成功提示,验证链路连通性。

代码解析

整个项目分为两个核心文件:server.go(服务端核心逻辑)和 main.go,以下逐模块拆解。

main.go
package main

func main() {
	server := NewServer("127.0.0.1", 8888)
	server.Start()
}
  • 调用 NewServer 方法创建服务端实例,指定监听的 IP(127.0.0.1,仅本地访问)和端口(8888);
  • 调用 Start 方法启动服务端,进入监听状态。
服务端核心:server.go
(1)定义 Server 结构体
type Server struct {
	Ip   string
	Port int
}

封装服务端的核心配置:监听的 IP 地址和端口号,让服务端的配置更清晰、可扩展。

(2)创建 Server 实例
//创建一个server的接口
func NewServer(ip string, port int) *Server {
	server := &Server{
		Ip:   ip,
		Port: port,
	}
	return server
}

提供构造函数,标准化 Server 实例的创建流程,外部只需传入 IP 和端口即可,无需关注内部初始化细节。

(3)连接处理逻辑
func (this *Server) Handler(conn net.Conn) {
	//...当前链接的业务
	fmt.Println("链接建立成功")
}

定义每个客户端连接的处理函数:

  • 参数 conn net.Conn 是 Go 网络编程中代表 “客户端与服务端连接” 的核心对象,后续的消息读写都基于此;
  • 本版本仅打印 “链接建立成功”,验证连接链路通,后续可扩展为消息接收、解析、转发等逻辑。
(4)启动服务端
//启动服务器的接口
func (this *Server) Start() {
	//socket listen
	listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", this.Ip, this.Port))
	if err != nil {
		fmt.Println("net.Listen err:", err)
		return
	}
	//close listen socket
	defer listener.Close()

	for {
		//accept
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("listener accept err:", err)
			continue
		}

		//do handler
		go this.Handler(conn)
	}
}

这是服务端启动的核心逻辑,拆解为 4 个关键步骤:

  1. 创建监听套接字:调用 net.Listen("tcp", addr) 创建 TCP 监听器,指定监听的 IP:Port;若创建失败(如端口被占用),打印错误并退出。

  2. 延迟关闭监听器:使用 defer listener.Close() 确保程序退出时关闭监听器,释放系统资源。

  3. 无限循环等待连接for 循环让服务端持续运行,不断接收新的客户端连接。

  4. 接收连接并处理

    • listener.Accept() 会阻塞等待客户端连接,成功则返回 conn(连接对象);
    • 启动协程go this.Handler(conn))处理该连接,避免单个连接阻塞整个服务端(保证并发能力)。

V0.2-广播上线功能

在上一版 V0.1 中,我们已经完成了基础 TCP 服务端搭建,实现了客户端与服务端的连接建立。本文仅聚焦 V0.2 版本的新增功能与核心升级点,不再重复介绍已有基础能力。

V0.2 核心迭代目标:实现用户上线广播—— 当新用户连接时,所有在线用户都能收到实时上线通知,让 IM 系统从「单点连接」升级为「多人感知」。

V0.2 核心更新内容

  1. 新增用户抽象模块(User) :封装客户端连接、用户名,标准化用户对象
  2. 新增在线用户管理:全局并发安全的在线用户列表,支持用户上下线记录
  3. 新增消息广播能力:服务端可向所有在线用户统一推送消息
  4. 上线通知自动化:新用户连接后,自动广播上线信息至全员

项目结构

在原有 main.go + server.go 基础上,新增 user.go 用户模块:

├── main.go    (无改动)
├── server.go  (核心升级:用户管理 + 广播)
└── user.go    (V0.2 新增:用户抽象封装)

代码解析

新增用户模块:user.go

专门封装「用户」核心属性与行为,让用户相关逻辑与服务端解耦,符合模块化设计思想。

(1)定义 User 结构体
type User struct {
	Name string
	Addr string
	C    chan string
	conn net.Conn
}
  • Name:用户名(初始默认值为客户端地址,后续可扩展自定义昵称);
  • Addr:客户端网络地址(IP+Port),标识用户唯一网络身份;
  • C:用户专属消息通道,服务端推送的广播消息会先进入该通道;
  • conn:用户与服务端的 TCP 连接对象,用于向客户端回写消息。
(2)创建 User 实例
//创建一个用户的API
func NewUser(conn net.Conn) *User {
	userAddr := conn.RemoteAddr().String()

	user := &User{
		Name: userAddr,
		Addr: userAddr,
		C:    make(chan string),
		conn: conn,
	}

	//启动监听当前user channel消息的goroutine
	go user.ListenMessage()

	return user
}
  • 从 TCP 连接中提取客户端地址 userAddr,作为用户初始名称和地址;
  • 初始化用户专属消息通道 C,用于接收服务端广播的消息;
  • 启动独立协程执行 ListenMessage 方法,持续监听消息通道,保证消息实时推送;
  • 标准化用户实例创建流程,外部只需传入连接对象即可。
(3)监听用户消息通道
//监听当前User channel的方法,一旦有消息,就直接发送给对端客户端
func (this *User) ListenMessage() {
	for {
		msg := <-this.C

		this.conn.Write([]byte(msg + "\n"))
	}
}
  • 无限循环阻塞读取用户通道 C 中的消息;
  • 一旦收到消息,通过 conn.Write 将消息回写给客户端(拼接换行符提升可读性);
  • 每个用户独立协程监听,保证消息推送的并发安全性,且不阻塞其他用户逻辑。
服务端核心升级:server.go

server.go 在 V0.1 基础上新增「在线用户管理」「消息广播」核心能力,是 V0.2 功能的核心载体。

(1)扩展 Server 结构体
type Server struct {
	Ip   string
	Port int

	//在线用户的列表
	OnlineMap map[string]*User
	mapLock   sync.RWMutex

	//消息广播的channel
	Message chan string
}
  • OnlineMap:存储所有在线用户,key 为用户名,value 为用户实例,实现在线用户快速查找;
  • mapLock:读写互斥锁,保证多协程操作 OnlineMap 时的并发安全(避免读写冲突);
  • Message:全局广播消息通道,所有待广播的消息先进入该通道,再由专门协程分发。
(2)更新 Server 实例创建
//创建一个server的接口
func NewServer(ip string, port int) *Server {
	server := &Server{
		Ip:        ip,
		Port:      port,
		OnlineMap: make(map[string]*User),
		Message:   make(chan string),
	}

	return server
}
  • 初始化 OnlineMap 为空映射,用于存储在线用户;
  • 初始化 Message 通道,为广播消息提供传输载体。
(3)新增广播消息分发协程
//监听Message广播消息channel的goroutine,一旦有消息就发送给全部的在线User
func (this *Server) ListenMessager() {
	for {
		msg := <-this.Message

		//将msg发送给全部的在线User
		this.mapLock.Lock()
		for _, cli := range this.OnlineMap {
			cli.C <- msg
		}
		this.mapLock.Unlock()
	}
}
  • 无限循环阻塞读取全局广播通道 Message 中的消息;
  • 加锁遍历 OnlineMap 所有在线用户,将消息推送到每个用户的专属通道 C
  • 操作 OnlineMap 时加锁 / 解锁,避免并发读写问题;
  • 独立协程运行,不阻塞服务端主逻辑,保证广播能力的实时性。
(4)新增广播方法封装
//广播消息的方法
func (this *Server) BroadCast(user *User, msg string) {
	sendMsg := "[" + user.Addr + "]" + user.Name + ":" + msg

	this.Message <- sendMsg
}
  • 封装广播消息格式:[客户端地址]用户名:消息内容,让通知更易读;
  • 将格式化后的消息写入全局广播通道 Message,交由 ListenMessager 协程分发。
(5)升级连接处理逻辑
func (this *Server) Handler(conn net.Conn) {
	//...当前链接的业务
	//fmt.Println("链接建立成功")

	user := NewUser(conn)

	//用户上线,将用户加入到onlineMap中
	this.mapLock.Lock()
	this.OnlineMap[user.Name] = user
	this.mapLock.Unlock()

	//广播当前用户上线消息
	this.BroadCast(user, "已上线")

	//当前handler阻塞
	select {}
}
  • 创建新用户实例:基于客户端连接初始化用户对象;
  • 加锁将新用户加入 OnlineMap,完成「上线注册」;
  • 调用 BroadCast 方法,向所有在线用户推送该用户的上线通知;
  • select {} 让协程永久阻塞(避免连接处理协程退出),保证用户连接持续有效。
(6)升级服务端启动逻辑
//启动服务器的接口
func (this *Server) Start() {
	//socket listen
	listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", this.Ip, this.Port))
	if err != nil {
		fmt.Println("net.Listen err:", err)
		return
	}
	//close listen socket
	defer listener.Close()

	//启动监听Message的goroutine
	go this.ListenMessager()

	for {
		//accept
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("listener accept err:", err)
			continue
		}

		//do handler
		go this.Handler(conn)
	}
}
  • 新增 go this.ListenMessager():服务端启动时,立即启动广播消息分发协程,提前准备好消息广播能力;
  • 其余逻辑与 V0.1 一致,保证基础连接能力的同时,新增广播协程的启动。

V0.2 核心逻辑链路总结

  1. 服务端启动时,启动 ListenMessager 协程,监听全局广播通道;
  2. 客户端连接服务端,触发 Handler 协程;
  3. 基于连接创建 User 实例,启动 ListenMessage 协程监听用户专属通道;
  4. 将新用户加入 OnlineMap(加锁保证并发安全);
  5. 调用 BroadCast 方法,将「上线通知」写入全局广播通道;
  6. ListenMessager 协程读取到广播消息,遍历 OnlineMap 将消息推送给所有在线用户的专属通道;
  7. 每个用户的 ListenMessage 协程读取到消息,通过 TCP 连接回写给客户端,完成「上线通知广播」。

V0.3-用户消息广播

在 V0.2 中,我们完成了新用户上线时的全网广播功能。本版本将更进一步,实现任意用户群发消息的能力 —— 当任何一个在线用户发送消息时,服务端会将该消息广播给当前所有在线的其他用户,这是构建群聊系统的核心基石。

V0.3 核心更新内容

  1. 改造 User 构造接口:将 Server 对象注入 User 实例,以便在用户层调用服务端的广播方法。
  2. 抽取用户独立生命周期方法:将用户上线(Online)、下线(Offline)和处理消息(DoMessage)提取为 User 的专属方法。
  3. 实现客户端消息接收与分发:在服务端建立读取客户端消息的协程,接收消息后直接交给用户自己的处理逻辑去全网广播。

代码解析

完善用户功能模块:user.go

将原本在 server.go 中相对分散的用户交互逻辑,统一收敛并封装在 User 结构体内,使其拥有更完整的能力和独立的生命周期。

(1)扩展 User 结构体与构造函数
type User struct {
 Name string
 Addr string
 C    chan string
 conn net.Conn

 server *Server // 新增:保存当前 User 所属的 Server 实例
}

// 创建一个用户的API
func NewUser(conn net.Conn, server *Server) *User {
 userAddr := conn.RemoteAddr().String()

 user := &User{
  Name:   userAddr,
  Addr:   userAddr,
  C:      make(chan string),
  conn:   conn,
  server: server, // 初始化赋值
 }

 //启动监听当前user channel消息的goroutine
 go user.ListenMessage()

 return user
}
  • 新增 server 字段:持有服务端的指针引用,使得代表用户的 User 对象能够主动调用 Server 层面提供的方法(如广播方法、操作在线用户字典等)。
(2)封装用户的上线与下线业务
// 用户的上线业务
func (this *User) Online() {
 //用户上线,将用户加入到onlineMap中
 this.server.mapLock.Lock()
 this.server.OnlineMap[this.Name] = this
 this.server.mapLock.Unlock()

 //广播当前用户上线消息
 this.server.BroadCast(this, "已上线")
}

// 用户的下线业务
func (this *User) Offline() {
 //用户下线,将用户从onlineMap中删除
 this.server.mapLock.Lock()
 delete(this.server.OnlineMap, this.Name)
 this.server.mapLock.Unlock()

 //广播当前用户下线消息
 this.server.BroadCast(this, "下线")
}
  • 将原来挤在 Handler 里控制用户上下网、修改 OnlineMap 的代码提纯到了 User 的专属方法里;
  • 这个封装有效增强了后续逻辑的可读性。
(3)封装用户处理消息的业务
// 用户处理消息的业务
func (this *User) DoMessage(msg string) {
 this.server.BroadCast(this, msg)
}
  • 简单明了:此版本中,用户产生的任何消息都直接调用所属 Server 服务的 BroadCast 方法全网广播。
服务端连接处理升级:server.go

核心变更是重构 Handler 方法,使其能持续倾听并处理客户端发来的实际消息内容。

处理用户消息
func (s *Server) ManagerMessage(user *User) {
	buf := make([]byte, 4096)
	for {
		n, err := user.conn.Read(buf)
		if n == 0 {
			user.UserOffline()
			return
		}
		if err != nil && err != io.EOF {
			fmt.Println("conn.Read err:", err)
			return
		}
		msg := fmt.Sprintf("[%s]:%s", user.Addr, string(buf[:n-1]))
		s.BroadCast(user, msg)
	}
}
  • 开启一个新的 goroutine,内部是一个死循环长驻,持续读取当前连接发过来的字节流:conn.Read(buf)
  • 判断读取的字节数,如果不为 0 则提取出真正的字串内容并截掉末尾潜在的换行符进行规整;
  • 将获取到的洁净消息传递至上一小节封装的 user.DoMessage(msg) 处理逻辑中,实现真正的消息广播功能。
持续接收并处理客户端消息
func (this *Server) Handler(conn net.Conn) {

 user := NewUser(conn, this) // 将当前 server 传递给新建的 User 
 user.Online()

 go s.ManagerMessage(user)

 //当前handler阻塞
 select {}
}

V0.3 核心逻辑链路总结

  1. 客户端连接,触发服务端 Handler 协程;
  2. 基于当前 connserver 引用,实例化出 User 对象;
  3. 调用 User.Online(),完成入表注册并基于 server.BroadCast 群发该用户的上线通知;
  4. Handler 启动一条专属子协程 go func() 彻底陷入对于 conn.Read() 的阻塞倾听等待;
  5. 一旦有该用户发出的数据流到来,将其转码为字符串并去除换行符,构建出干净的 msg
  6. 接着业务传递至 User.DoMessage(msg),内部再次调用 server.BroadCast 将消息放入公共信道;
  7. 全局的 ListenMessager 将会把公共信道内的这条文本顺次派发给当前字典里所有的所有在线用户,实现群聊群发。

V0.4-用户业务封装

(注意:此版本代码层级上的变化实质上是在 V0.3 的基础上进行的抽象理念确认阶段,在目录结构上提取和巩固了相关结构的概念,实际应用逻辑并未出现重大重构差异,主要作为代码规范及后续更深入业务开发的过渡性标示版本。)

V0.4 的目标是理清和确立将复杂的网络和逻辑封装入具体的对象与类中。

V0.5-在线用户查询

在初步搭建起消息广播大本营后,此时客户端迫切需要知道有哪些用户在线。在 V0.5 版本,我们正式确立指令交互机制:如果用户输入特殊关键字的指令(指令解析),服务端将执行相应的系统功能,而非简单的群发广播。

V0.5 核心迭代目标:实现在线用户查询(who命令)

V0.5 核心更新内容

  1. 增加定向私信用户方法:提供直接向该用户对应的底层连接发数据的方法;
  2. 重构消息路由:在用户的 DoMessage 内进行简单的命令解析(如果消息为 "who",则触发特殊查询;否则走默认普通广播)。

代码解析

所有关于指令查询和反馈修改的逻辑均直接体现在用户对象所属逻辑内。

扩展定向发送能力:user.go

首先需要给 User 补充一个“只能自己看到”私下投递消息的方法,而不是动辄去广播。

// 给当前User对应的客户端发送消息
func (this *User) SendMsg(msg string) {
 this.conn.Write([]byte(msg))
}
  • 对底层的 conn.Write 做了最直接的二次封装。
强化消息处理路由:user.go -> DoMessage()

不再不管三七二十一地直接把消息通过 server.BroadCast() 当大喇叭广播出去。加入对 msg 本身的判断。

// 用户处理消息的业务
func (this *User) DoMessage(msg string) {
 if msg == "who" {
  //查询当前在线用户都有哪些

  this.server.mapLock.Lock()
  for _, user := range this.server.OnlineMap {
   onlineMsg := "[" + user.Addr + "]" + user.Name + ":" + "在线...\n"
   this.SendMsg(onlineMsg)
  }
  this.server.mapLock.Unlock()

 } else {
  // 默认依然是当作普通群聊喇叭
  this.server.BroadCast(this, msg)
 }
}
  • 识别用户发送的文本中是否恰好为 "who" 这个指令。
  • 当满足条件时:锁定服务器在线用户字典表 server.OnlineMap
  • 对字段里的每一位在线客户,拼接一条类似 "[127.0.0.1:8888]某某:在线..." 这样的一览提示,连续利用我们刚才新增的私有投递能力 this.SendMsg() 顺次发给这位查询者本身。

V0.5 核心逻辑链路总结

  1. 某客户端发送了一串文本至服务端,触发该用户关联的读取协程;
  2. 文本进入封装的业务网关 User.DoMessage(msg) 进行路由判断;
  3. 如果发出的字符串是 "who",则进入内部命令拦截分支;
  4. 网关对当前全局 server.OnlineMap 遍历,每抓出一条其他用户的名字,就构建一条结果反馈;
  5. 通过 User.SendMsg(msg) 定向使用底层 conn.Write 投递回原本发起查询的那一位请求者的屏幕上;
  6. 实现了指令拦截而不向其他所有无关全站用户发生群发“打扰”。

V0.6-修改用户名

既然在 V0.5 我们引入了特殊命令的处理能力,那么目前仅仅显示 IP 端口为用户名的默认状态显然难以支撑复杂的社交交互设计。

V0.6 版本加入了重命名(rename)系统指令,让用户通过特定格式命令动态修改自己展现给别人的昵称。

V0.6 核心更新内容

  1. 协议解析扩展:扩展判断 rename|新名字 的指令格式。
  2. 并发安全更新用户名:确保用户在字典里新旧名字的交替更新且确保多协程下的线程安全。
  3. 查重机制:修改名字时,系统可以预判重名现象并予以驳回。

代码解析

主要变更继续发生在路由的 user.go DoMessage 命令网关当中,同时需要引入 strings 表处理字符串切割。

添加 rename 分支支持:user.go

增加一个新的分支语句对字符串头部进行匹配截取。

import (
 "net"
 "strings" // 引入 string 用于字符串解析操作
)

func (this *User) DoMessage(msg string) {
 if msg == "who" {
  // ...此前已编写查询逻辑
 } else if len(msg) > 7 && msg[:7] == "rename|" {
  // 消息格式: rename|张三
  newName := strings.Split(msg, "|")[1]

  //判断name是否存在
  _, ok := this.server.OnlineMap[newName]
  if ok {
   this.SendMsg("当前用户名被使用\n")
  } else {
   // 在修改名字时牵涉全局字典的变更,一定要加所操作
   this.server.mapLock.Lock()
   delete(this.server.OnlineMap, this.Name)
   this.server.OnlineMap[newName] = this
   this.server.mapLock.Unlock()

   this.Name = newName
   this.SendMsg("您已经更新用户名:" + this.Name + "\n")
  }

 } else {
  // 普通广播模式
  this.server.BroadCast(this, msg)
 }
}
  • 这里设计了简单的按管道符(|)区分命令头跟指令参数的文本协议:"rename|XXX"
  • 长度检查 len(msg) > 7 保证执行字符串切除时不会发生索引越界的 panic
  • 修改名称最重要的一点:不能只是更改 this.Name,必须要去更新 Server.OnlineMap 中对应的字典键! 因此执行了 delete 去掉当前实例的老名字代表的 Key 记录,重新映射一次新名字,且全过程要 mapLock 上锁加以保护。

V0.6 核心逻辑链路总结

  1. 用户发动请求内容被底层协程捕获传入 User.DoMessage(msg)
  2. 引擎对 msg 进行协议前缀比对,若命中 rename| 前缀且长短合法,则进入改名模式分支;
  3. strings.Split 通过管道符进行切割,提取到数组下标 [1] 的新名字串;
  4. OnlineMap 执行查询,排查是否已有其他用户使用了这个名字(防重名机制);
  5. 名字若是合法,加锁清理掉字典中原 this.Name 键占用的条目;
  6. 使用新的名字作为键挂载当前的 User(即 this)实例,完成系统注册表更新;
  7. 最后同步变更内存中当前对象的 this.Name = newName 字段,并利用 SendMsg 发送私有变更成功的确认回执。

V0.7-超时强踢功能

任何常驻连线的网络游戏或服务端中,必须针对因宕机网络挂起变成幽灵节点,抑或长期不操作影响服务端资源的用户连接做清理。这被俗称为“超时限制强踢”。

V0.7 核心迭代目标是借助 selecttime.After 相结合带来的优雅超时处理范式管理 TCP 协程的退出。

V0.7 核心更新内容

  1. 加入存活监测(Keep-Alive)通知信道:用作标识客户端活跃的状态触点。
  2. 利用多路复用计时:通过死循环 select 控制处理客户端链接状态的核心协程。

代码解析

改变发生在开启网络服务的起点 server.go:每次为刚连接的新客户端派生出来的请求 Handler

修改 handler 监控阻塞块:server.go

这里去掉了原先简单的无意义的 select {} 堵塞,替换为了兼并心跳功能的轮询。

import (
 // ... 其他
 "time" 
)

func (this *Server) Handler(conn net.Conn) {
 user := NewUser(conn, this)
 user.Online()

 // 监听用户是否活跃的channel
 isLive := make(chan bool)

 // 接受客户端发送的消息(保持死循环抓取并派发业务)
 go func() {
  buf := make([]byte, 4096)
  for {
   n, err := conn.Read(buf)
   // ... 业务报错截断与EOF跳出与V0.6保持一致

   msg := string(buf[:n-1])
   user.DoMessage(msg)

   // **新增**:用户的任意消息,代表当前用户是一个活跃的,触碰其存活标志位
   isLive <- true
  }
 }()

 // 更换曾经的 'select {}' 
 for {
  select {
  case <-isLive:
   // 当前用户是活跃的,一旦执行到该case,当前 select 会被激活而跳出
   // 使得代码有机会走向下一轮 for 并再重置一次以下的定时器
   // 即:只要他说话,我们就不会让他被强制踢下线。

  case <-time.After(time.Second * 300):
   // 已经超时 (原来 10 秒供于测试,最终可能被设为真实 300 秒)
   // 将当前的User强制的关闭

   user.SendMsg("你被踢了")

   // 销毁用户的资源通道等
   close(user.C)
   // 关闭底层连接
   conn.Close()

   // 退出当前Handler协程彻底清理垃圾碎片
   return 
  }
 }
}
  • time.After(时长) 底层实质会传回一个堵塞并在到期时解堵塞的通信 Channel 节点;
  • 核心思维:由于这部分代码处于一个外部完整的 for {} 当中,每次外层运行新一轮就会顺次刷出一个全新的且尚未倒数完成的 time.After 并在该处停下看是哪边被满足。如果在超时没到达前内部 read 协程传来了激活信号 case <-isLive,定时器就会被强行洗牌不再有效,也就完美达成了“发消息就增加时间”的业务需求。

V0.7 核心逻辑链路总结

  1. 用户连接开启,随之申请名为 isLive 的同步通道,用于监控用户活跃度;
  2. 在死循环轮询中,主 Handler 协程遇到了双向 select 进行多路复用等待;
  3. 当用户通过另一条读取报文的协程发动任何消息时,业务不仅会被处理,还会往 isLive 推入一枚激活信号;
  4. select 捕捉到 <-isLive 的通行动作,打断堵塞状态,使得代码回到上层 for 并重新生成一个 <-time.After(300秒) 重新计时;
  5. 反之,如果长达 300 秒内读报文协程毫无建树(没有激活任何 isLive),select 的下方条件 time.After 自然通车;
  6. 执行强制离线操作:发送临终遣散语、关闭管道资源、撕裂 conn 底层网络链接,最终摧毁回收整个承载这名用户的 Handler 协程栈。

V0.8-私聊功能

在先前的迭代中,我们实现了可以看在线人数、更改自己显式的昵称的能力,那么自然少不了指定用户定向发信的“悄悄话”功能。

V0.8 借助前期已打造的协议雏形和强大的对象拆分基础,极其轻量快速的拓展了私聊实现。

V0.8 核心更新内容

  1. 引入第三级长文本识别模式:命令头格式 to|目标好友呢称|真正的消息正文内容
  2. 引入简单的容错预检测:空消息防范,以及对方用户可能查无此人的校验。

代码解析

一切又回归到业务集中处理的控制方法内。

追加 to 解析分支:user.go

为原先的 user.go DoMessageif-else if 链路当中新加设一层对于 to| 标识符的筛选分发支持:

func (this *User) DoMessage(msg string) {
 if msg == "who" {
  // ...
 } else if len(msg) > 7 && msg[:7] == "rename|" {
  // ...
 } else if len(msg) > 4 && msg[:3] == "to|" {
  // 消息格式:  to|张三|消息内容

  // 1 获取对方的用户名
  remoteName := strings.Split(msg, "|")[1]
  if remoteName == "" {
   this.SendMsg("消息格式不正确,请使用 \"to|张三|你好啊\" 格式。\n")
   return
  }

  // 2 根据用户名 得到对方User对象
  remoteUser, ok := this.server.OnlineMap[remoteName]
  if !ok {
   this.SendMsg("该用户名不不存在\n")
   return
  }

  // 3 获取消息内容,通过对方的User对象将消息内容发送过去
  content := strings.Split(msg, "|")[2]
  if content == "" {
   this.SendMsg("无消息内容,请重发\n")
   return
  }
  
  // 最终把组合拼接的话发送向对端
  remoteUser.SendMsg(this.Name + "对您说:" + content)
 } else {
  this.server.BroadCast(this, msg)
 }
}
  • 相对于之前简单的取段 strings.Split(msg, "|")[1],因为私聊还涉及更结尾处的数据正文故需要取其 [2] 甚至往后的部分。
  • 由于所有的 User 引用内存都存活在服务端的 this.server.OnlineMap 中,利用哈希字典查找即能非常方便的拿出那个要对接通信的对端结构体对象。并直接利用我们在 V0.5 就已备好的私信发送方法 SendMsg()
  • 这极大地体现出前期把“消息下发功能”从 server 转移下放到 User 身上的架构合理性带来的设计甜头。

V0.8 核心逻辑链路总结

  1. A 用户给服务端发来类似 "to|B|你好啊" 这段被私聊协议包装的字串;
  2. 服务端在 DoMessage(msg) 函数内完成前缀拦截,并依靠 strings.Split() 切分得到三段数组;
  3. 将提取出的目标受众 "B" 作为 Key 键值抛向中央服务中心 server.OnlineMap["B"] 中检索其所代表的对端使用者对象实例;
  4. 若未查实则向 A 用户端报错反悔;
  5. 若匹配到对应的存活结构体(代表 B 还在线),提取出文本 "你好啊"
  6. 动用 B 实例指针下的 remoteUser.SendMsg(),将组合好的最终句子通过 B 所专属的 net.Conn 单点信道进行物理推送。

V0.9-独立客户端实现

在以往所有的服务端版本测试中,我们一直依赖 netcat (nc) 等第三方工具来充当客户端发起连接。本版本(最终整合版 ServerV)中,我们补齐了生态的最后一块拼图:用 Go 语言原生实现一个独立且完整的交互式客户端程序

通过客户端程序的封装,我们将之前的“公聊”、“私聊”、“查在线”以及“修改用户名”等零散的通信协议,全部通过良好的终端菜单交互组合了起来。

V0.9 核心更新内容

  1. 构建独立的 Client 客户端:通过 net.Dial 主动拨号连接服务端;
  2. 结合 flag 包解析启动参数:支持通过 client -ip <IP> -port <Port> 的形式灵活启动;
  3. 全异步接收服务端消息:开启独立协程对接标准输出 os.Stdout,实现无感知的长连接监听;
  4. 封装终端菜单UI与各大业务模块:彻底终结手敲特殊指令(如 to|rename|)的时代。

代码解析

这是一个独立运行的程序,核心围绕新增的 client.go 展开:

客户端基石抽象与连接:client.go
(1)定义 Client 结构体与初始化
type Client struct {
 ServerIp   string
 ServerPort int
 Name       string
 conn       net.Conn
 flag       int // 当前client的模式
}

func NewClient(serverIp string, serverPort int) *Client {
 //创建客户端对象
 client := &Client{
  ServerIp:   serverIp,
  ServerPort: serverPort,
  flag:       999,
 }

 //链接server
 conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", serverIp, serverPort))
 if err != nil {
  fmt.Println("net.Dial error:", err)
  return nil
 }

 client.conn = conn

 //返回对象
 return client
}
  • Client 结构体保存了服务端的 IP 和端口,以及代表当前连接的 net.Conn
  • 核心底层变更为 net.Dial("tcp", ...)。作为主动发起连接的一方,客户端创建成功即代表 TCP 握手完成。
(2)命令行参数支持 (flag)
var serverIp string
var serverPort int

// ./client -ip 127.0.0.1 -port 8888
func init() {
 flag.StringVar(&serverIp, "ip", "127.0.0.1", "设置服务器IP地址(默认是127.0.0.1)")
 flag.IntVar(&serverPort, "port", 8888, "设置服务器端口(默认是8888)")
}

func main() {
 //命令行解析
 flag.Parse()

 client := NewClient(serverIp, serverPort)
 if client == nil {
  fmt.Println(">>>>> 链接服务器失败...")
  return
 }
 // ... 启动业务协程
}
  • 利用 init() 初始化函数结合 Go 标准库 flag,允许开发者在启动二进制文件时从终端传递连接参数。
(3)异步监听服务端响应:超轻量级转发
// 处理server回应的消息,直接显示到标准输出即可
func (client *Client) DealResponse() {
 //一旦client.conn有数据,就直接copy到stdout标准输出上, 永久阻塞监听
 io.Copy(os.Stdout, client.conn)
}
  • main() 函数中通过 go client.DealResponse() 单独开启一个协程。
  • 这里利用了非常巧妙的 io.Copy():因为 conn 本身实现了 Reader 接口,而 os.Stdout(终端输出)实现了 Writer 接口,此代码会永远把服务器发来的字轨死循环原样倾倒在控制台屏幕上,代码极其精简。
(4)终端UI主循环与菜单交互

以一个可视化的文字菜单封装并接管原来的全黑屏输入。

func (client *Client) menu() bool {
 var flag int

 fmt.Println("1.公聊模式")
 fmt.Println("2.私聊模式")
 fmt.Println("3.更新用户名")
 fmt.Println("0.退出")

 fmt.Scanln(&flag)

 if flag >= 0 && flag <= 3 {
  client.flag = flag
  return true
 } else {
  fmt.Println(">>>>请输入合法范围内的数字<<<<")
  return false
 }
}

func (client *Client) Run() {
 for client.flag != 0 {
  for client.menu() != true {
  }

  //根据不同的模式处理不同的业务
  switch client.flag {
  case 1:
   //公聊模式
   client.PublicChat()
   break
  case 2:
   //私聊模式
   client.PrivateChat()
   break
  case 3:
   //更新用户名
   client.UpdateName()
   break
  }
 }
}
  • 采用 fmt.Scanln 读取用户在终端打入的选项标号;
  • Run() 是死循环的核心业务流,除非用户选择 0.退出 跳出,否则不停循环展现菜单并根据 switch 派发任务。
(5)各种业务逻辑的具体封装

我们将原先需要用户手动输入的特定协议格式,全部封装为了向导式的终端函数,这里完整介绍各个核心业务的设计:

修改用户名功能 (UpdateName):

func (client *Client) UpdateName() bool {
 fmt.Println(">>>>请输入用户名:")
 fmt.Scanln(&client.Name)

 sendMsg := "rename|" + client.Name + "\n"
 _, err := client.conn.Write([]byte(sendMsg))
 if err != nil {
  fmt.Println("conn.Write err:", err)
  return false
 }
 return true
}
  • 将原来略显极客的 rename|张三 交互封装为了正常的 [提示] -> [输入数据] -> [程序后台拼接协议] 的常见模式。

公聊模式 (PublicChat):

func (client *Client) PublicChat() {
 var chatMsg string
 fmt.Println(">>>>请输入聊天内容,exit退出.")
 fmt.Scanln(&chatMsg)

 for chatMsg != "exit" {
  if len(chatMsg) != 0 {
   sendMsg := chatMsg + "\n"
   _, err := client.conn.Write([]byte(sendMsg))
   // ... 错误检查 ...
  }
  chatMsg = ""
  fmt.Println(">>>>请输入聊天内容,exit退出.")
  fmt.Scanln(&chatMsg)
 }
}
  • 维护了一个死循环,只要用户没有输入 exit 退出当前模式,就会不断读取控制台终端的数据,打上统一的 \n 作为一条公聊消息直接发走。

私聊及查询在线用户功能 (PrivateChat & SelectUsers):

这是最复杂的私聊连招机制(查询->输入人名->循环互发)

// 查询在线用户
func (client *Client) SelectUsers() {
 sendMsg := "who\n"
 _, err := client.conn.Write([]byte(sendMsg))
 // ... 错误检查 ...
}

// 私聊模式
func (client *Client) PrivateChat() {
 var remoteName string
 var chatMsg string

 client.SelectUsers() // 1. 先主动发送who命令查一下当前谁在线
 fmt.Println(">>>>请输入聊天对象[用户名], exit退出:")
 fmt.Scanln(&remoteName)

 for remoteName != "exit" {
  fmt.Println(">>>>请输入消息内容, exit退出:")
  fmt.Scanln(&chatMsg)

  for chatMsg != "exit" {
   if len(chatMsg) != 0 {
    // 封装成符合服务端 v0.8 要求的协议: "to|张三|你好啊\n"
    sendMsg := "to|" + remoteName + "|" + chatMsg + "\n\n"
    _, err := client.conn.Write([]byte(sendMsg))
                //... 
   }
   chatMsg = ""
   fmt.Println(">>>>请输入消息内容, exit退出:")
   fmt.Scanln(&chatMsg)
  }

  // 换人聊天
  client.SelectUsers()
  fmt.Println(">>>>请输入聊天对象[用户名], exit退出:")
  fmt.Scanln(&remoteName)
 }
}
  • 可以看到,客户端内部不仅维护了嵌套对话状态机(换人 -> 聊天 -> 退出继续换人),还在底层完成了 to|xxx|text 此类协议长串的拼接与发送。
  • 这样一来,对于真实的 C 端使用者而言,完全感受不到任何“管道符分离协议”的学习成本,他只用跟系统文字对话即可(请输入对象... 请输入内容...)。

核心逻辑链路总结

在最终实现的 C/S 架构完整版中,整个系统的核心运转链路可以总结为以下几个并行/串行的闭环:

  1. 连接建立链路

    • 客户端通过 net.Dial 主动发起 TCP 拨号。
    • 服务端被动监听,通过 Accept() 捕获连接,为该实体分配 User 对象并加入在线广播表。
  2. 异步读写链路 (双向奔赴)

    • 服务端视角:为每个上线的 User 单独开启写协程 (监听 User 绑定的 channel 并发给客户端) 和读协程 (阻塞轮询该客户端网络流 conn.Read);
    • 客户端视角:通过 go client.DealResponse() 单独开启一个后台常驻协程,使用 io.Copy(os.Stdout, client.conn) 永久阻塞监听服务端发来的数据,并直接路由输出到显示器终端。
  3. 业务封包与拆包链路

    • 客户端请求打包:终端 UI 获取到用户输入(例如选择想私聊的用户与内容),在客户端程序中组装成系统约定的协议格式(形如 to|张三|你好\n),随后通过 conn.Write 发给服务端。
    • 服务端协议拆包:服务端的 user.DoMessage() 处理提取到的字符串,利用 strings.Splitstrings.HasPrefix 等手段解析特定命令头(如 who, rename|, to|),抽离出业务意图与参数,进而执行在线查找、遍历转发等逻辑。
  4. 终端状态机流转 (UI轮询机制)

    • 客户端主要依靠 Run() -> menu() 构成了视觉主循环。最外层死循环维持程序存活,内嵌子循环负责维持当前的业务状态(公聊态 / 与具体某人的私聊态)。
    • 依赖输入校验和 exit 等指令语进行死循环的逐级跳出与状态栈回退,将原来复杂的协议通信全部隐藏成了可视化的菜单推挽交互。