前言
参考链接:
37-项目案例-即时通信系统-课程介绍_哔哩哔哩_bilibili
《8小时转职Golang工程师》超时强踢功能的BUG修复 - Blog of Code
收获
代码:go_study/base/IM-System at main · BlueCestbon/go_study · GitHub
语雀笔记:https://www.yuque.com/xw76/back/ftwkp7y1c1x77rf0
Go语言学习中,第一个系统级的 Demo,如同刘丹冰老师所说,串联起来了基础的 Go 知识。
- 如何搭建这个通信系统?得有支持连接的 server 吧,用户上线得有注册和提醒吧,用户能发送消息(私聊、公聊),用户能改自己用户名,用户超时强踢。
- net包下的属性/方法,net.Listen(), net.Conn, net.Conn.Write()/Read()/Close(), net.Dial()
- 强化了基础语法,对象的接口实现、channel 的用法
- 超时强踢导致 CPU 占用率高,读取了关闭的 channel 得到对应的零值
1. 基础server搭建
一个 server 能实现启动一个进程,监听一个 port ,接收到 tcp 请求之后能响应
1.1. 代码
package main
import (
"fmt"
"net"
)
type Server struct {
Ip string
Port int
}
func NewServer(ip string, port int) *Server {
return &Server{
Ip: ip,
Port: port,
}
}
func (server *Server) Handler(conn net.Conn) {
fmt.Println("成功建立连接")
}
// Start 启动服务器的接口
func (server *Server) Start() {
// socket listen
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", server.Ip, server.Port))
if err != nil {
fmt.Println("net.Listen err: ", err)
return
}
// close
defer listener.Close()
for {
// accept
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener conn err: ", err)
continue
}
// handle
go server.Handler(conn)
}
}
package main
func main() {
// 因为我需要在wsl里访问win的机器,写的地址就不能是127.0.0.1
server := NewServer("0.0.0.0", 8888)
server.Start()
}
1.2. 结果展示

2. 用户上线及广播功能

客户端(得有 user 类)上线之后,先去 server 记录一下(onlineMap 和 channel),server 一直监听自己的 channel,发现自己的 channel 有东西了,就遍历当前的 onlineMap,给其他 user 发消息(user 也要有 channel),user 监听自己的 channel,发现有东西了,就写到客户端那。
2.1. 代码
package main
import (
"fmt"
"net"
"sync"
)
type Server struct {
Ip string
Port int
// 在线的用户
OnlineMap map[string]*User
mapLock sync.RWMutex
// server监控channel,有信息就触发广播
ServerChannel chan string
}
func NewServer(ip string, port int) *Server {
return &Server{
Ip: ip,
Port: port,
OnlineMap: make(map[string]*User),
ServerChannel: make(chan string),
}
}
// BroadCast 广播消息
func (server *Server) BroadCast(user *User, msg string) {
sendMsg := "[" + user.Addr + "]" + user.Name + ":" + msg
server.ServerChannel <- sendMsg
}
// Handler 处理连接上线之后的操作
func (server *Server) Handler(conn net.Conn) {
// fmt.Println("成功建立连接")
// 用户上线,添加到OnlineMap
user := NewUser(conn)
server.mapLock.Lock()
server.OnlineMap[user.Name] = user
server.mapLock.Unlock()
// 广播当前用户上线消息
go server.BroadCast(user, "已上线")
// 阻塞当前的handler
select {}
}
// ListenServerChannel 监听server的广播信道,一旦有消息,就发送给全部在线的user
func (server *Server) ListenServerChannel() {
for {
sendMsg := <-server.ServerChannel
server.mapLock.Lock()
for _, user := range server.OnlineMap {
user.C <- sendMsg
}
server.mapLock.Unlock()
}
}
// Start 启动服务器的接口
func (server *Server) Start() {
// socket listen
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", server.Ip, server.Port))
if err != nil {
fmt.Println("net.Listen err: ", err)
return
}
// 监听sever的广播信道
go server.ListenServerChannel()
// close
defer listener.Close()
for {
// accept
fmt.Printf("start listen %s:%d\n", server.Ip, server.Port)
conn, err := listener.Accept()
if err != nil {
fmt.Println("listener conn err: ", err)
continue
}
// handle
go server.Handler(conn)
}
}
package main
import "net"
type User struct {
Name string
Addr string
C chan string
Conn net.Conn
}
// NewUser 创建用户的API
func NewUser(conn net.Conn) *User {
addr := conn.RemoteAddr().String()
user := &User{
Name: addr,
Addr: addr,
C: make(chan string),
Conn: conn,
}
// 初始化之后,就监听自己的这个channel,有消息就输出到客户端
go user.ListenMessage()
return user
}
func (user *User) ListenMessage() {
for {
msg := <-user.C
user.Conn.Write([]byte(msg + "\n"))
}
}
2.2. 结果展示

3. 用户消息广播功能
目前客户端只能被动接收消息,而不能主动发送消息,需要实现接收客户端的输入,再向其他用户广播
3.1. 代码
func (server *Server) Handler(conn net.Conn) {
// fmt.Println("成功建立连接")
// 用户上线,添加到OnlineMap
user := NewUser(conn)
server.mapLock.Lock()
server.OnlineMap[user.Name] = user
server.mapLock.Unlock()
// 广播当前用户上线消息
go server.BroadCast(user, "已上线")
// 接收客户端消息
go func() {
buf := make([]byte, 4096)
for {
// 如果读到的数据是0,就说明是正常关闭的
if n == 0 {
server.BroadCast(user, "下线")
return
}
// 以EOF结尾的
if err != nil && err != io.EOF {
fmt.Println("Conn Read err:", err)
return
}
// 去除用户信息的’\n‘
msg := string(buf[:n-1])
// 将这个消息进行广播
server.BroadCast(user, msg)
}
}()
// 阻塞当前的handler
select {}
}
3.2. 结果展示

4. 用户业务封装
目前server里写了具体的user如何上线、下线、发消息,太耦合,应该移动到user代码里
4.1. 代码
// Handler 处理连接上线之后的操作
func (server *Server) Handler(conn net.Conn) {
// fmt.Println("成功建立连接")
// 用户上线,添加到OnlineMap
user := NewUser(conn, server)
user.Online()
// 接收客户端消息
go func() {
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
// 如果读到的数据是0,就说明是正常关闭的
if n == 0 {
user.Offline()
return
}
// 以EOF结尾的
if err != nil && err != io.EOF {
fmt.Println("Conn Read err:", err)
return
}
// 去除用户信息的’\n‘
msg := string(buf[:n-1])
// 用户处理这个消息
user.SendMsg(msg)
}
}()
// 阻塞当前的handler
select {}
}
package main
import "net"
type User struct {
Name string
Addr string
C chan string
Conn net.Conn
Server *Server
}
// NewUser 创建用户的API
func NewUser(conn net.Conn, server *Server) *User {
addr := conn.RemoteAddr().String()
user := &User{
Name: addr,
Addr: addr,
C: make(chan string),
Conn: conn,
Server: server,
}
// 初始化之后,就监听自己的这个channel,有消息就输出到客户端
go user.ListenMessage()
return user
}
func (user *User) ListenMessage() {
for {
msg := <-user.C
user.Conn.Write([]byte(msg + "\n"))
}
}
// Online 上线
func (user *User) Online() {
user.Server.mapLock.Lock()
user.Server.OnlineMap[user.Name] = user
user.Server.mapLock.Unlock()
// 广播当前用户上线消息
go user.Server.BroadCast(user, "已上线")
}
// Offline 下线
func (user *User) Offline() {
user.Server.mapLock.Lock()
delete(user.Server.OnlineMap, user.Name)
user.Server.mapLock.Unlock()
// 广播当前用户下线消息
go user.Server.BroadCast(user, "下线")
}
// SendMsg 处理消息
func (user *User) SendMsg(msg string) {
go user.Server.BroadCast(user, msg)
}
5. 在线用户查询
当前用户查询有哪些用户在线,告知当前的客户端
5.1. 代码
// DoMsg 处理消息
func (user *User) DoMsg(msg string) {
if msg == "who" {
for _, onlineUser := range user.Server.OnlineMap {
msg = "[" + onlineUser.Addr + "]" + onlineUser.Name + ":" + "在线...\n"
user.SendMsg(msg)
}
} else {
go user.Server.BroadCast(user, msg)
}
}
// SendMsg 发消息给当前客户端
func (user *User) SendMsg(msg string) {
user.Conn.Write([]byte(msg))
}
5.2. 结果展示

6. 修改用户名
上面时输入”who“触发查询在线用户的指令,下面来一个”rename|肖伟“
考虑重名禁止修改
6.1. 代码
func (user *User) DoMsg(msg string) {
// 以rename|开头,Unicode字符结尾
pattern := `^rename\|.*.$`
reRename := regexp.MustCompile(pattern)
if msg == "who" {
for _, onlineUser := range user.Server.OnlineMap {
msg = "[" + onlineUser.Addr + "]" + onlineUser.Name + ":" + "在线...\n"
user.SendMsg(msg)
}
} else if reRename.MatchString(msg) {
newName := strings.Split(msg, "|")[1]
user.Server.mapLock.Lock()
// 判断新用户名是否存在
_, ok := user.Server.OnlineMap[newName]
if ok {
user.SendMsg("当前用户名已存在,请更换")
} else {
// 删除旧的
delete(user.Server.OnlineMap, user.Name)
user.Name = newName
// 添加新的
user.Server.OnlineMap[user.Name] = user
user.Server.mapLock.Unlock()
user.SendMsg("rename success to " + newName)
}
} else {
go user.Server.BroadCast(user, msg)
}
}
6.2. 结果展示

7. 超时强踢功能
用户长时间不活跃就踢掉,如何判断活跃?要求10s之内发消息,上线之后各自user开始计时,如果时间到10s了,就给当前用户的activeChannel添加信息,再有一个监听这个channel的goroutine去移除这类用户
上面打横线的是在user里做,应该是server做,server有什么,有handler方法,当客户端online之后,启动一个定时器,定时器结束之后执行移除的操作,当用户发送消息时,需要重置定时器,如何重置,再执行一次定时器就行,如何执行,for select的语句
注意这里user.Conn.Close()之后,还执行了fmt.Println("关闭了 ", user) fmt.Println("err, ", err),
关闭了 &{172.23.44.64:39320 172.23.44.64:39320 0xc000086120 0xc000066008 0xc00009c040}
err, read tcp 172.23.32.1:8888->172.23.44.64:39320: use of closed network connection
也就是触发了一次Read
7.1. 代码
func (server *Server) Handler(conn net.Conn) {
// fmt.Println("成功建立连接")
// 用户上线,添加到OnlineMap
user := NewUser(conn, server)
user.Online()
isLive := make(chan bool)
// 接收客户端消息
go func() {
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
// 如果读到的数据是0,就说明是正常关闭的
if n == 0 {
fmt.Println("关闭了 ", user)
fmt.Println("err, ", err)
user.Offline()
return
}
// 以EOF结尾的
if err != nil && err != io.EOF {
fmt.Println("Conn Read err:", err)
return
}
// 去除用户信息的’\n‘
msg := string(buf[:n-1])
// 用户处理这个消息
user.DoMsg(msg)
// 活跃了
isLive <- true
}
}()
// 阻塞当前的handler
for {
select {
case <-isLive:
// doNothing,只是为了会顺序执行下面的定时器,起到重置的作用
case <-time.After(10 * time.Second):
// 移除当前用户
server.mapLock.Lock()
delete(server.OnlineMap, user.Name)
server.mapLock.Unlock()
user.SendMsg("你已超时,强制下线")
// 关闭用户聊天的channel
close(user.C)
// 断开连接
user.Conn.Close()
return
}
}
}
7.2. 结果展示

8. 私聊功能
也得定义发消息的格式,比如”to|棉花泡泡糖|我是张三“
用户发之前先用who看看,系统发之前要检查用户是否存在
如何把消息发给指定的用户呢?onlineMap里面取
8.1. 代码
func (user *User) DoMsg(msg string) {
// 以rename|开头,Unicode字符结尾
patternRename := `^rename\|.*.$`
reRename := regexp.MustCompile(patternRename)
// 私聊消息的正则
patternPrivateChat := `^to\|.*.\|.*.$`
rePrivateChat := regexp.MustCompile(patternPrivateChat)
if msg == "who" {
for _, onlineUser := range user.Server.OnlineMap {
msg = "[" + onlineUser.Addr + "]" + onlineUser.Name + ":" + "在线...\n"
user.SendMsg(msg)
}
} else if reRename.MatchString(msg) {
newName := strings.Split(msg, "|")[1]
user.Server.mapLock.Lock()
// 判断新用户名是否存在
_, ok := user.Server.OnlineMap[newName]
if ok {
user.SendMsg("当前用户名已存在,请更换")
} else {
// 删除旧的
delete(user.Server.OnlineMap, user.Name)
user.Name = newName
// 添加新的
user.Server.OnlineMap[user.Name] = user
user.Server.mapLock.Unlock()
user.SendMsg("rename success to " + newName)
}
} else if rePrivateChat.MatchString(msg) {
userMsg := strings.Split(msg, "|")
toUserName, theMsg := userMsg[1], userMsg[2]
toUser, ok := user.Server.OnlineMap[toUserName]
if !ok {
user.SendMsg("当前用户不在线,请重新选择用户")
return
}
if len(theMsg) == 0 {
user.SendMsg("信息为空,请重新发送")
return
}
// 发给指定用户
toUser.SendMsg("[" + user.Addr + "]" + user.Name + ":" + theMsg)
} else {
go user.Server.BroadCast(user, msg)
}
}
8.2. 结果展示
李四给张三互发消息,没人理棉花泡泡糖

9. 客户端实现建立连接
不使用nc命令,而是通过一个客户端,内部是net.Dial()去建立连接
9.1. 代码
package main
import (
"flag"
"fmt"
"net"
)
type Client struct {
ServerIp string
ServerPort int
Name string
Conn net.Conn
}
func NewClient(serverIp string, serverPort int) *Client {
// 创建客户端对象
client := &Client{
ServerIp: serverIp,
ServerPort: serverPort,
}
// 连接到server
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", serverIp, serverPort))
if err != nil {
fmt.Println("net.Dial err, ", err)
return nil
}
client.Conn = conn
// 返回对象
return client
}
var serverIp string
var serverPort int
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
}
fmt.Println(">>>>>连接成功...")
// 阻塞
for {
}
}
9.2. 展示结果

10. 菜单显示
判断用户的意图:公聊、私聊、更新用户名、退出系统
10.1. 代码
package main
import (
"flag"
"fmt"
"net"
)
type Client struct {
ServerIp string
ServerPort int
Name string
Conn net.Conn
clientFlag int // 当前客户端的模式
}
func NewClient(serverIp string, serverPort int) *Client {
// 创建客户端对象
client := &Client{
ServerIp: serverIp,
ServerPort: serverPort,
clientFlag: 999,
}
// 连接到server
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", serverIp, serverPort))
if err != nil {
fmt.Println("net.Dial err, ", err)
return nil
}
client.Conn = conn
// 返回对象
return client
}
var serverIp string
var serverPort int
func init() {
flag.StringVar(&serverIp, "ip", "127.0.0.1", "设置服务器IP地址(默认127.0.0.1)")
flag.IntVar(&serverPort, "port", 8888, "设置服务器端口(默认8888)")
}
func (client *Client) menu() bool {
var clientFlag int
fmt.Println("1.公聊模式")
fmt.Println("2.私聊模式")
fmt.Println("3.更改用户名")
fmt.Println("0.退出")
fmt.Scanln(&clientFlag)
if clientFlag >= 0 && clientFlag <= 3 {
client.clientFlag = clientFlag
return true
} else {
fmt.Println("请输入合法范围内的数字")
return false
}
}
func (client *Client) run() {
for client.clientFlag != 0 {
for client.menu() != true {
}
switch client.clientFlag {
case 1:
// 公聊模式
fmt.Println("选择了公聊模式")
break
case 2:
// 私聊模式
fmt.Println("选择了私聊模式")
break
case 3:
// 更改用户名
fmt.Println("选择了更改用户名")
break
}
}
}
func main() {
flag.Parse()
client := NewClient(serverIp, serverPort)
if client == nil {
fmt.Println(">>>>>连接失败...")
return
}
fmt.Println(">>>>>连接成功...")
client.run()
}
10.2. 展示结果

11. 菜单里的功能实现
公聊模式
私聊模式
更改用户名
打印server响应的回执
// 把client的消息拷贝到stdout输出上,永久阻塞监听
io.Copy(os.Stdout, client.Conn)
11.1. 代码
package main
import (
"flag"
"fmt"
"io"
"net"
"os"
)
type Client struct {
ServerIp string
ServerPort int
Name string
Conn net.Conn
clientFlag int // 当前客户端的模式
}
func NewClient(serverIp string, serverPort int) *Client {
// 创建客户端对象
client := &Client{
ServerIp: serverIp,
ServerPort: serverPort,
clientFlag: 999,
}
// 连接到server
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", serverIp, serverPort))
if err != nil {
fmt.Println("net.Dial err, ", err)
return nil
}
client.Conn = conn
// 返回对象
return client
}
var serverIp string
var serverPort int
func init() {
flag.StringVar(&serverIp, "ip", "127.0.0.1", "设置服务器IP地址(默认127.0.0.1)")
flag.IntVar(&serverPort, "port", 8888, "设置服务器端口(默认8888)")
}
func (client *Client) menu() bool {
var clientFlag int
fmt.Println("1.公聊模式")
fmt.Println("2.私聊模式")
fmt.Println("3.更改用户名")
fmt.Println("0.退出")
fmt.Scanln(&clientFlag)
if clientFlag >= 0 && clientFlag <= 3 {
client.clientFlag = clientFlag
return true
} else {
fmt.Println("请输入合法范围内的数字")
return false
}
}
func (client *Client) PublicChat() {
fmt.Println("选择了公聊模式")
fmt.Println("请输入消息内容,输入exit退出")
var msg string
fmt.Scanln(&msg)
for msg != "exit" {
if len(msg) == 0 {
fmt.Println("不能发送空消息")
fmt.Scanln(&msg)
continue
}
_, err := client.Conn.Write([]byte(msg + "\n"))
if err != nil {
fmt.Println("conn write err, ", err)
break
}
// 置空,方便下次输入
msg = ""
fmt.Scanln(&msg)
}
}
func (client *Client) ListOnlineUser() {
_, err := client.Conn.Write([]byte("who\n"))
if err != nil {
fmt.Println("conn write err, ", err)
}
}
func (client *Client) PrivateChat() {
fmt.Println("选择了私聊模式")
client.ListOnlineUser()
fmt.Println("请输入要聊天的对象[用户名],exit退出")
var toUserName string
fmt.Scanln(&toUserName)
for toUserName != "exit" {
fmt.Println("请输入消息内容,exit退出")
var msg string
fmt.Scanln(&msg)
for msg != "exit" {
if len(msg) == 0 {
fmt.Println("不能发送空消息")
fmt.Scanln(&msg)
continue
}
sendMsg := "to|" + toUserName + "|" + msg
_, err := client.Conn.Write([]byte(sendMsg + "\n"))
if err != nil {
fmt.Println("conn write err, ", err)
break
}
// 置空,方便下次输入
msg = ""
fmt.Scanln(&msg)
}
client.ListOnlineUser()
fmt.Println("请输入要聊天的对象[用户名],exit退出")
fmt.Scanln(&toUserName)
}
}
func (client *Client) Rename() {
fmt.Println("请输入用户名")
fmt.Scanln(&client.Name)
// 模拟手动输入这个协议
sendMsg := "rename|" + client.Name
_, err := client.Conn.Write([]byte(sendMsg + "\n"))
if err != nil {
fmt.Println("conn write err, ", err)
}
}
func (client *Client) run() {
for client.clientFlag != 0 {
for client.menu() != true {
}
switch client.clientFlag {
case 1:
// 公聊模式
client.PublicChat()
break
case 2:
// 私聊模式
client.PrivateChat()
break
case 3:
// 更改用户名
client.Rename()
break
}
}
}
// DealResponse 显示返回结果,显示到标准输出
func (client *Client) DealResponse() {
// 把client的消息拷贝到stdout输出上,永久阻塞监听
io.Copy(os.Stdout, client.Conn)
}
func main() {
flag.Parse()
client := NewClient(serverIp, serverPort)
if client == nil {
fmt.Println(">>>>>连接失败...")
return
}
fmt.Println(">>>>>连接成功...")
// 开启一个goroutine去处理server的回执消息
go client.DealResponse()
// 客户端业务
client.run()
}
11.2. 展示结果

12. 优化超时强踢导致CPU占用率高,读取关闭的channel
第七点超时强踢功能里,此处关闭 user.C,关闭 user.Conn,然后return
case <-time.After(10 * time.Second):
// 从OnlineMap移除当前用户...
user.SendMsg("你已超时,强制下线")
// 关闭用户聊天的channel
close(user.C)
// 断开连接
user.Conn.Close()
return
下面是用户初始化之后会执行的 goroutine,user.C 已经关闭了,读取到的 msg 是对应 channel 的零值,空字符串,然后就会走到下面的 user.Conn.Write(),但是上面已经 user.Conn.Close() 了,所以这里会报错 connection closed。这里是 for 循环,所以 err 会一直输出,导致 CPU 占有率很高。
所以我们要让 ListenMessage 知道当前的 user.C 已经关闭了,就不要再读了,也就不会走下面的 write,
func (user *User) ListenMessage() {
for {
msg := <-user.C
// 当超时下线的时候,这里user.C已经关闭了,取到的msg是零值。
// 并且下线的时候user.Conn已经close了,下面的Conn.Write就会报错
// 所以得让ListenMessage知道这个conn关闭了,或者是channel已经关闭了,就不要执行下面的了
_, err := user.Conn.Write([]byte(msg + "\n"))
if err != nil {
// 会一直输出err
fmt.Println("conn write err, ", err)
//return
}
}
}
利用到的特性是 golang 里面的 for msg := range ch {},当 ch 没了,for 循环就退出
for msg := range user.C {
_, err := user.Conn.Write([]byte(msg + "\n"))
if err != nil {
fmt.Println("conn write err, ", err)
}
}
13. 复盘
如何一步一步迭代开发搭建起来的?
- 完成一个server,能通过TCP协议建立连接
- 客户端上线之后能有提醒,server能记录在线的客户端,能通知其他客户端,需要有广播
- 上面完成了上线提醒的广播,这是server做的,用户自己本身也需要发消息,所以涉及到socket读取控制台的数据 conn.read
- 封装用户业务功能,让user和server分离,user对象需要加一个server属性
- 查询在线的用户,约定一个协议,输入who,就是触发这个动作
- 修改当前用户的用户名,也是约定协议,输入rename|xw
- 长期不活跃的用户被剔除,需要有定时器,在用户发消息之后,需要重置定时器,用for select
- 私聊发送消息,依据用户名从server获取到user对象,然后发消息,也需要约定协议
- 现在都是nc连接的,下面实现一个客户端,模拟输入的约定消息,flag去读取命令行的输入

391

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



