4.5 实现聊天室:核心流程
本节我们讲解聊天室的核心流程的实现。
4.5.1 前端关键代码
在项目中的 template/home.html 文件中增加 html 相关代码:(考虑篇幅,只保留主要的 html 部分,完整代码可通过 git clone https://github.com/go-programming-tour-book/chatroom
获取)
<div class="container" id="app">
<div class="row">
<div class="col-md-12">
<div class="page-header">
<h2 class="text-center"> 欢迎来到《Go 语言编程之旅:一起用 Go 做项目》聊天室 </h2>
</div>
</div>
</div>
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-6">
<div> 聊天内容 </div>
<div class="msg-list" id="msg-list">
<div class="message"
v-for="msg in msglist"
v-bind:class="{ system: msg.type==1, myself: msg.user.nickname==curUser.nickname }"
>
<div class="meta" v-if="msg.user.nickname"><span class="author">${ msg.user.nickname }</span> at ${ formatDate(msg.msg_time) }</div>
<div>
<span class="content" style="white-space: pre-wrap;">${ msg.content }</span>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div> 当前在线用户数:<font color="red">${ onlineUserNum }</font></div>
<div class="user-list">
<div class="user" v-for="user in users">
用户:@${ user.nickname } 加入时间:${ formatDate(user.enter_at) }
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-1"></div>
<div class="col-md-10">
<div class="user-input">
<div class="usertip text-center">${ usertip }</div>
<div class="form-inline has-success text-center" style="margin-bottom: 10px;">
<div class="input-group">
<span class="input-group-addon"> 您的昵称 </span>
<input type="text" v-model="curUser.nickname" v-bind:disabled="joined" class="form-control" aria-describedby="inputGroupSuccess1Status">
</div>
<input type="submit" class="form-control btn-primary text-center" v-on:click="leavechat" v-if="joined" value="离开聊天室">
<input type="submit" class="form-control btn-primary text-center" v-on:click="joinchat" v-else="joined" value="进入聊天室">
</div>
<textarea id="chat-content" rows="3" class="form-control" v-model="content"
@keydown.enter.prevent.exact="sendChatContent"
@keydown.meta.enter="lineFeed"
@keydown.ctrl.enter="lineFeed"
placeholder="在此收入聊天内容。ctrl/command+enter 换行,enter 发送"></textarea>
<input type="button" value="发送(Enter)" class="btn-primary form-control" v-on:click="sendChatContent">
</div>
</div>
</div>
</div>
之后打开终端,启动聊天室。打开浏览器访问 localhost:2022,出现如下界面:
根据前面的讲解知道,这是通过 HTTP 请求了 /
这个路由,对应到如下 handle 的代码:
// server/home.go
func homeHandleFunc(w http.ResponseWriter, req *http.Request) {
tpl, err := template.ParseFiles(rootDir + "/template/home.html")
if err != nil {
fmt.Fprint(w, "模板解析错误!")
return
}
err = tpl.Execute(w, nil)
if err != nil {
fmt.Fprint(w, "模板执行错误!")
return
}
}
代码只是简单的渲染页面。
小提示:因为模板中不涉及到任何服务端渲染,所以,在部署时,如果使用 Nginx 这样的 WebServer,完全可以直接将 index 指向 home.html,而不经过 Go 渲染。
我们的前端使用了 Vue,如果你对 Vue 完全不了解,建议你可以到 Vue 的官网学习一下,它是国人开发的,中文文档很友好。
在看到的页面中,在「您的昵称」处输入:polaris,点击「进入聊天室」。
这个过程涉及到的网络环节前面已经抓包讲解过,这里主要看下前端 JS 部分的实现。
// 只保留了 WebSocket 相关的核心代码
if ("WebSocket" in window) {
let host = location.host;
// 打开一个 websocket 连接
gWS = new WebSocket("ws://"+host+"/ws?nickname="+this.nickname);
gWS.onopen = function () {
// WebSocket 已连接上的回调
};
gWS.onmessage = function (evt) {
let data = JSON.parse(evt.data);
if (data.type == 2) {
that.usertip = data.content;
that.joined = false;
} else if (data.type == 3) {
// 用户列表
that.users.splice(0);
for (let nickname in data.users) {
that.users.push(data.users[nickname]);
}
} else {
that.addMsg2List(data);
}
};
gWS.onerror = function(evt) {
console.log("发生错误:");
console.log(evt);
};
gWS.onclose = function () {
console.log("连接已关闭...");
};
} else {
alert("您的浏览器不支持 WebSocket!");
}
前端 WebSocket 的核心是构造函数和几个回调函数。
- new WebSocket:创建一个 WebSocket 实例,提供服务端的 ws 地址,地址可以跟 HTTP 协议一样,加上请求参数。注意,如果你使用 HTTPS 协议,相应的 WebSocket 地址协议要改为 wss;
- WebSocket.onopen:用于指定连接成功后的回调函数;
- WebSocket.onerror:用于指定连接失败后的回调函数;
- WebSocket.onmessage:用于指定当从服务器接收到信息时的回调函数;
- WebSocket.onclose:用于指定连接关闭后的回调函数;
在用户点击进入聊天室时,根据 Vue 绑定的事件,会执行上面的代码,发起 WebSocket 连接,服务端会将相关信息通过 WebSocket 长连接返回给客户端,客户端通过 WebSocket.onmessage
回调进行处理。
得益于 Vue 的双向绑定,在数据显示、事件绑定等方面,处理起来很方便。
关于前端的实现,这里有几点提醒下读者:
- Vue 默认的分隔符是
{{}}
,和 Go 的一样,避免冲突进行了修改; - ctrl/command+enter 换行,enter 发送 的事件绑定需要留意下;
- 因为我们没有实现注册登录的功能,为了方便,做了自动记住上次昵称的处理,存入 localStorage 中;
- 通过 setInterval 来自动重连;
- 注意用户列表的处理:
that.users.splice(0)
,如果that.users = []
是不行的,这涉及到 Vue 怎么监听数据的问题; - WebSocket 有两个方法:send 和 close,一个用来发送消息,一个用于主动断开链接;
- WebSocket 有一个属性 readyState 可以判定当前连接的状态;
4.5.2 后端流程关键代码
后端关键流程和本章第 1 节的关键流程是类似的。(为了方便,我们给涉及到的几个 goroutine 进行命名:运行 WebSocketHandleFunc 的 goroutine 叫 conn goroutine,也可以称为 read goroutine;给用户发送消息的 goroutine 叫 write goroutine;广播器所在 goroutine 叫 broadcaster goroutine)。
// server/websocket.go
func WebSocketHandleFunc(w http.ResponseWriter, req *http.Request) {
// Accept 从客户端接收 WebSocket 握手,并将连接升级到 WebSocket。
// 如果 Origin 域与主机不同,Accept 将拒绝握手,除非设置了 InsecureSkipVerify 选项(通过第三个参数 AcceptOptions 设置)。
// 换句话说,默认情况下,它不允许跨源请求。如果发生错误,Accept 将始终写入适当的响应
conn, err := websocket.Accept(w, req, nil)
if err != nil {
log.Println("websocket accept error:", err)
return
}
// 1. 新用户进来,构建该用户的实例
nickname := req.FormValue("nickname")
if l := len(nickname); l < 2 || l > 20 {
log.Println("nickname illegal: ", nickname)
wsjson.Write(req.Context(), conn, logic.NewErrorMessage("非法昵称,昵称长度:4-20"))
conn.Close(websocket.StatusUnsupportedData, "nickname illegal!")
return
}
if !logic.Broadcaster.CanEnterRoom(nickname) {
log.Println("昵称已经存在:", nickname)
wsjson.Write(req.Context(), conn, logic.NewErrorMessage("该昵称已经已存在!"))
conn.Close(websocket.StatusUnsupportedData, "nickname exists!")
return
}
user := logic.NewUser(conn, nickname, req.RemoteAddr)
// 2. 开启给用户发送消息的 goroutine
go user.SendMessage(req.Context())
// 3. 给当前用户发送欢迎信息
user.MessageChannel <- logic.NewWelcomeMessage(nickname)
// 给所有用户告知新用户到来
msg := logic.NewNoticeMessage(nickname + " 加入了聊天室")
logic.Broadcaster.Broadcast(msg)
// 4. 将该用户加入广播器的用户列表中
logic.Broadcaster.UserEntering(user)
log.Println("user:", nickname, "joins chat")
// 5. 接收用户消息
err = user.ReceiveMessage(req.Context())
// 6. 用户离开
logic.Broadcaster.UserLeaving(user)
msg = logic.NewNoticeMessage(user.NickName + " 离开了聊天室")
logic.Broadcaster.Broadcast(msg)
log.Println("user:", nickname, "leaves chat")
// 根据读取时的错误执行不同的 Close
if err == nil {
conn.Close(websocket.StatusNormalClosure, "")
} else {
log.Println("read from client error:", err)
conn.Close(websocket.StatusInternalError, "Read from client error")
}
}
根据注释,我们就关键流程步骤一一讲解。
1、新用户进来,创建一个代表该用户的 User 实例
该聊天室没有实现注册登录功能,为了方便识别谁是谁,我们简单要求输入昵称。昵称在建立 WebSocket 连接时,通过 HTTP 协议传递,因此可以通过 http.Request 获取到,即:req.FormValue("nickname")
。虽然没有注册功能,但依然要解决昵称重复的问题。这里必须引出 Broadcaster 了。
广播器 broadcaster
聊天室,顾名思义,消息要进行广播。broadcaster 就是一个广播器,负责将用户发送的消息广播给聊天室里的其他人。先看看广播器的定义。
// logic/broadcast.go
// broadcaster 广播器
type broadcaster struct {
// 所有聊天室用户
users map[string]*User
// 所有 channel 统一管理,可以避免外部乱用
enteringChannel chan *User
leavingChannel chan *User
messageChannel chan *Message
// 判断该昵称用户是否可进入聊天室(重复与否):true 能,false 不能
checkUserChannel chan string
checkUserCanInChannel chan bool
}
这里使用了“单例模式”,在 broadcat.go 中实例化一个广播器实例:Broadcaster,方便外部使用。
因为 Broadcaster.Broadcast() 在一个单独的 goroutine 中运行,按照 Go 语言的原则,应该通过通信来共享内存。因此,我们定义了 5 个 channel,用于和其他 goroutine 进行通信。
- enteringChannel:用户进入聊天室时,通过该 channel 告知 Broadcaster,即将该用户加入 Broadcaster 的 users 中;
- leavingChannel:用户离开聊天室时,通过该 channel 告知 Broadcaster,即将该用户从 Broadcaster 的 users 中删除,同时需要关闭该用户对应的 messageChannel,避免 goroutine 泄露,后文会讲到;
- messageChannel:用户发送的消息,通过该 channel 告知 Broadcaster,之后 Broadcaster 将它发送给 users 中的用户;
- checkUserChannel:用来接收用户昵称,方便 Broadcaster 所在 goroutine 能够无锁判断昵称是否存在;
- checkUserCanInChannel:用来回传该用户昵称是否已经存在;
判断用户是否存在时,利用了上面提到的两个 channel,看看具体的实现:
func (b *broadcaster) CanEnterRoom(nickname string) bool {
b.checkUserChannel <- nickname
return <-b.checkUserCanInChannel
}
如上图所示,两个 goroutine 通过两个 channel 进行通讯,因为 conn goroutine(代表用户连接 goroutine)可能很多,通过这种方式,避免了使用锁。
虽然没有显示使用锁,但这里要求 checkUserChannel 必须是无缓冲的,否则判断可能会出错。
如果用户已存在,连接会断开;否则创建该用户的实例:
user := logic.NewUser(conn, nickname, req.RemoteAddr)
这里又引出了 User 类型。
// logic/user.go
type User struct {
UID int `json:"uid"`
NickName string `json:"nickname"`
EnterAt time.Time `json:"enter_at"`
Addr string `json:"addr"`
MessageChannel chan *Message `json:"-"`
conn *websocket.Conn
}
一个 User 代表一个进入了聊天室的用户。
2、开启给用户发送消息的 goroutine
服务一个用户(一个连接),至少需要两个 goroutine:一个读用户发送的消息,一个给用户发送消息。
go user.SendMessage(req.Context())
// logic/user.go
func (u *User) SendMessage(ctx context.Context) {
for msg := range u.MessageChannel {
wsjson.Write(ctx, u.conn, msg)
}
}
当前连接已经在一个新的 goroutine 中了,我们用来做消息读取用,同时新开一个 goroutine 用来给用户发送消息。
具体的消息发送是,通过 for-range 从当前用户的 MessageChannel 中读取消息,然后通过 nhooyr.io/websocket/wsjson
包的 Write 方法发送给浏览器,该库会自动做 JSON 编码。
前文提到过,这里是一个长期运行的 goroutine,存在泄露的风险。当用户退出时,一定要让给 goroutine 退出,退出方法就是关闭 u.MessageChannel 这个 channel。
3、新用户进入,给用户发消息
// 给当前用户发送欢迎信息
user.MessageChannel <- logic.NewWelcomeMessage(nickname)
// 给所有用户告知新用户到来
msg := logic.NewNoticeMessage(nickname + " 加入了聊天室")
logic.Broadcaster.Broadcast(msg)
新用户进入,一方面给 TA 发送欢迎的消息,另一方面需要通知聊天室的其他人,有新用户进来了。
这里又引出了第三个类型:Message。
// 给用户发送的消息
type Message struct {
// 哪个用户发送的消息
User *User `json:"user"`
Type int `json:"type"`
Content string `json:"content"`
MsgTime time.Time `json:"msg_time"`
Users map[string]*User `json:"users"`
}
这里着重需要关注的是 Type 字段,用它来判定消息在客户端如何显示。有如下几种类型的消息:
const (
MsgTypeNormal = iota // 普通 用户消息
MsgTypeSystem // 系统消息
MsgTypeError // 错误消息
MsgTypeUserList // 发送当前用户列表
)
消息一共分成三大类:1)在聊天室窗口显示;2)页面错误提示(比如昵称已存在);3)当前聊天室用户列表。其中,在聊天室窗口显示,又分为用户消息和系统消息。
Message 结构中几个字段的意思就清楚了,特别说明的是,字段 User 代表该消息的属主:普通用户还是系统。所以,特别实例化了一个系统用户:
// 系统用户,代表是系统主动发送的消息
var System = &User{}
它的 UID 是 0。
接下来看看发送消息的过程,发送消息分两情况,它们的处理方式有些差异:
- 给单个用户(当前)用户发送消息
- 给聊天室其他用户广播消息
用两个图来来表示这两种情况。
给当前用户发送消息的情况比较简单:conn goroutine 通过用户实例(User)的字段 MessageChannel 将 Message 发送给 write goroutine。
给聊天室其他用户广播消息自然需要通过 broadcaster goroutine 来实现:conn goroutine 通过 Broadcaster 的 MessageChannel 将 Message 发送出去,broadcaster goroutine 遍历自己维护的聊天室用户列表,通过 User 实例的 MessageChannel 将消息发送给 write goroutine。
提示:细心的读者可能会想到 broadcaster 这里可能会成为瓶颈,用户量大时,可能会有消息挤压,这一点后续讨论。
4. 将该用户加入广播器的用户列表中
这个过程很简单,一行代码,最终通过 channel 发送到 Broadcaster 中。
logic.Broadcaster.UserEntering(user)
5. 接收用户消息
跟给用户发送消息类似,调用的是 user 的方法:
err = user.ReceiveMessage(req.Context())
该方法的实现如下:
// logic/user.go
func (u *User) ReceiveMessage(ctx context.Context) error {
var (
receiveMsg map[string]string
err error
)
for {
err = wsjson.Read(ctx, u.conn, &receiveMsg)
if err != nil {
// 判定连接是否关闭了,正常关闭,不认为是错误
var closeErr websocket.CloseError
if errors.As(err, &closeErr) {
return nil
}
return err
}
// 内容发送到聊天室
sendMsg := NewMessage(u, receiveMsg["content"])
Broadcaster.Broadcast(sendMsg)
}
}
逻辑较简单,即通过 nhooyr.io/websocket/wsjson
包读取用户输入数据,构造出 Message 实例,广播出去。
这里特别提一下 Go1.13 中 errors 包的新功能,实际项目中可能大家还没有用到。
var closeErr websocket.CloseError
if errors.As(err, &closeErr) {
return nil
}
当用户主动退出聊天室时,wsjson.Read
会返回错,除此之外,可能还有其他原因导致返回错误。这两种情况应该加以区分。这得益于 Go1.13 errors 包的新功能和 nhooyr.io/websocket 包对该新功能的支持,我们可以通过 As 来判定错误是不是连接关闭导致的。
6. 用户离开
用户可以主动或由于其他原因离开聊天室,这时候 user.ReceiveMessage 方法会返回,执行下面的代码:
// 6. 用户离开
logic.Broadcaster.UserLeaving(user)
msg = logic.NewNoticeMessage(user.NickName + " 离开了聊天室")
logic.Broadcaster.Broadcast(msg)
log.Println("user:", nickname, "leaves chat")
// 根据读取时的错误执行不同的 Close
if err == nil {
conn.Close(websocket.StatusNormalClosure, "")
} else {
log.Println("read from client error:", err)
conn.Close(websocket.StatusTryAgainLater, "Read from client error")
}
这里主要做了三件事情:
- 在 Broadcaster 中注销该用户;
- 给聊天室中其他还在线的用户发送通知,告知该用户已离开;
- 根据 err 处理不同的 Close 行为。关于 Close 的 Status 可以参考 rfc6455 的 第 7.4 节;
4.5.3 小结
到这里我们把最核心的流程讲解完了。但我们略过了 broadcaster 中的关键代码,下节我们主要讲解广播器:broadcaster。
本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。