前导知识
GO WebSocket 编程 - 掘金 (juejin.cn)
实现思路总览
- 用户通过网页建立Client 并向hub注册,Hub 维持一个注册表,当Client从前端接收消息时向Hub发送,hub会向注册表中的所有Client 回显消息。当网页关闭时Client 会从Hub 中注销。
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函数实现:
- 创建hub并起一个协程运行hub.Run
- 定义路由,注册每种请求对应的处理函数
- 监听端口
主页面路由实现:
请求根目录时直接返回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
- 作为server运行时html实现中new WebSocket要写自己的ip地址而不是localhost
- 由于主页面路由设置,serveFile指定路径,程序需要在特定目录启动