示例: 聊天服务

我们用一个聊天服务器来终结本章节的内容,这个程序可以让一些用户通过服务器向其它所有用户广播文本消息。这个程序中有四种 goroutine。main 和 broadcaster 各自是一个goroutine 实例,每一个客户端的连接都会有一个 handleConn 和 clientWriter 的goroutine。broadcaster 是 select 用法的不错的样例,因为它需要处理三种不同类型的消息。

下面演示的 main goroutine 的工作,是 listen 和 accept(译注:网络编程里的概念)从客户端过来的连接。对每一个连接,程序都会建立一个新的 handleConn 的 goroutine,就像我们在本章开头的并发的 echo 服务器里所做的那样。

Unresolved include directive in modules/ROOT/pages/ch8/ch8-10.adoc - include::example$/ch8/chat/chat.go[]

然后是 broadcaster 的 goroutine。他的内部变量 clients 会记录当前建立连接的客户端集合。其记录的内容是每一个客户端的消息发出 channel 的“资格”信息。

Unresolved include directive in modules/ROOT/pages/ch8/ch8-10.adoc - include::example$/ch8/chat/chat.go[]

broadcaster 监听来自全局的 entering 和 leaving 的 channel 来获知客户端的到来和离开事件。当其接收到其中的一个事件时,会更新 clients 集合,当该事件是离开行为时,它会关闭客户端的消息发送 channel。broadcaster 也会监听全局的消息 channel,所有的客户端都会向这个 channel 中发送消息。当 broadcaster 接收到什么消息时,就会将其广播至所有连接到服务端的客户端。

现在让我们看看每一个客户端的 goroutine。handleConn 函数会为它的客户端创建一个消息发送 channel 并通过 entering channel 来通知客户端的到来。然后它会读取客户端发来的每一行文本,并通过全局的消息 channel 来将这些文本发送出去,并为每条消息带上发送者的前缀来标明消息身份。当客户端发送完毕后,handleConn 会通过 leaving 这个 channel 来通知客户端的离开并关闭连接。

Unresolved include directive in modules/ROOT/pages/ch8/ch8-10.adoc - include::example$/ch8/chat/chat.go[]

另外,handleConn 为每一个客户端创建了一个 clientWriter 的 goroutine,用来接收向客户端发送消息的 channel 中的广播消息,并将它们写入到客户端的网络连接。客户端的读取循环会在 broadcaster 接收到 leaving 通知并关闭了 channel 后终止。

下面演示的是当服务器有两个活动的客户端连接,并且在两个窗口中运行的情况,使用 netcat 来聊天:

$ go build gopl.io/ch8/chat
$ go build gopl.io/ch8/netcat3
$ ./chat &
$ ./netcat3
You are 127.0.0.1:64208               $ ./netcat3
127.0.0.1:64211 has arrived           You are 127.0.0.1:64211
Hi!
127.0.0.1:64208: Hi!                  127.0.0.1:64208: Hi!
                                      Hi yourself.
127.0.0.1:64211: Hi yourself.         127.0.0.1:64211: Hi yourself.
^C
                                      127.0.0.1:64208 has left
$ ./netcat3
You are 127.0.0.1:64216               127.0.0.1:64216 has arrived
                                      Welcome.
127.0.0.1:64211: Welcome.             127.0.0.1:64211: Welcome.
                                      ^C
127.0.0.1:64211 has left”

当与 n 个客户端保持聊天 session 时,这个程序会有 2n+2 个并发的 goroutine,然而这个程序却并不需要显式的锁(§9.2)。clients 这个 map 被限制在了一个独立的 goroutine 中,broadcaster,所以它不能被并发地访问。多个 goroutine 共享的变量只有这些 channel 和 net.Conn 的实例,两个东西都是并发安全的。我们会在下一章中更多地讲解约束,并发安全以及 goroutine 中共享变量的含义。


练习 8.12: 使 broadcaster 能够在每个新的客户端到来时通知它当前的客户端集合。这需要你在 clients 集合中,以及 entering 和 leaving 的 channel 中记录客户端的名字。

练习 8.13: 使聊天服务器能够断开空闲的客户端连接,比如最近五分钟之后没有发送任何消息的那些客户端。提示:可以在其它 goroutine 中调用 conn.Close() 来解除 Read 调用,就像 input.Scanner() 所做的那样。

练习 8.14: 修改聊天服务器的网络协议,这样每一个客户端就可以在 entering 时提供他们的名字。将消息前缀由之前的网络地址改为这个名字。

练习 8.15: 如果一个客户端没有及时地读取数据可能会导致所有的客户端被阻塞。修改 broadcaster 来跳过一条消息,而不是等待这个客户端一直到其准备好读写。或者为每一个客户端的消息发送 channel 建立缓冲区,这样大部分的消息便不会被丢掉;broadcaster 应该用一个非阻塞的 send 向这个 channel 中发消息。