原文地址:https://www.oschina.net/translate/build-a-realtime-chat-server-with-go-and-websockets
现代网页应用程序正日趋丰富而复杂。像这样有趣又有活力的体验很受用户欢迎。用户无需向服务器发起调用,或刷新浏览器,就可以让页面实时更新。早期的开发者依赖 AJAX 来创建具备近乎实时体验的应用程序。而现在,他们运用 WebSockets 就能创建完全实时的应用程序了。
本教程中我们将使用 Go 编程语言以及 WebSockets 来创建一个实时的聊天应用程序。前端将会使用 HTML5 和 VueJS 来编写。该内容需要你对 Go 语言, JavaScript 以及 HTML5 有一个基础的了解,最好有一点点使用 VueJS 的经验。
如需使用 Go,你可以看看 Go 的官方网站上优秀的交互式教程:
https://tour.golang.org/welcome/1
如需使用 Vue,你可以看看 Jeffrey Way 在 Laracasts 上提供的优秀的系列视频教程:
https://laracasts.com/series/learn-vue-2-step-by-step
通常 Web 应用使用一个或多个请求对 HTTP 服务器提供对外服务。客户端软件通常是 Web 浏览器向服务器发送请求,服务器发回一个响应。响应通常是 HTML 内容,由浏览器来渲染为页面。样式表,JavaScript 代码和图像也可以在响应中发送回来以完成整个网页。每个请求和响应都属于特定的单独的连接的一部分,像 Facebook 这样的大型网站为了渲染单个页面实际上可以产生数百个这样的连接。
AJAX 的工作方式跟这个完全相同。使用 JavaScript,开发人员可以向 HTTP 服务器请求一小段信息,然后根据响应更新部分页面。这可以在不刷新浏览器的情况下完成,但仍然存在一些限制。
每个 HTTP 请求/响应的连接在被响应之后都会关闭,因此获得任何新的信息必须新建另一个连接。如果没有新的请求发送给服务器,它就不知道客户端正在查找新的信息。能让 AJAX 应用程序看起来像实时的一种技术是定时循环发送 AJAX 请求。在设置了时间间隔之后,应用程序可以重新将请求发送到服务器,以查看是否有任何更新需要反馈给浏览器。这比较适合小型应用程序,但并不高效。这时候 WebSockets 就派上用场了。
WebSockets 是由 Internet 工程任务组(IETF)创建的建议标准的一部分。 RFC6455 中详细描述了 WebSockets 实现的完整技术规范。下面是该文档定义 WebSocket 的节选:
WebSocket 协议用于客户端代码和远程主机之间进行通信,其中客户端代码是在可控环境下的非授信代码
换句话说,WebSocket 是一个总是打开的连接,允许客户端和服务器自发地来回发送消息。服务器可在必要时将新信息推送到客户端,客户端也可以对服务器执行相同操作。
大多数现代浏览器都在其 JavaScript 实现中支持 WebSockets。要从浏览器中启动一个 WebSocket 连接,你可以使用简单的 WebSocket JavaScript 对象,如下:
var ws = new Websocket("ws://example.com/ws");
您唯一需要的参数是一个 URL,WebSocket 连接可通过此 URL 连接服务器。该请求实际是一个 HTTP 请求,但为了安全连接我们使用“ws://”或“wss://”。这使服务器知道我们正在尝试创建一个新的 WebSocket 连接。之后服务器将“升级”该客户端和服务之间的连接到永久的双向连接。
一旦新的 WebSocket 对象被创建,并且连接成功创建之后,我们就可以使用“send()”方法发送文本到服务器,并在 WebSocket 的“onmessage”属性上定义一个处理函数来处理从服务器发送的消息。具体逻辑会在之后的聊天应用程序代码中解释。
WebSockets 并不包含在 Go 标准库中,但幸运的是有一些不错的第三方包让 WebSockets 的使用轻而易举。在这个例子中,我们将使用一个名为“gorilla/websocket”的包,它是流行的 Gorilla Toolkit 包集合的一部分,多用于在 Go 中创建 Web 应用程序。请运行以下命令进行安装:
$ go get github.com/gorilla/websocket
这个应用程序的第一部分是服务器。这是一个处理请求的简单 HTTP 服务器。它将为我们提供 HTML5 和 JavaScript 代码,以及建立客户端的 WebSocket 连接。另外,服务器还将跟踪每个 WebSocket 连接并通过 WebSocket 连接将聊天信息从一个客户端发送到所有其他客户端。首先创建一个新的空目录,然后在该目录中创建一个“src”和“public”目录。在“src”目录中创建一个名为“main.go”的文件。
搭建服务器首先要进行一些设置。我们像所有 Go 应用程序一样启动应用程序,并定义包命名空间,在本例中为“main”。接下来我们导入一些有用的包。 “log”和“net/http”都是标准库的一部分,将用于日志记录并创建一个简单的 HTTP 服务器。最终包“github.com/gorilla/websocket”将帮助我们轻松创建和使用 WebSocket 连接。
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket")
下面的两行代码是一些全局变量,在应用程序的其它地方会被用到。全局变量的实践较差,不过这次为了简单起见我们还是使用了它们。第一个变量是一个 map 映射,其键对应是一个指向 WebSocket 的指针,其值就是一个布尔值。我们实际上并不需要这个值,但使用的映射数据结构需要有一个映射值,这样做更容易添加和删除单项。
第二个变量是一个用于由客户端发送消息的队列,扮演通道的角色。在后面的代码中,我们会定义一个 goroutine 来从这个通道读取新消息,然后将它们发送给其它连接到服务器的客户端。
var clients = make(map[*websocket.Conn]bool) // connected clients
var broadcast = make(chan Message) // broadcast channel
接下来我们创建一个 upgrader 的实例。这只是一个对象,它具备一些方法,这些方法可以获取一个普通 HTTP 链接然后将其升级成一个 WebSocket,稍后会有相关代码介绍。
// Configure the upgrader
var upgrader = websocket.Upgrader{}
最后我们将定义一个对象来管理消息,数据结构比较简单,带有一些字符串属性,一个 email 地址,一个用户名以及实际的消息内容。我们将利用 email 来展示 Gravatar 服务所提供的唯一身份标识。
由反引号包含的文本是 Go 在对象和 JSON 之间进行序列化和反序列化时需要的元数据。
// Define our message object
type Message struct {
Email string `json:"email"`
Username string `json:"username"`
Message string `json:"message"`
}
Go 应用程序的主要入口总是 "main()" 函数。代码非常简洁。我们首先创建一个静态的文件服务,并将之与 "/" 路由绑定,这样用户访问网站时就能看到 index.html 和其它资源。在这个示例中我们有一个保存 JavaScript 代码的 "app.js" 文件和一个保存样式的 "style.css" 文件。
func main() {
// Create a simple file server
fs := http.FileServer(http.Dir("../public"))
http.Handle("/", fs)
我们想定义的下一个路由是 "/ws",在这里处理启动 WebSocket 的请求。我们先向处理函数传递一个函数的名称,"handleConnections",稍后再来定义这个函数。
func main() {
...
// Configure websocket route
http.HandleFunc("/ws", handleConnections)
下一步就是启动一个叫 "handleMessages" 的 Go 程序。这是一个并行过程,独立于应用和其它部分运行,从广播频道中取得消息并通过各客户端的 WebSocket 连接传递出去。并行是 Go 中一项强大的特性。关于它如何工作的内容超出了这篇文章的范围,不过你可以自行查看 Go 的官方教程网站。如果你熟悉 JavaScript,可联想一下并行过程,作为后台过程运行的 Go 程序,或 JavaScript 的异步函数。
func main() {
...
// Start listening for incoming chat messages
go handleMessages()
最后,我们向控制台打印一个辅助信息并启动 Web 服务。如果有错误发生,我们就把它记录下来然后退出应用程序。
func main() {
...
// Start the server on localhost port 8000 and log any errors
log.Println("http server started on :8000")
err := http.ListenAndServe(":8000", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
接下来我们创建一个函数处理传入的 WebSocket 连接。首先我们使用升级的 "Upgrade()" 方法改变初始的 GET 请求,使之成为完全的 WebSocket。如果发生错误,记录下来,但不退出。同时注意 defer 语句,它通知 Go 在函数返回的时候关闭 WebSocket。这是个不错的方法,它为我们节省了不少可能出现在不同分支中返回函数前的 "Close()" 语句。
func handleConnections(w http.ResponseWriter, r *http.Request) {
// Upgrade initial GET request to a websocket
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Fatal(err)
}
// Make sure we close the connection when the function returns
defer ws.Close()
接下来把新的客户端添加到全局的 "clients" 映射表中进行注册,这个映射表在早先已经创建了。
func handleConnections(w http.ResponseWriter, r *http.Request) {
...
// Register our new client
clients[ws] = true
最后一步是一个无限循环,它一直等待着要写入 WebSocket 的新消息,将其从 JSON 反序列化为 Message 对象然后送入广播频道。然而 "handleMessages()" Go 程序就能把它送给连接中的其它客户端。
如果从 socket 中读取数据有误,我们假设客户端已经因为某种原因断开。我们记录错误并从全局的 “clients” 映射表里删除该客户端,这样一来,我们不会继续尝试与其通信。
另外,HTTP 路由处理函数已经被作为 goroutines 运行。这使得 HTTP 服务器无需等待另一个连接完成,就能处理多个传入连接。
func handleConnections(w http.ResponseWriter, r *http.Request) {
...
for {
var msg Message // Read in a new message as JSON and map it to a Message object
err := ws.ReadJSON(&msg)
if err != nil {
log.Printf("error: %v", err)
delete(clients, ws)
break
}
// Send the newly received message to the broadcast channel
broadcast <- msg }
}
服务器的最后一部分是 "handleMessages()"函数。这是一个简单循环,从“broadcast”中连续读取数据,然后通过各自的 WebSocket 连接将消息传播到所以客户端。同样,如果写入 Websocket 时出现错误,我们将关闭连接,并将其从 “clients” 映射中删除。
func handleMessages() {
for {
// Grab the next message from the broadcast channel
msg := <-broadcast
// Send it out to every client that is currently connected
for client := range clients {
err := client.WriteJSON(msg)
if err != nil {
log.Printf("error: %v", err)
client.Close()
delete(clients, client)
}
}
}
}