背景
大家好,我是公众号「线下聚会游戏」作者,开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏。其中的核心技术就是WebSocket,快关注HullQin一起学习吧!
还没学过Go,要先看什么?
建议你花1天时间,看一下Go的原理简介、基础语法。什么教程都可以,知名的教程就行。
至少要明白:各种数据类型,控制流(for、if等)写法,弄懂channel和goroutine,如何加锁。
一定要自己写写goroutine和channel试一下,了解一下基础语法。
此外,还要了解常用包的用法,包括fmt、net/http。
技术选型
面对自己不熟悉的语言和不熟悉的框架,该怎么做技术选型呢?
我告诉你个小技巧,直接在Github上搜索,看Star最多的那个仓库,就可以啦~
看吧,我们搜到了gorilla/websocket
,star数以显著差异甩开了后面几名。这就没有什么好纠结的了,果断使用它。
新建项目
在使用GoLand时,新建Go Project会有2个选项:
我们选用第一个即可。
如果你没有GoLand,也可以手动创建文件夹,在里面新建文件go.mod
(我是使用的目前最新稳定版1.18)
module echo
go 1.18
安装依赖
代码语言:shell复制go get github.com/gorilla/websocket
拷贝chat代码
把gorilla/websocket
的官方demo拷贝过来即可,我们慢慢分析:
- github.com/gorilla/websocket/tree/master/examples/chat
你需要这4个文件:
- main.go
- hub.go
- client.go
- index.html
第一步,看主函数
代码语言:go复制func main() {
flag.Parse()
hub := newHub()
go hub.run()
http.HandleFunc("/", serveHome)
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
上篇已经介绍了flag
和http.HandleFunc
,这里跟上篇是一模一样的。
这里还开启了一个goroutine,注意它是写在main函数里的,不是写在http.HandleFunc里的。所以不管有多少客户端连接,这个服务只开启了一个goroutine。newHub().run()
。我们下一步看newHub()
,在hub.go文件中。
再看下注册的2个请求处理函数:
serveHome
是一个HTTP服务,把html文件返回给请求方(浏览器)。- 针对
/ws
路由,则会调用serveWs
,我们下下一步看serveWs
做了什么,在clent.go文件中。
第二步,看hub.go
Hub定义和newHub函数定义
代码语言:go复制type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan []byte),
}
}
可以看到newHub只是新建了一个空白的Hub。而1个Hub包含4个东西:
clients
,保存了每个客户端的引用的Map(其实这个Map的value没有用到,key是客户端的引用,可以当作是其它语言的set)。register
,用于注册客户端的channel。每当有客户端建立websocket连接时,通过register,把客户端保存到clients引用中。unregister
,用于注销客户端的channel。每当有客户端断开websocket连接时,通过unregister,把客户端引用从clients中删除。broadcast
,用于发送广播的channel。把消息存到这个channel后,之后会有其它goroutine遍历clients
,把消息发送给所有客户端。
服务开启时启动的goroutine: hub.run()
代码语言:go复制func (h *Hub) run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
一个死循环:不断从channel读取数据。读取到register
,就注册客户端。读取到unregister
,就断开客户端连接,删除引用。读取到broadcast
,就遍历clients
,广播消息(通过把消息写入每个客户端的client.send
channel中,实现广播),正是下一步要看的逻辑。
下一步,我们看client
。
第三步,看client.go
Client定义
代码语言:go复制type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
hub
: 每个Client客户端保存了Hub
的引用。(虽然目前全局只有1个hub,但是为了可扩展性,还是保存一份吧,因为将来会有多hub,下篇文章我们就介绍!)conn
: 即跟客户端的websocket连接,通过这个conn
可以跟客户端交互(即收发消息)。send
: 一个channel,在第二步已经见识到了,broadcast时,就是把消息写入了每个Client的send
channel中。通过从这个channel读取消息,发送消息给客户端。
main函数用到的serveWs函数
代码语言:go复制func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in
// new goroutines.
go client.writePump()
go client.readPump()
}
在hub中,注册了一下。
随后启动了2个goroutine: client.writePump()
和client.readPump()
,然后这个函数逻辑就结束了。
这2个goroutine,分别用于处理写入消息和读取消息。
client.writePump
代码语言:go复制func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
首先开启了一个ping计时器。会固定周期发送Ping消息给客户端。这是WebSocket协议要求的,参考《RFC6455》。你在浏览器上抓包看不到这个Ping消息。这种方式,可以将没响应的连接清理掉。
然后,这个goroutine,声明了defer执行的逻辑:关闭计时器,关闭连接。
最重要的部分,这个goroutine有个死循环:不断读取client.send这个channel中的数据。只要hub.broadcast给它传了消息,那么就由这个goroutine来处理。c.conn.NextWriter
和w.Write(message)
是真正的发消息的逻辑。
此外,每隔一段时间(定时器设置的时间间隔),服务器都会发送一个Ping给浏览器。浏览器会自动回复一个Pong(不需要客户端开发者关注,客户端开发者通常是JS开发者)。
client.readPump
代码语言:go复制func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break
}
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
c.hub.broadcast <- message
}
}
readPump
就是读取消息,收到客户端消息后,就借助hub.broadcast
广播出去。
此外,这个goroutine有个重要的任务:关闭连接后,负责hub.unregister
和conn.Close
。
总结!最重要的一个图!
为了帮助大家理解,我绘制了这个图:
其中,彩色矩形表示goroutine,彩色线条是各个channel(从A指向B表示,由goroutine A写入数据,由goroutine B读取数据)。
User和Client图中只画了2个,是可以继续增加的。
写在最后
我是HullQin,公众号线下聚会游戏的作者(欢迎关注我,交个朋友)。转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费无广告。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这个专栏里分享:《教你做小游戏》。