Golang: 并发聊天室综合案例

一个小的并发案例,网络聊天室,在线统计,消息转发。

专栏的介绍可以参考 《CaseGolang专栏》,代码可以看《宝库-Case》

这里主要是一步步实现一个并发的聊天室,很简单的案例。

设计

实现细节可能会冗杂一些,但是大致的实现思路却非常简单。

1
2
//总体流程
//server -(处理连接 或者 转发)-> message --->Client.C ---> 读conn.Write给Client端

具体的功能可以按照客户端发送的消息不同,而处理方式不同:

  • 已经连接时,向所有客户端显示已经登录
  • 客户端发消息要求更改客户端显示名字
  • 发送聊天消息则简单转发给所有用户
  • 离线时,向所有客户端显示某客户端已经离线
  • 查看在线人数或者哪些用户在线

大致思路如下:

设计思路

案例

上线功能

先测试一下服务器端的功能: (之后再完善)

上线转发功能

此阶段性代码如下: (当前只管功能完成,不管是否高效)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
//filename: server.go

package main

import (
"net"
"log"
)

//用于记录全局在线人员
type Client struct {
C chan string //用于给该Client转发消息
Name string //用户名
Addr string //网络地址
}

var onlineMap map[string]Client

var message = make(chan string) //用于操作 onlineMap 同步控制,同时传输消息


//总体流程
//server -(处理连接 或者 转发)-> message --->Client.C ---> 读conn.Write给Client端
func main() {
listener, err := net.Listen("tcp", ":8000")
if err != nil {
log.Fatalln("net.Listen = ", err)
}
defer listener.Close()

//初始化 map
onlineMap = make(map[string]Client)


//协程1,用于转发消息给 Client.C
go sendMessage2ClientChan()

//主协程,循环等待连接
for {
conn, err := listener.Accept()
if err != nil {
log.Println("listen.Accept err = ", err)
continue
}

//处理用户连接:
// 1. 新连接加入 map
// 2. 开启等待读取 Client.C 写回客户端的协程,等待读取Client.C
// 3. 广播该客户端上线了
// 4. 新开协程接收用户发送过来的数据 TODO
go HandleConn(conn)



}
}

//死循环,阻塞等待,写消息(写入 Client.C)
func sendMessage2ClientChan() {
for {
msg := <- message //阻塞等待读取消息

for _, cli := range onlineMap {
cli.C <- msg //写
}
}
}

//读取 Client.C 转发给具体的 CLient
func writeMsg2Client(cli Client, conn net.Conn) {
for msg := range cli.C { //读
conn.Write([]byte(msg + "\n"))
}
}


//处理新连接
func HandleConn(conn net.Conn) { //处理用户连接
defer conn.Close() //处理完就关闭连接

//获取客户端信息,加入 map
cliAddr := conn.RemoteAddr().String() //作为key
////默认情况 Client.Name 就是地址名字
cli := Client{make(chan string), cliAddr, cliAddr}

//把结构体添加到 map
onlineMap[cliAddr] = cli

//等待读取 Client.C,转发给 Client 对端
go writeMsg2Client(cli, conn)

//写 mesage 给具体的 cli.C
message <- makeMsg(cli, "上线了。")

//开一个协程,读取Client发送过来的数据
//TODO

for {

}//连接还不能关闭

}

//工具方法
func makeMsg(cli Client, str string) (buf string) {
buf = "[ " + cli.Addr + " ] " + cli.Name + " : " + str
return
}

消息转发功能

客户端发送的消息,可以全局显示:

转发功能

代码也很简单,即 hanldeConn 开个协程专门用来读取客户端写过来的内容,通过 message 转发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//处理新连接
func HandleConn(conn net.Conn) { //处理用户连接
defer conn.Close() //处理完就关闭连接

//获取客户端信息,加入 map
cliAddr := conn.RemoteAddr().String() //作为key
////默认情况 Client.Name 就是地址名字
cli := Client{make(chan string), cliAddr, cliAddr}

//把结构体添加到 map
onlineMap[cliAddr] = cli

//等待读取 Client.C,转发给 Client 对端
go writeMsg2Client(cli, conn)

//写 mesage 给具体的 cli.C
message <- makeMsg(cli, "上线了。")

//新开协程接收用户Client的数据
go func() {
buf := make([]byte, 1024*2)
for {
n, err := conn.Read(buf)
if n==0 { //对端断开或者其他连接问题
log.Println("conn.Read err = ", err)
return //该协程结束,而不是server结束
}
//转发读到的消息
msg := string(buf[:n-1]) //读多少,转发多少
message <- makeMsg(cli, msg)
}
}()

for {

}//连接还不能关闭
}

提示自己功能

上线向大家提示一下我上线了,同时显示下 whoami

1
2
3
4
5
6
//写 mesage 给具体的 cli.C
message <- makeMsg(cli, "上线了。")
//写给自己端,提示我的用户是谁 ---- 后面的改名功能
cli.C <- makeMsg(cli, cli.Name + "初来乍到,请多指教")

//新开协程接收用户Client的数据

查看用户

万一我想查看一下当前在线的用户怎么办?who:

在线用户

代码实现,就是对读取的字符串细化处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//转发读到的消息
msg := string(buf[:n-1]) //读多少,转发多少

if len(msg) == 3 && msg == "who" {
//遍历 map,给当前用户发送所有成员
conn.Write([]byte("-----------\n用户列表如下: \n"))
for _, tmp := range onlineMap {
usrStr := tmp.Addr + ":" + tmp.Name +"\n"
conn.Write([]byte(usrStr))
}
conn.Write([]byte("-----------\n"))

} else {
message <- makeMsg(cli, msg)
}

改名功能

我想改名,显示一个吊炸天的名字。发送 rename|骨傲天:

改名

1
2
3
4
5
6
else if len >=8 && msg[:6] == "rename" { //rename|骨傲天
newName := strings.Split(msg, "|")[1]
cli.Name = newName
onlineMap[cliAddr] = cli //因为map存储的是副本
conn.Write([]byte("rename 完毕。\n"))
}

离线功能

如果 server 从 client 读到的信息是对方重置了连接,即表明离线了:

离线功能

此时怎么办?简单处理如下:(检测到下线了,给大家写一下消息)。

但是实际上,应该更加细分,如果对方是主动退出,如果是连接断开导致的退出。

其实也可能是超时连接,或者长时间连接但不发送消息导致了。(超时退出,看后面)

如此一来,考虑用 slelect 比较好:

离线功能

效果如下:

离线功能

超时退出

就是在 select 里面加一个分支检测超时,即其他分支不执行,就执行这个分支:

超时功能

代码如下:

超时功能代码

总结

  • map 为什么可以并发写?

因为这里是以 ip:port 作为 key 的,一定不会重复(无脏数据)。严格意义上,还是要做好同步控制。

  • 先写入 message 在写入 Client.C 的原因?

因为这里的 Client 是一个领域模型对象,代表了不同的客户端;但是 message 代表数据队列。
两者本质上是不同的,通过写入数据管道,最终保存在数据结构中,围绕数据结构展开读写,这是本编程案例的思想。

其次: message 是全局的,而 Client.C 这个消息是单个Client,代码中所有的开启,处理连接的协程都是基于个体Client的,阻塞读取也是基于本 client的,单个 client 的消息之所以会群显示,就是因为遍历单独写入了 Client.C :

1
2
3
4
5
6
7
8
9
10
//死循环,阻塞等待,写消息(写入 Client.C)
func sendMessage2ClientChan() {
for {
msg := <- message //阻塞等待读取消息

for _, cli := range onlineMap {
cli.C <- msg //写
}
}
}

缺陷:容器里面存入引用&指针才是上策—如果修改的话。

map 里面一开始就应该存储 Client 结构的指针,因为没想到后面想修改其中的内容,所以一开始设计成了值副本。

最后,详细代码放在了 github 上 《gopher宝库》


Merlin 最后补充 网络部分(网络和并发联系紧密)

文章目录
  1. 1. 设计
  2. 2. 案例
    1. 2.1. 上线功能
    2. 2.2. 消息转发功能
    3. 2.3. 提示自己功能
    4. 2.4. 查看用户
    5. 2.5. 改名功能
    6. 2.6. 离线功能
    7. 2.7. 超时退出
  3. 3. 总结
|