4.5 实现聊天室:核心流程

本节我们讲解聊天室的核心流程的实现。

4.5.1 前端关键代码

在项目中的 template/home.html 文件中增加 html 相关代码:(考虑篇幅,只保留主要的 html 部分,完整代码可通过 git clone https://github.com/go-programming-tour-book/chatroom 获取)

  1. <div class="container" id="app">
  2. <div class="row">
  3. <div class="col-md-12">
  4. <div class="page-header">
  5. <h2 class="text-center"> 欢迎来到《Go 语言编程之旅:一起用 Go 做项目》聊天室 </h2>
  6. </div>
  7. </div>
  8. </div>
  9. <div class="row">
  10. <div class="col-md-1"></div>
  11. <div class="col-md-6">
  12. <div> 聊天内容 </div>
  13. <div class="msg-list" id="msg-list">
  14. <div class="message"
  15. v-for="msg in msglist"
  16. v-bind:class="{ system: msg.type==1, myself: msg.user.nickname==curUser.nickname }"
  17. >
  18. <div class="meta" v-if="msg.user.nickname"><span class="author">${ msg.user.nickname }</span> at ${ formatDate(msg.msg_time) }</div>
  19. <div>
  20. <span class="content" style="white-space: pre-wrap;">${ msg.content }</span>
  21. </div>
  22. </div>
  23. </div>
  24. </div>
  25. <div class="col-md-4">
  26. <div> 当前在线用户数:<font color="red">${ onlineUserNum }</font></div>
  27. <div class="user-list">
  28. <div class="user" v-for="user in users">
  29. 用户:@${ user.nickname } 加入时间:${ formatDate(user.enter_at) }
  30. </div>
  31. </div>
  32. </div>
  33. </div>
  34. <div class="row">
  35. <div class="col-md-1"></div>
  36. <div class="col-md-10">
  37. <div class="user-input">
  38. <div class="usertip text-center">${ usertip }</div>
  39. <div class="form-inline has-success text-center" style="margin-bottom: 10px;">
  40. <div class="input-group">
  41. <span class="input-group-addon"> 您的昵称 </span>
  42. <input type="text" v-model="curUser.nickname" v-bind:disabled="joined" class="form-control" aria-describedby="inputGroupSuccess1Status">
  43. </div>
  44. <input type="submit" class="form-control btn-primary text-center" v-on:click="leavechat" v-if="joined" value="离开聊天室">
  45. <input type="submit" class="form-control btn-primary text-center" v-on:click="joinchat" v-else="joined" value="进入聊天室">
  46. </div>
  47. <textarea id="chat-content" rows="3" class="form-control" v-model="content"
  48. @keydown.enter.prevent.exact="sendChatContent"
  49. @keydown.meta.enter="lineFeed"
  50. @keydown.ctrl.enter="lineFeed"
  51. placeholder="在此收入聊天内容。ctrl/command+enter 换行,enter 发送"></textarea>&nbsp;
  52. <input type="button" value="发送(Enter)" class="btn-primary form-control" v-on:click="sendChatContent">
  53. </div>
  54. </div>
  55. </div>
  56. </div>

之后打开终端,启动聊天室。打开浏览器访问 localhost:2022,出现如下界面:

image

根据前面的讲解知道,这是通过 HTTP 请求了 / 这个路由,对应到如下 handle 的代码:

  1. // server/home.go
  2. func homeHandleFunc(w http.ResponseWriter, req *http.Request) {
  3. tpl, err := template.ParseFiles(rootDir + "/template/home.html")
  4. if err != nil {
  5. fmt.Fprint(w, "模板解析错误!")
  6. return
  7. }
  8. err = tpl.Execute(w, nil)
  9. if err != nil {
  10. fmt.Fprint(w, "模板执行错误!")
  11. return
  12. }
  13. }

代码只是简单的渲染页面。

小提示:因为模板中不涉及到任何服务端渲染,所以,在部署时,如果使用 Nginx 这样的 WebServer,完全可以直接将 index 指向 home.html,而不经过 Go 渲染。

我们的前端使用了 Vue,如果你对 Vue 完全不了解,建议你可以到 Vue 的官网学习一下,它是国人开发的,中文文档很友好。

在看到的页面中,在「您的昵称」处输入:polaris,点击「进入聊天室」。

image

这个过程涉及到的网络环节前面已经抓包讲解过,这里主要看下前端 JS 部分的实现。

  1. // 只保留了 WebSocket 相关的核心代码
  2. if ("WebSocket" in window) {
  3. let host = location.host;
  4. // 打开一个 websocket 连接
  5. gWS = new WebSocket("ws://"+host+"/ws?nickname="+this.nickname);
  6. gWS.onopen = function () {
  7. // WebSocket 已连接上的回调
  8. };
  9. gWS.onmessage = function (evt) {
  10. let data = JSON.parse(evt.data);
  11. if (data.type == 2) {
  12. that.usertip = data.content;
  13. that.joined = false;
  14. } else if (data.type == 3) {
  15. // 用户列表
  16. that.users.splice(0);
  17. for (let nickname in data.users) {
  18. that.users.push(data.users[nickname]);
  19. }
  20. } else {
  21. that.addMsg2List(data);
  22. }
  23. };
  24. gWS.onerror = function(evt) {
  25. console.log("发生错误:");
  26. console.log(evt);
  27. };
  28. gWS.onclose = function () {
  29. console.log("连接已关闭...");
  30. };
  31. } else {
  32. alert("您的浏览器不支持 WebSocket!");
  33. }

前端 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)。

  1. // server/websocket.go
  2. func WebSocketHandleFunc(w http.ResponseWriter, req *http.Request) {
  3. // Accept 从客户端接收 WebSocket 握手,并将连接升级到 WebSocket。
  4. // 如果 Origin 域与主机不同,Accept 将拒绝握手,除非设置了 InsecureSkipVerify 选项(通过第三个参数 AcceptOptions 设置)。
  5. // 换句话说,默认情况下,它不允许跨源请求。如果发生错误,Accept 将始终写入适当的响应
  6. conn, err := websocket.Accept(w, req, nil)
  7. if err != nil {
  8. log.Println("websocket accept error:", err)
  9. return
  10. }
  11. // 1. 新用户进来,构建该用户的实例
  12. nickname := req.FormValue("nickname")
  13. if l := len(nickname); l < 2 || l > 20 {
  14. log.Println("nickname illegal: ", nickname)
  15. wsjson.Write(req.Context(), conn, logic.NewErrorMessage("非法昵称,昵称长度:4-20"))
  16. conn.Close(websocket.StatusUnsupportedData, "nickname illegal!")
  17. return
  18. }
  19. if !logic.Broadcaster.CanEnterRoom(nickname) {
  20. log.Println("昵称已经存在:", nickname)
  21. wsjson.Write(req.Context(), conn, logic.NewErrorMessage("该昵称已经已存在!"))
  22. conn.Close(websocket.StatusUnsupportedData, "nickname exists!")
  23. return
  24. }
  25. user := logic.NewUser(conn, nickname, req.RemoteAddr)
  26. // 2. 开启给用户发送消息的 goroutine
  27. go user.SendMessage(req.Context())
  28. // 3. 给当前用户发送欢迎信息
  29. user.MessageChannel <- logic.NewWelcomeMessage(nickname)
  30. // 给所有用户告知新用户到来
  31. msg := logic.NewNoticeMessage(nickname + " 加入了聊天室")
  32. logic.Broadcaster.Broadcast(msg)
  33. // 4. 将该用户加入广播器的用户列表中
  34. logic.Broadcaster.UserEntering(user)
  35. log.Println("user:", nickname, "joins chat")
  36. // 5. 接收用户消息
  37. err = user.ReceiveMessage(req.Context())
  38. // 6. 用户离开
  39. logic.Broadcaster.UserLeaving(user)
  40. msg = logic.NewNoticeMessage(user.NickName + " 离开了聊天室")
  41. logic.Broadcaster.Broadcast(msg)
  42. log.Println("user:", nickname, "leaves chat")
  43. // 根据读取时的错误执行不同的 Close
  44. if err == nil {
  45. conn.Close(websocket.StatusNormalClosure, "")
  46. } else {
  47. log.Println("read from client error:", err)
  48. conn.Close(websocket.StatusInternalError, "Read from client error")
  49. }
  50. }

根据注释,我们就关键流程步骤一一讲解。

1、新用户进来,创建一个代表该用户的 User 实例

该聊天室没有实现注册登录功能,为了方便识别谁是谁,我们简单要求输入昵称。昵称在建立 WebSocket 连接时,通过 HTTP 协议传递,因此可以通过 http.Request 获取到,即:req.FormValue("nickname")。虽然没有注册功能,但依然要解决昵称重复的问题。这里必须引出 Broadcaster 了。

广播器 broadcaster

聊天室,顾名思义,消息要进行广播。broadcaster 就是一个广播器,负责将用户发送的消息广播给聊天室里的其他人。先看看广播器的定义。

  1. // logic/broadcast.go
  2. // broadcaster 广播器
  3. type broadcaster struct {
  4. // 所有聊天室用户
  5. users map[string]*User
  6. // 所有 channel 统一管理,可以避免外部乱用
  7. enteringChannel chan *User
  8. leavingChannel chan *User
  9. messageChannel chan *Message
  10. // 判断该昵称用户是否可进入聊天室(重复与否):true 能,false 不能
  11. checkUserChannel chan string
  12. checkUserCanInChannel chan bool
  13. }

这里使用了“单例模式”,在 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,看看具体的实现:

  1. func (b *broadcaster) CanEnterRoom(nickname string) bool {
  2. b.checkUserChannel <- nickname
  3. return <-b.checkUserCanInChannel
  4. }

image

如上图所示,两个 goroutine 通过两个 channel 进行通讯,因为 conn goroutine(代表用户连接 goroutine)可能很多,通过这种方式,避免了使用锁。

虽然没有显示使用锁,但这里要求 checkUserChannel 必须是无缓冲的,否则判断可能会出错。

如果用户已存在,连接会断开;否则创建该用户的实例:

  1. user := logic.NewUser(conn, nickname, req.RemoteAddr)

这里又引出了 User 类型。

  1. // logic/user.go
  2. type User struct {
  3. UID int `json:"uid"`
  4. NickName string `json:"nickname"`
  5. EnterAt time.Time `json:"enter_at"`
  6. Addr string `json:"addr"`
  7. MessageChannel chan *Message `json:"-"`
  8. conn *websocket.Conn
  9. }

一个 User 代表一个进入了聊天室的用户。

2、开启给用户发送消息的 goroutine

服务一个用户(一个连接),至少需要两个 goroutine:一个读用户发送的消息,一个给用户发送消息。

  1. go user.SendMessage(req.Context())
  2. // logic/user.go
  3. func (u *User) SendMessage(ctx context.Context) {
  4. for msg := range u.MessageChannel {
  5. wsjson.Write(ctx, u.conn, msg)
  6. }
  7. }

当前连接已经在一个新的 goroutine 中了,我们用来做消息读取用,同时新开一个 goroutine 用来给用户发送消息。

具体的消息发送是,通过 for-range 从当前用户的 MessageChannel 中读取消息,然后通过 nhooyr.io/websocket/wsjson 包的 Write 方法发送给浏览器,该库会自动做 JSON 编码。

前文提到过,这里是一个长期运行的 goroutine,存在泄露的风险。当用户退出时,一定要让给 goroutine 退出,退出方法就是关闭 u.MessageChannel 这个 channel。

3、新用户进入,给用户发消息

  1. // 给当前用户发送欢迎信息
  2. user.MessageChannel <- logic.NewWelcomeMessage(nickname)
  3. // 给所有用户告知新用户到来
  4. msg := logic.NewNoticeMessage(nickname + " 加入了聊天室")
  5. logic.Broadcaster.Broadcast(msg)

新用户进入,一方面给 TA 发送欢迎的消息,另一方面需要通知聊天室的其他人,有新用户进来了。

这里又引出了第三个类型:Message。

  1. // 给用户发送的消息
  2. type Message struct {
  3. // 哪个用户发送的消息
  4. User *User `json:"user"`
  5. Type int `json:"type"`
  6. Content string `json:"content"`
  7. MsgTime time.Time `json:"msg_time"`
  8. Users map[string]*User `json:"users"`
  9. }

这里着重需要关注的是 Type 字段,用它来判定消息在客户端如何显示。有如下几种类型的消息:

  1. const (
  2. MsgTypeNormal = iota // 普通 用户消息
  3. MsgTypeSystem // 系统消息
  4. MsgTypeError // 错误消息
  5. MsgTypeUserList // 发送当前用户列表
  6. )

消息一共分成三大类:1)在聊天室窗口显示;2)页面错误提示(比如昵称已存在);3)当前聊天室用户列表。其中,在聊天室窗口显示,又分为用户消息和系统消息。

Message 结构中几个字段的意思就清楚了,特别说明的是,字段 User 代表该消息的属主:普通用户还是系统。所以,特别实例化了一个系统用户:

  1. // 系统用户,代表是系统主动发送的消息
  2. var System = &User{}

它的 UID 是 0。

接下来看看发送消息的过程,发送消息分两情况,它们的处理方式有些差异:

  • 给单个用户(当前)用户发送消息
  • 给聊天室其他用户广播消息

用两个图来来表示这两种情况。

image

给当前用户发送消息的情况比较简单:conn goroutine 通过用户实例(User)的字段 MessageChannel 将 Message 发送给 write goroutine。

image

给聊天室其他用户广播消息自然需要通过 broadcaster goroutine 来实现:conn goroutine 通过 Broadcaster 的 MessageChannel 将 Message 发送出去,broadcaster goroutine 遍历自己维护的聊天室用户列表,通过 User 实例的 MessageChannel 将消息发送给 write goroutine。

提示:细心的读者可能会想到 broadcaster 这里可能会成为瓶颈,用户量大时,可能会有消息挤压,这一点后续讨论。

4. 将该用户加入广播器的用户列表中

这个过程很简单,一行代码,最终通过 channel 发送到 Broadcaster 中。

  1. logic.Broadcaster.UserEntering(user)

5. 接收用户消息

跟给用户发送消息类似,调用的是 user 的方法:

  1. err = user.ReceiveMessage(req.Context())

该方法的实现如下:

  1. // logic/user.go
  2. func (u *User) ReceiveMessage(ctx context.Context) error {
  3. var (
  4. receiveMsg map[string]string
  5. err error
  6. )
  7. for {
  8. err = wsjson.Read(ctx, u.conn, &receiveMsg)
  9. if err != nil {
  10. // 判定连接是否关闭了,正常关闭,不认为是错误
  11. var closeErr websocket.CloseError
  12. if errors.As(err, &closeErr) {
  13. return nil
  14. }
  15. return err
  16. }
  17. // 内容发送到聊天室
  18. sendMsg := NewMessage(u, receiveMsg["content"])
  19. Broadcaster.Broadcast(sendMsg)
  20. }
  21. }

逻辑较简单,即通过 nhooyr.io/websocket/wsjson 包读取用户输入数据,构造出 Message 实例,广播出去。

这里特别提一下 Go1.13 中 errors 包的新功能,实际项目中可能大家还没有用到。

  1. var closeErr websocket.CloseError
  2. if errors.As(err, &closeErr) {
  3. return nil
  4. }

当用户主动退出聊天室时,wsjson.Read 会返回错,除此之外,可能还有其他原因导致返回错误。这两种情况应该加以区分。这得益于 Go1.13 errors 包的新功能和 nhooyr.io/websocket 包对该新功能的支持,我们可以通过 As 来判定错误是不是连接关闭导致的。

6. 用户离开

用户可以主动或由于其他原因离开聊天室,这时候 user.ReceiveMessage 方法会返回,执行下面的代码:

  1. // 6. 用户离开
  2. logic.Broadcaster.UserLeaving(user)
  3. msg = logic.NewNoticeMessage(user.NickName + " 离开了聊天室")
  4. logic.Broadcaster.Broadcast(msg)
  5. log.Println("user:", nickname, "leaves chat")
  6. // 根据读取时的错误执行不同的 Close
  7. if err == nil {
  8. conn.Close(websocket.StatusNormalClosure, "")
  9. } else {
  10. log.Println("read from client error:", err)
  11. conn.Close(websocket.StatusTryAgainLater, "Read from client error")
  12. }

这里主要做了三件事情:

  • 在 Broadcaster 中注销该用户;
  • 给聊天室中其他还在线的用户发送通知,告知该用户已离开;
  • 根据 err 处理不同的 Close 行为。关于 Close 的 Status 可以参考 rfc6455 的 第 7.4 节;

4.5.3 小结

到这里我们把最核心的流程讲解完了。但我们略过了 broadcaster 中的关键代码,下节我们主要讲解广播器:broadcaster。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

4.5 实现聊天室:核心流程 - 图6