实现聊天室:核心流程

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>&nbsp;
                <input type="button" value="发送(Enter)" class="btn-primary form-control" v-on:click="sendChatContent">
            </div>
        </div>
    </div>
</div>

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

image

根据前面的讲解知道,这是通过 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,点击「进入聊天室」。

image

这个过程涉及到的网络环节前面已经抓包讲解过,这里主要看下前端 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
}

image

如上图所示,两个 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。

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

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

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

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 中。

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 国际进行许可。