GO websocket实现简易聊天室

617 阅读2分钟

前导知识

GO WebSocket 编程 - 掘金 (juejin.cn)

实现思路总览

  • 用户通过网页建立Client 并向hub注册,Hub 维持一个注册表,当Client从前端接收消息时向Hub发送,hub会向注册表中的所有Client 回显消息。当网页关闭时Client 会从Hub 中注销。

image.png

hub结构

broadcast chan []byte  //广播管道
clients map[*Client]struct{}  //注册表
register chan *Client  //注册管道
unregister chan *Client  //注销管道
  • broadcast:接收client从前端得到的消息,并最终向client的send管道发送消息
  • clients:注册表,以便向每个client的send管道发送信息。用户进入聊天室时load,退出聊天室时delete
  • register:用户进入聊天室时向注册管道发送消息提醒hub注册该client
  • unregister:用户退出聊天室时向注销管道发送消息提醒hub注销该client

client结构

hub *Hub 
conn *websocket.Conn //websocket连接
send chan []byte  //消息管道
name []byte //姓名
  • hub:每个client中都包含hub的指针,用以向hub的broadcast发送消息
  • conn:与websocket的连接
  • send:hub广播管道接收消息后向每个client的消息管道发送消息,client再回显到前端

hub监听管道

由于hub结构体中维持着三个管道(broadcast、register、unregister),我们可以开一个协程死循环的监听三个管道

  • register管道接收到client时,向hub的注册表进行注册
  • unregister管道接收到client时,向hub的注册表进行注销并关闭该client的send管道
  • broadcast管道接收到某个client发送的消息时,遍历注册表,向每个client的send管道写入消息。若无法将数据写入管道则视为该client出故障了,注销该client并关闭其send管道
func (hub *Hub) Run(){
	for{
		select{
		case client := <-hub.register:
			hub.clients[client] = struct{}{}
		case client := <- hub.unregister:
			delete(hub.clients,client)
			close(client.send)
		case msg := <-hub.broadcast:
			for client :=  range hub.clients{
				select{
				case client.send <- msg://如果管道不能立即写入数据,就认为该client出故障了
				default:
					close(client.send)
					delete(hub.clients, client)
				}
			}
		}

	}
}

conn的设置参数

     const(
  maxMsgSize = 512 //消息的长度不能超过512
  pingPeriod = 9 * pongWait / 10 //client向前端定时发送心跳确保前端还存在
  pongWait   = 60 * time.Second //前端确认心跳的等待时间
  writeWait  = 10 * time.Second //10秒内必须把信息写给前端
  )

client从send管道读取广播内容并回显到浏览器

创建一个ticker定时向conn发送心跳,开启协程死循环监听send管道和ticker

  • 在开启协程时先做好善后工作:结束时关闭ws连接和ticker
defer func(){
	ticker.Stop() //ticker不用就stop,防止协程泄漏
	fmt.Printf("close connection to %s\n", client.conn.RemoteAddr().String())
	client.conn.Close() //给前端写数据失败,就可以关系连接了
}()
  • 死循环监听管道
for{
	select{
	case msg, ok := <-client.send:
		if !ok{
			fmt.Println("管道已经关闭")
			client.conn.WriteMessage(websocket.CloseMessage, []byte{})
			return
		}
		client.conn.SetWriteDeadline(time.Now().Add(writeWait))////10秒内必须把信息写给前端(写到websocket连接里去),否则就关闭连接
		if writer, err := client.conn.NextWriter(websocket.TextMessage); err != nil{
			return 
		}else{
			writer.Write(msg)
			writer.Write([]byte{'\n'})
			// 有消息一次全写出去
			n := len(client.send)
			for i := 0; i < n; i++ {
				writer.Write(<-client.send)
				writer.Write([]byte{'\n'})
			}
			if err := writer.Close(); err != nil { //必须调close,否则下次调用client.conn.NextWriter时本条消息才会发送给浏览器
				return //结束一切
			}
		}
	case <-ticker.C:
		client.conn.SetWriteDeadline(time.Now().Add(writeWait))
		if err := client.conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil{
			return
		}
	}
}

client从websocket读取数据并传输给boradcast管道的实现

可以单开一个协程死循环的从conn读取数据

  • 在开启协程时先做好善后工作:结束时的注销和关闭ws连接
defer func() { //收尾工作
		client.hub.unregister <- client //从hub那注销client
		fmt.Printf("%s offline\n", client.frontName)
		fmt.Printf("close connection to %s\n", client.conn.RemoteAddr().String())
		client.conn.Close() //关闭websocket管道
	}()
  • 进行conn参数设置
client.conn.SetReadLimit(maxMsgSize) //一次从管管中读取的最大长度
// 连接不断的情况下,每隔54秒向浏览器发一次ping,浏览器返回pong
client.conn.SetReadDeadline(time.Now().Add(pongWait)) //60秒后不允许读
client.conn.SetPongHandler(func(appData string) error {
client.conn.SetReadDeadline(time.Now().Add(pongWait)) //每次收到pong都把deadline往后推迟60秒
   return nil
   })
  • 死循环读取conn内容
for {
	_, message, err := client.conn.ReadMessage() //如果前端主动断开连接,该行会报错,for循环会退出。注销client时,hub那儿会关闭client.send管道
	if err != nil {
		//如果以意料之外的关闭状态关闭,就打印日志
		if websocket.IsUnexpectedCloseError(err, websocket.CloseAbnormalClosure, websocket.CloseGoingAway) {
			fmt.Printf("close websocket conn error: %v\n", err)
		}
		break //只要ReadMessage失败,就关闭websocket管道、注销client,退出
	} else {
		//换行符用空格替代,bytes.TrimSpace把首尾连续的空格去掉,-1为全部替换
		message = bytes.TrimSpace(bytes.Replace(message, newLine, space, -1))
		if len(client.frontName) == 0 {
			client.frontName = message //约定:从浏览器读到的第一条消息代表前端的身份标识,该信息不进行广播
			fmt.Printf("%s online\n", string(client.frontName))
		} else {
			//要广播的内容前面加上front的名字
			client.hub.broadcast <- bytes.Join([][]byte{client.frontName, message}, []byte(": ")) //从websocket连接里读出数据,发给hub的broadcast
		}
	}
}

main函数实现:

  1. 创建hub并起一个协程运行hub.Run
  2. 定义路由,注册每种请求对应的处理函数
  3. 监听端口

主页面路由实现:

请求根目录时直接返回html页面,通过html页面调用chat路由进行websocket连接

func ServeHome(w http.ResponseWriter, r *http.Request){
    if r.URL.Path != "/" {//只允许访问根路径
	http.Error(w, "Not Found", http.StatusNotFound)
	return
	}
    if r.Method != "GET"{//只允许get请求
	http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
	return
        }
http.ServeFile(w, r, "socket/chatRoom/home.html") //请求根目录时直接返回一个html页面
}

chat页面路由实现:

func ServeWS(hub *Hub, w http.ResponseWriter, r *http.Request){
	upgrader := websocket.Upgrader{
	HandshakeTimeout: 2 * time.Second, //握手超时时间
	ReadBufferSize:   1024,            //读缓冲大小
	WriteBufferSize:  1024,            //写缓冲大小
	CheckOrigin:      func(r *http.Request) bool { return true },
	Error:            func(w http.ResponseWriter, r *http.Request, status int, reason error) {},
	}
	conn, err := upgrader.Upgrade(w,r,nil)
	checkError(err)
	fmt.Printf("connect to client %s\n", conn.RemoteAddr().String())
	client := &Client{hub: hub, conn: conn, send: make(chan []byte,256)}
	hub.register <- client
	go client.read()
	go client.write()
}

html实现(不做详解)

<!DOCTYPE html>
<html lang="en">

<head>
    <title>聊天室</title>
    <script type="text/javascript">
        window.onload = function () {//页面打开时执行以下初始化内容
            var conn;
            var msg = document.getElementById("msg");
            var log = document.getElementById("log");

            function appendLog(item) {
                var doScroll = log.scrollTop > log.scrollHeight - log.clientHeight - 1;
                log.appendChild(item);
                if (doScroll) {
                    log.scrollTop = log.scrollHeight - log.clientHeight;
                }
            }

            document.getElementById("form").onsubmit = function () {
                if (!conn) {
                    return false;
                }
                if (!msg.value) {
                    return false;
                }
                conn.send(msg.value);
                msg.value = "";
                return false;
            };

            if (window["WebSocket"]) {//如果支持websockte就尝试连接
                //从浏览器的开发者工具里看一下ws的请求头
                conn = new WebSocket("ws://127.0.0.1:5656/chat");//请求跟websocket服务端建立连接(注意端口要一致)。关闭浏览器页面时会自动断开连接
                conn.onclose = function (evt) {
                    var item = document.createElement("div")
                    item.innerHTML = "<b>Connection closed.</b>";//连接关闭时打印一条信息
                    appendLog(item);
                };
                conn.onmessage = function (evt) {//如果conn里有消息
                    var messages = evt.data.split('\n');//用换行符分隔每条消息
                    for (var i = 0; i < messages.length; i++) {
                        var item = document.createElement("div");
                        item.innerText = messages[i];//把消息逐条显示在屏幕上
                        appendLog(item);
                    }
                };
            } else {
                var item = document.createElement("div");
                item.innerHTML = "<b>Your browser does not support WebSockets.</b>";
                appendLog(item);
            }
        };
    </script>
    <style type="text/css">
        html {
            overflow: hidden;
        }

        body {
            overflow: hidden;
            padding: 0;
            margin: 0;
            width: 100%;
            height: 100%;
            background: gray;
        }

        #log {
            background: white;
            margin: 0;
            padding: 0.5em 0.5em 0.5em 0.5em;
            position: absolute;
            top: 0.5em;
            left: 0.5em;
            right: 0.5em;
            bottom: 3em;
            overflow: auto;
        }

        #form {
            padding: 0 0.5em 0 0.5em;
            margin: 0;
            position: absolute;
            bottom: 1em;
            left: 0px;
            width: 100%;
            overflow: hidden;
        }
    </style>
</head>

<body>
    <div id="log"></div>
    <form id="form">
        <input type="submit" value="发送" />
        <input type="text" id="msg" size="100" autofocus />
    </form>
</body>

</html>

KEYS

  1. 作为server运行时html实现中new WebSocket要写自己的ip地址而不是localhost
  2. 由于主页面路由设置,serveFile指定路径,程序需要在特定目录启动