Skip to content

Latest commit

 

History

History
1002 lines (720 loc) · 50.1 KB

File metadata and controls

1002 lines (720 loc) · 50.1 KB

一、使用 Web 套接字的聊天应用

Go非常适合编写高性能并发服务器应用程序和工具,而 Web 是交付这些应用程序和工具的完美媒介。现在很难找到一个不支持 web 的小工具,它允许我们构建一个针对几乎所有平台和设备的单一应用程序。

我们的第一个项目将是一个基于网络的聊天应用程序,它允许多个用户在他们的网络浏览器中进行实时对话。惯用的 Go 应用程序通常由许多包组成,这些包通过在不同的文件夹中放置代码来组织,Go 标准库也是如此。我们将首先使用net/http包构建一个简单的 web 服务器,它将为 HTML 文件提供服务。然后,我们将继续添加对 web 套接字的支持,我们的消息将通过这些套接字传递。

在 C#、Java 或 Node.js 等语言中,需要使用复杂的线程代码和巧妙地使用锁,以保持所有客户端的同步。正如我们将看到的,Go 通过其内置的通道和并发范例极大地帮助了我们。

在本章中,您将学习如何:

  • 使用net/http包为 HTTP 请求提供服务
  • 向用户的浏览器提供模板驱动的内容
  • 满足 Go 接口以构建我们自己的http.Handler类型
  • 使用 Go 的 goroutines 允许应用程序同时执行多个任务
  • 使用通道在运行 Go 例程之间共享信息
  • 升级 HTTP 请求以使用 web 套接字等现代功能
  • 向应用程序添加跟踪以更好地了解其内部工作
  • 使用测试驱动的开发实践编写一个完整的 Go 包
  • 通过导出的接口返回未导出的类型

本项目完整的源代码可在中找到 https://github.com/matryer/goblueprints/tree/master/chapter1/chat 。源代码定期提交,因此 GitHub 中的历史实际上也遵循本章的流程。

一个简单的 web 服务器

我们的聊天应用程序需要的第一件事是 web 服务器,它有两个主要职责:它必须服务于在用户浏览器中运行的 HTML 和 JavaScript 聊天客户端,并接受 web 套接字连接,以允许客户端进行通信。

GOPATH环境变量在附录稳定 Go 环境的良好实践中有详细介绍。如果你需要帮助设置,一定要先阅读。

GOPATH中名为chat的新文件夹中创建一个main.go文件,并添加以下代码:

package main

import (
  "log"
  "net/http"
)

func main() {

  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`
      <html>
        <head>
          <title>Chat</title>
        </head>
        <body>
          Let's chat!
        </body>
      </html>
    `))
  })
  // start the web server
  if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

这是一个完整但简单的围棋程序,将:

  • 使用net/http包收听根路径
  • 提出请求时写出硬编码的 HTML
  • 使用ListenAndServe方法在端口:8080上启动 web 服务器

http.HandleFunc函数将路径模式"/"映射到我们作为第二个参数传递的函数,因此当用户点击http://localhost:8080/时,该函数将被执行。func(w http.ResponseWriter, r *http.Request)的函数签名是整个 Go 标准库中处理 HTTP 请求的常用方法。

提示

我们使用package main是因为我们想从命令行构建和运行我们的程序。然而,如果我们正在构建一个可重用的聊天包,我们可能会选择使用不同的东西,比如package chat

在终端中,导航到刚才创建并执行的main.go文件,运行程序:

go run main.go

打开localhost:8080浏览器查看**聊天!**消息。

让 HTML 代码像这样嵌入到我们的 Go 代码中是可行的,但这相当难看,而且随着我们的项目的增长,情况只会变得更糟。接下来,我们将看到模板如何帮助我们清理这些内容。

模板

模板允许我们将通用文本与特定文本混合,例如,将用户名注入欢迎消息中。例如,考虑以下模板:

Hello {name}, how are you?

我们可以将前面模板中的{name}文本替换为一个人的真实姓名。因此,如果劳丽登录,她可能会看到:

Hello Laurie, how are you?

Go 标准库有两个主要的模板包:一个称为text/template用于文本,另一个称为html/template用于 HTML。html/template包与文本版本的功能相同,只是它理解将数据注入模板的上下文。这非常有用,因为它避免了脚本注入攻击,并解决了常见问题,例如必须为 URL 编码特殊字符。

最初,我们只想将 Go 代码中的 HTML 代码移动到它自己的文件中,但目前还不会混合任何文本。模板包使得加载外部文件非常容易,因此它是我们的一个好选择。

在我们的chat文件夹下创建一个名为templates的新文件夹,并在其中创建一个chat.html文件。我们将把 HTML 从main.go移到此文件,但我们将做一个小的更改,以确保我们的更改生效。

<html>
  <head>
    <title>Chat</title>
  </head>
  <body>
    Let's chat (from template)
  </body>
</html>

现在,我们已经准备好了外部 HTML 文件,但是我们需要一种方法来编译模板并将其提供给用户的浏览器。

提示

编译模板是一个解释源模板并准备与各种数据混合的过程,必须在使用模板之前进行,但只需进行一次。

我们将编写自己的struct类型,负责加载、编译和交付模板。我们将定义一个新类型,它将接受一个filename字符串,编译模板一次(使用sync.Once类型),保留对已编译模板的引用,然后响应 HTTP 请求。您需要导入text/templatepath/filepathsync包来构建代码。

main.go中,在func main()行上方插入以下代码:

// templ represents a single template
type templateHandler struct {
  once     sync.Once
  filename string
  templ    *template.Template
}
// ServeHTTP handles the HTTP request.
func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  t.once.Do(func() {
    t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
  })
  t.templ.Execute(w, nil)
}

提示

您知道可以自动添加和删除导入的包吗?参见附录稳定围棋环境的良好实践了解如何做到这一点。

templateHandler类型有一个名为ServeHTTP的方法,其签名与我们之前传递给http.HandleFunc的方法相似。此方法将加载源文件,编译模板并执行它,并将输出写入指定的http.ResponseWriter对象。由于ServeHTTP方法满足http.Handler接口,我们实际上可以直接将其传递给http.Handle

提示

快速查看 Go 标准库源代码,位于http://golang.org/pkg/net/http/#Handler 将揭示http.Handler的接口定义规定,只有ServeHTTP方法需要存在,才能使用类型来服务net/http包的 HTTP 请求。

做事一次

我们只需要编译一次模板,在 Go 中有几种不同的方法来实现这一点。最明显的是有一个NewTemplateHandler函数,它创建类型并调用一些初始化代码来编译模板。如果我们确信该函数将只由一个 goroutine 调用(可能是在main函数的设置过程中的主 goroutine),那么这将是一种完全可以接受的方法。我们在上一节中使用的另一种方法是在ServeHTTP方法中编译模板一次。sync.Once类型保证我们作为参数传递的函数只执行一次,而不管有多少 goroutine 调用ServeHTTP。这很有帮助,因为 Go 中的 web 服务器是自动并发的,一旦我们的聊天应用程序风靡全球,我们很可能会有许多并发调用ServeHTTP方法。

ServeHTTP方法中编译模板还可以确保我们的代码不会在确定需要之前浪费时间进行工作。在我们目前的情况下,这种惰性初始化方法并没有为我们节省太多,但是在安装任务是时间和资源密集型的情况下,如果功能使用频率较低,很容易看出这种方法是如何派上用场的。

使用自己的处理程序

要实现我们的templateHandler类型,我们需要更新main主体功能,使其如下所示:

func main() {
  // root
  http.Handle("/", &templateHandler{filename: "chat.html"})
  // start the web server
  if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

templateHandler结构是有效的http.Handler类型,因此我们可以将其直接传递给http.Handle函数,并要求其处理与指定模式匹配的请求。在前面的代码中,我们创建了一个类型为templateHandler的新对象,将文件名指定为chat.html,然后获取其地址(使用运算符的&地址),并将其传递给http.Handle函数。我们不存储对新创建的templateHandler类型的引用,但这没关系,因为我们不需要再次引用它。

在您的终端中,按Ctrl+C退出程序,然后重新运行,然后刷新浏览器并注意添加的(来自模板)文本。现在我们的代码比 HTML 代码简单得多,并且没有那些难看的块。

正确构建和执行 Go 程序

当我们的代码由单个main.go文件组成时,使用go run命令运行Go 程序非常棒。但是,通常我们可能需要快速添加其他文件。这要求我们在运行之前将整个包正确地构建成一个可执行的二进制文件。这很简单,从现在开始,您将在终端中构建和运行程序:

go build -o {name}
./{name}

go build命令使用指定文件夹中的所有.go文件创建输出二进制文件,-o标志指示生成的二进制文件的名称。然后,您可以直接通过名称调用程序来运行它。

例如,在我们的聊天应用程序中,我们可以运行:

go build -o chat
./chat

由于我们在第一次提供页面时正在编译模板,因此每次发生任何更改时,我们都需要重新启动您的 web 服务器程序,以便看到更改生效。

在服务器上建模聊天室和客户端

聊天应用程序的所有用户(客户端)将自动放置在一个大的公共房间中,每个人都可以与其他人聊天。room类型将负责管理客户端连接和路由消息进出,而client类型表示与单个客户端的连接。

提示

Go 将类称为类型,将这些类的实例称为对象。

为了管理我们的 web 套接字,我们将使用 Go 社区开源第三方软件包最强大的方面之一。每天都会发布解决现实世界问题的新软件包,供您在自己的项目中使用,甚至允许您添加功能、报告和修复错误以及获得支持。

提示

除非你有很好的理由,否则重新发明轮子通常是不明智的。因此,在开始构建一个新的包之前,值得搜索任何可能已经解决了您的问题的现有项目。如果你发现一个类似的项目不完全满足你的需求,考虑为项目做贡献和增加特性。Go 有一个特别活跃的开源社区(记住 Go 本身是开源的),随时准备欢迎新面孔或化身。

我们将使用 Gorilla 项目的websocket包来处理服务器端套接字,而不是编写自己的套接字。如果您对它的工作原理感到好奇,请访问 GitHub 上的项目主页,https://github.com/gorilla/websocket ,浏览开源代码。

客户建模

chat文件夹中的main.go旁边创建一个名为client.go的新文件,并添加以下代码:

package main
import (
  "github.com/gorilla/websocket"
)
// client represents a single chatting user.
type client struct {
  // socket is the web socket for this client.
  socket *websocket.Conn
  // send is a channel on which messages are sent.
  send chan []byte
  // room is the room this client is chatting in.
  room *room
}

在前面的代码中,套接字将保存对 web 套接字的引用,允许我们与客户端进行通信,send字段是一个缓冲通道,通过该通道,接收到的消息排队,准备转发到用户的浏览器(通过套接字)。room字段将保留对客户正在聊天的房间的引用,这是必需的,以便我们可以将消息转发给房间中的其他人。

如果您尝试构建此代码,您将注意到一些错误。您必须确保已调用go get检索websocket包,这与打开终端并键入以下内容一样简单:

go get github.com/gorilla/websocket

再次构建代码将产生另一个错误:

./client.go:17 undefined: room

问题是我们引用了一个room类型,但没有在任何地方定义它。为了让编译器满意,创建一个名为room.go的文件并插入以下占位符代码:

package main
type room struct {
  // forward is a channel that holds incoming messages
  // that should be forwarded to the other clients.
  forward chan []byte
}

一旦我们对房间需要做什么有了更多的了解,我们将在稍后改进这个定义,但现在,这将允许我们继续。稍后,我们将使用forward通道向所有其他客户端发送传入消息。

您可以将通道视为内存中的线程安全消息队列,其中发送方传递数据,接收方以非阻塞、线程安全的方式读取数据。

为了让客户机执行任何工作,我们必须定义一些方法来执行与 web 套接字之间的实际读写操作。将以下代码添加到client结构的client.go外部(下方),将为client类型添加两个名为readwrite的方法:

func (c *client) read() {
  for {
    if _, msg, err := c.socket.ReadMessage(); err == nil {
      c.room.forward <- msg
    } else {
      break
    }
  }
  c.socket.Close()
}
func (c *client) write() {
  for msg := range c.send {
    if err := c.socket.WriteMessage(websocket.TextMessage, msg); err != nil {
      break
    }
  }
  c.socket.Close()
}

read方法允许我们的客户端通过ReadMessage方法从套接字读取信息,不断地将接收到的任何消息发送到room类型的forward通道。如果遇到错误(如'the socket has died'),循环将中断,插座将关闭。类似地,write方法持续接受来自send通道的消息,并通过WriteMessage方法将所有内容从套接字中写入。如果写入套接字失败,for循环中断,套接字关闭。重新构建包以确保所有内容都已编译。

为房间建模

我们需要一种客户端加入和离开房间的方式,以确保前面部分中的c.room.forward <- msg代码实际上将消息转发给所有客户端。为了确保我们不试图同时访问相同的数据,一个明智的方法是使用两个通道:一个将向房间添加客户端,另一个将删除客户端。让我们更新room.go代码,如下所示:

package main

type room struct {

  // forward is a channel that holds incoming messages
  // that should be forwarded to the other clients.
  forward chan []byte
  // join is a channel for clients wishing to join the room.
  join chan *client
  // leave is a channel for clients wishing to leave the room.
  leave chan *client
  // clients holds all current clients in this room.
  clients map[*client]bool
}

我们添加了三个字段:两个频道和一张地图。joinleave通道的存在只是为了让我们能够安全地在clients地图上添加和删除客户端。如果我们直接访问映射,那么两个并发运行的 Go 例程可能会试图同时修改映射,从而导致内存损坏或状态不可预测。

使用惯用 Go 进行并发编程

现在我们开始使用 Go 并发产品的一个极其强大的特性select语句。我们可以在需要同步或修改共享内存时使用select语句,或者根据通道内的各种活动采取不同的操作。

room结构下方,添加以下run方法,其中包含两个select子句:

func (r *room) run() {
  for {
    select {
    case client := <-r.join:
      // joining
      r.clients[client] = true
    case client := <-r.leave:
      // leaving
      delete(r.clients, client)
      close(client.send)
    case msg := <-r.forward:
      // forward message to all clients
      for client := range r.clients {
        select {
        case client.send <- msg:
          // send the message
        default:
          // failed to send
          delete(r.clients, client)
          close(client.send)
        }
      }
    }
  }
}

尽管这似乎有很多代码需要消化,但一旦我们将其分解一下,就会发现它相当简单,尽管功能非常强大。顶部的for循环表示此方法将永远运行,直到程序终止。这似乎是一个错误,但请记住,如果我们将此代码作为 Go 例程运行,它将在后台运行,这不会阻止应用程序的其余部分。前面的代码将继续监视我们房间内的三个频道:joinleaveforward。如果在这些通道中的任何一个接收到消息,select语句将运行该特定情况下的代码。重要的是要记住,它一次只运行一个案例代码块。这就是我们能够同步的方式,以确保我们的r.clients地图一次只能修改一件事。

如果我们在join频道上收到消息,我们只需更新r.clients地图,以保留加入房间的客户的参考。请注意,我们正在将该值设置为true。我们使用的地图更像是一个切片,但不必担心随着客户的来来往往而缩小切片。将值设置为true只是一种方便、低内存的存储引用的方法。

如果我们在leave频道上收到消息,我们只需从映射中删除client类型,并关闭其send频道。关闭一条通道在 Go 中具有特殊的意义,当我们看最后一个select案例时,这一点就很清楚了。

如果我们在forward通道上收到一条消息,我们将迭代所有客户机,并沿着每个客户机的发送通道send发送消息。然后,我们客户端类型的write方法将拾取它,并将其通过套接字发送到浏览器。如果send频道关闭,则我们知道客户机不再接收任何消息,这就是我们的第二条select条款(特别是默认情况)将客户机移出房间并整理东西的操作。

将房间变成 HTTP 处理程序

现在我们将把room类型转换为http.Handler类型,就像我们之前使用模板处理程序时所做的那样。您会记得,要做到这一点,我们必须简单地添加一个名为ServeHTTP的方法,并带有适当的签名。将以下代码添加到room.go文件的底部:

const (
  socketBufferSize  = 1024
  messageBufferSize = 256
)
var upgrader = &websocket.Upgrader{ReadBufferSize: socketBufferSize, WriteBufferSize: socketBufferSize}
func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  socket, err := upgrader.Upgrade(w, req, nil)
  if err != nil {
    log.Fatal("ServeHTTP:", err)
    return
  }
  client := &client{
    socket: socket,
    send:   make(chan []byte, messageBufferSize),
    room:   r,
  }
  r.join <- client
  defer func() { r.leave <- client }()
  go client.write()
  client.read()
}

ServeHTTP方法意味着房间现在可以充当处理程序。我们将很快实现它,但首先让我们看看这段代码中发生了什么。

为了使用 web 套接字,我们必须使用websocket.Upgrader类型升级 HTTP 连接,它是可重用的,因此我们只需要创建一个。然后,当通过ServeHTTP方法传入请求时,我们通过调用upgrader.Upgrade方法获得套接字。如果一切顺利,我们将创建我们的客户机,并将其传递到当前房间的join频道。我们还将离开操作推迟到客户端完成时,这将确保在用户离开后,一切都被整理好。

然后,客户端的write方法被称为 Go 例程,如第go 行开头的三个字符所示(单词go后跟空格字符)。这告诉 Go 在不同的线程或 goroutine 中运行该方法。

将在其他语言中实现多线程或并发所需的代码量与在 Go 中实现多线程或并发所需的三个按键进行比较,您将了解为什么它成为系统开发人员的最爱。

最后,我们在主线程中调用read方法,它将阻塞操作(保持连接活动),直到关闭它为止。在代码段顶部添加常量是声明在整个项目中硬编码的值的良好实践。随着这些数量的增长,您可能会考虑将它们放入自己的文件中,或者至少在各自文件的顶部,这样它们仍然易于阅读和修改。

使用辅助函数消除复杂性

我们的房间几乎可以使用了,但为了让它发挥任何作用,需要创建通道和地图。实际上,这可以通过要求开发人员使用以下代码来实现:

r := &room{
  forward: make(chan []byte),
  join:    make(chan *client),
  leave:   make(chan *client),
  clients: make(map[*client]bool),
}

另一个稍微优雅一点的解决方案是提供一个newRoom函数来为我们实现这一点。这样,其他人就不必知道为了使我们的房间变得有用,需要做些什么。在type room struct定义下,添加此函数:

// newRoom makes a new room that is ready to go.
func newRoom() *room {
  return &room{
    forward: make(chan []byte),
    join:    make(chan *client),
    leave:   make(chan *client),
    clients: make(map[*client]bool),
  }
}

现在我们代码的用户只需要调用newRoom函数,而不是更详细的六行代码。

创建和使用房间

让我们更新main.go中的main函数,首先创建并运行一个房间,供所有人连接:

func main() {
  r := newRoom()
  http.Handle("/", &templateHandler{filename: "chat.html"})
  http.Handle("/room", r)
  // get the room going
  go r.run()
  // start the web server
  if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

我们在一个单独的 Go 例程中运行房间(再次注意go关键字),以便聊天操作在后台进行,从而允许我们的主线程运行 web 服务器。我们的服务器现在已经完成并成功构建,但是如果没有与之交互的客户端,它仍然是无用的。

构建 HTML 和 JavaScript 聊天客户端

为了让聊天应用程序的用户与服务器交互,从而与其他用户交互,我们需要编写一些客户端代码,利用现代浏览器中的web 套接字。当用户点击我们应用程序的根目录时,我们已经通过模板交付 HTML 内容,因此我们可以增强这一点。

使用以下标记更新templates文件夹中的chat.html文件:

<html>
  <head>
    <title>Chat</title>
    <style>
      input { display: block; }
      ul    { list-style: none; }
    </style>
  </head>
  <body>
    <ul id="messages"></ul>
    <form id="chatbox">
      <textarea></textarea>
      <input type="submit" value="Send" />
       </form>  </body>
</html>

前面的 HTML 将在包含文本区域和发送按钮的页面上呈现一个简单的 web 表单。这就是我们的用户向服务器提交消息的方式。前面代码中的messages元素将包含聊天信息的文本,因此所有用户都可以看到正在说的内容。接下来,我们需要添加一些 JavaScript 来为页面添加一些功能。在form标签下方、关闭</body>标签上方插入以下代码:

    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
    <script>
      $(function(){
        var socket = null;
        var msgBox = $("#chatbox textarea");
        var messages = $("#messages");
        $("#chatbox").submit(function(){
          if (!msgBox.val()) return false;
          if (!socket) {
            alert("Error: There is no socket connection.");
            return false;
          }
          socket.send(msgBox.val());
          msgBox.val("");
          return false;
        });
        if (!window["WebSocket"]) {
          alert("Error: Your browser does not support web sockets.")
        } else {
          socket = new WebSocket("ws://localhost:8080/room");
          socket.onclose = function() {
            alert("Connection has been closed.");
          }
          socket.onmessage = function(e) {
            messages.append($("<li>").text(e.data));
          }
        }
      });
    </script>

socket = new WebSocket("ws://localhost:8080/room")行是我们打开套接字并为两个关键事件添加事件处理程序的地方:oncloseonmessage。当套接字接收到消息时,我们使用 jQuery 将消息附加到列表元素,从而将其呈现给用户。

提交HTML 表单会触发对socket.send的调用,这就是我们向服务器发送消息的方式。

再次构建并运行程序,以确保模板重新编译,从而表示这些更改。

在两个单独的浏览器(或同一浏览器的两个选项卡)中导航到http://localhost:8080/并使用该应用程序。您会注意到,从一个客户端发送的消息会立即出现在其他客户端中。

Building an HTML and JavaScript chat client

从模板中获取更多信息

目前,我们正在使用模板来交付静态 HTML,这很好,因为它为我们提供了一种干净、简单的方法来分离客户端代码和服务器代码。然而,模板实际上要强大得多,我们将调整我们的应用程序,使它们得到更实际的使用。

我们的应用程序(:8080的主机地址目前在两个位置进行硬编码。第一个实例是在main.go中启动 web 服务器:

if err := http.ListenAndServe(":8080", nil); err != nil {
  log.Fatal("ListenAndServe:", err)
}

当我们打开套接字时,第二次在 JavaScript 中硬编码:

socket = new WebSocket("ws://localhost:8080/room");

如果我们的聊天应用程序坚持只在端口8080上本地运行,那么它将非常顽固,因此我们将使用命令行标志使其可配置,然后使用模板的注入功能确保我们的 JavaScript 知道正确的主机。

main.go中更新您的main功能:

func main() {  
  var addr = flag.String("addr", ":8080", "The addr of the application.")
  flag.Parse() // parse the flags
  r := newRoom()
  http.Handle("/", &templateHandler{filename: "chat.html"})
  http.Handle("/room", r)
  // get the room going
  go r.run()
  // start the web server
  log.Println("Starting web server on", *addr)
  if err := http.ListenAndServe(*addr, nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

您需要导入flag包才能生成此代码。addr变量的定义将我们的标志设置为一个字符串,默认为:8080(简短描述该值的用途)。我们必须调用解析参数并提取适当信息的flag.Parse()。然后,我们可以使用*addr引用主机标志的值。

flag.String的调用返回一种*string类型,也就是说它返回存储标志值的字符串变量的地址。要获取值本身(而不是值的地址),我们必须使用指针间接寻址运算符*

我们还添加了一个log.Println调用以在终端中输出地址,这样我们就可以确保我们的更改生效。

我们将修改我们编写的templateHandler类型,以便它将请求的细节作为数据传递到模板的Execute方法中。在main.go中,更新ServeHTTP函数,将请求r作为data参数传递给Execute方法:

func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  t.once.Do(func() {
    t.templ = template.Must(template.ParseFiles(filepath.Join("templates", t.filename)))
  })
  t.templ.Execute(w, r)
}

这告诉模板使用可以从http.Request中提取的数据呈现自身,该数据恰好包含我们需要的主机地址。

为了使用http.RequestHost值,我们可以使用允许我们注入数据的特殊模板语法。更新chat.html文件中创建套接字的行:

socket = new WebSocket("ws://{{.Host}}/room");

双大括号表示注释以及我们告诉模板源注入数据的方式。{{.Host}}本质上相当于告诉它用request.Host中的值替换注释(因为我们将请求r对象作为数据传入)。

提示

我们只触及了 Go 标准库中内置模板的功能的表面。text/template包文档是了解更多您可以实现的目标的好地方。您可以在了解更多信息 http://golang.org/pkg/text/template

重新生成并再次运行聊天程序,但这一次请注意,聊天操作不再产生错误,无论我们指定哪个主机:

go build -o chat
./chat -addr=":3000"

在浏览器中查看页面的源代码,注意{{.Host}}已被应用程序的实际主机替换。有效的主机不仅仅是端口号;您还可以指定 IP 地址或其他主机名,前提是您的环境中允许它们,例如,-addr="192.168.0.1:3000"

追踪代码,查看引擎盖下方

我们知道我们的应用程序正在工作的唯一方法是打开两个或多个浏览器并使用我们的 UI 发送消息。换句话说,我们正在手动测试代码。这对于诸如聊天应用程序之类的实验性项目或预计不会增长的小项目来说是很好的,但是如果我们的代码需要更长的生命周期或由多人处理,那么这种手动测试就成了一种负担。我们不会为聊天程序处理测试驱动开发TDD,但我们应该探索另一种有用的调试技术,称为跟踪

跟踪是一种做法,我们记录或打印程序流程中的关键步骤,以使封面下的内容可见。在上一节中,我们添加了一个log.Println调用来输出聊天程序绑定到的地址。在本节中,我们将对此进行形式化,并编写我们自己的完整跟踪包。

在编写跟踪代码时,我们将探讨 TDD 实践,因为它是一个完美的包示例,我们可能会重用、添加、共享,甚至希望是开源的。

使用 TDD 编写包

Go中的包被组织到文件夹中,每个文件夹一个包。在同一个文件夹中有不同的包声明是一个生成错误,因为所有同级文件都应贡献给单个包。Go 没有子包的概念,这意味着嵌套包(位于嵌套文件夹中)仅出于美观或信息原因而存在,但不继承超级包的任何功能或可见性。在我们的聊天应用程序中,我们所有的文件都参与了main包,因为我们想要构建一个可执行工具。我们的跟踪包永远不会直接运行,因此它可以而且应该使用不同的包名。我们还需要考虑我们的软件包的应用程序编程接口API),考虑如何对软件包进行建模,以使其对用户保持尽可能的可扩展性和灵活性。这包括应该导出(对用户可见)并为简单起见保持隐藏的字段、函数、方法和类型。

Go 使用名称的大小写来表示导出哪些项目,以便包的用户可以看到以大写字母开头的名称(例如,Tracer),而以小写字母开头的名称(例如,templateHandler)则是隐藏的或私有的。

chat文件夹旁边创建一个名为trace的新文件夹,它将是我们跟踪包的名称。

在我们开始编写代码之前,让我们先就软件包的一些设计目标达成一致,通过这些目标我们可以衡量成功与否:

  • 包装应易于使用
  • 单元测试应该涵盖功能
  • 用户应该能够灵活地用自己的实现替换跟踪程序

接口

Go 中的接口是一种非常强大的语言特性,它允许我们定义 API,而无需对实现细节进行严格或具体的描述。只要有可能,使用接口描述包的基本构建块通常最终会带来回报,这就是我们跟踪包的起点。

trace文件夹中创建一个名为tracer.go的新文件,并编写以下代码:

package trace
// Tracer is the interface that describes an object capable of
// tracing events throughout code.
type Tracer interface {
  Trace(...interface{})
}

首先要注意的是,我们已经将我们的包定义为trace

虽然让文件夹名与包名匹配是一种很好的做法,但 Go 工具并不强制执行它,这意味着您可以自由地以不同的方式命名它们(如果有意义的话)。请记住,当人们导入您的包时,他们会键入文件夹的名称,如果突然导入一个具有不同名称的包,可能会让人困惑。

我们的Tracer类型(大写T表示我们打算将其作为一种公开可见的类型)是一个接口,它描述了一个名为Trace的方法。...interface{}参数类型表示我们的Trace方法将接受零个或多个任何类型的参数。您可能认为这是多余的,因为该方法应该只获取一个字符串(我们只想跟踪一些字符串,不是吗?)。但是,考虑函数,如 AutoT5T 和 OutT6T,它们都遵循一个模式,通过 GO 的标准库,在尝试一次通信多个事物时,提供了一个有用的快捷方式。在可能的情况下,我们应该遵循这些模式和实践,因为我们希望我们自己的 API 能够为 Go 社区所熟悉和清晰。

单元测试

我们承诺我们将遵循测试驱动的实践,但接口只是定义,不提供任何实现,因此无法直接测试。但是我们将要编写一个Tracer方法的真正实现,我们将首先编写测试。

trace文件夹中创建一个名为tracer_test.go的新文件,并插入以下脚手架代码:

package trace
import (
  "testing"
)
func TestNew(t *testing.T) {
  t.Error("We haven't written our test yet")
}

测试从一开始就被构建到 Go 工具链中,使得编写自动化测试成为一流的公民。测试代码与生产代码一起存在于后缀为_test.go的文件中。Go 工具将把以Test开头的任何函数(采用单个*testing.T参数)视为单元测试,并在我们运行测试时执行。要为此包运行它们,请导航到终端中的trace文件夹并执行以下操作:

go test

您将看到我们的测试失败,因为我们在TestNew函数体中调用了t.Error

--- FAIL: TestNew (0.00 seconds)
 tracer_test.go:8: We haven't written our test yet
FAIL
exit status 1
FAIL  trace	0.011s

提示

在每次测试运行之前清除终端是一个很好的方法,可以确保您不会混淆以前的运行和最近的运行。在 Windows 上,您可以使用cls命令;在 Unix 机器上,clear命令做同样的事情。

显然,我们还没有正确编写我们的测试,我们不希望它通过,所以让我们更新TestNew函数:

func TestNew(t *testing.T) {
 var buf bytes.Buffer
 tracer := New(&buf)
 if tracer == nil {
 t.Error("Return from New should not be nil")
 } else {
 tracer.Trace("Hello trace package.")
 if buf.String() != "Hello trace package.\n" {
 t.Errorf("Trace should not write '%s'.", buf.String())
 }
 }
}

本书中的大多数软件包都可以从 Go 标准库中获得,因此您可以为相应的软件包添加一个import语句以访问该软件包。其他是外部的,在导入之前,您需要使用go get下载它们。对于这种情况,您需要将import "bytes"添加到文件的顶部。

我们已经开始设计我们的 API,成为它的第一个用户。我们希望能够在bytes.Buffer中捕获跟踪程序的输出,以便确保缓冲区中的字符串与预期值匹配。否则,调用t.Errorf将使测试失败。在此之前,我们检查确认从一个虚构的New函数返回的不是nil;同样,如果是,测试将由于调用t.Error而失败。

红绿测试

现在运行go test实际上会产生一个错误;它抱怨没有New功能。我们在这里没有犯错误;我们正在遵循一种称为红-绿测试的做法。红绿测试建议我们首先编写一个单元测试,看到它失败(或产生错误),编写尽可能少的代码使测试通过,然后冲洗并再次重复。这里的关键点是,我们希望确保我们添加的代码实际上正在做一些事情,同时确保我们编写的测试代码正在测试一些有意义的东西。

考虑一分钟无意义的测试:

if true == true {
  t.Error("True should be true")
}

从逻辑上讲,真与假是不可能的(如果真等于假,那么是时候换台新电脑了),所以我们的测试毫无意义。如果测试或索赔不能失败,那么它就没有任何价值。

在某些情况下,将true替换为您期望设置为true的变量意味着这样的测试可能确实会失败(比如当被测试的代码行为不正常时)——此时,您有了一个有意义的测试,值得为代码库做出贡献。

您可以将的go test输出视为待办事项列表,一次只解决一个问题。现在,我们要解决的就是关于New功能缺失的投诉。在trace.go文件中,让我们添加尽可能少的代码来处理事情;在接口类型定义下添加以下代码段:

func New() {}

运行go test现在向我们表明,事情确实取得了进展,尽管进展不远。我们现在有两个错误:

./tracer_test.go:11: too many arguments in call to New
./tracer_test.go:11: New(&buf) used as value

第一个错误告诉我们正在将参数传递给New函数,但New函数不接受任何参数。第二个错误是我们使用了New函数的返回值,但是New函数没有返回任何值。您可能已经看到了这一点,事实上,随着您获得编写测试驱动代码的更多经验,您很可能会跳过这些琐碎的细节。然而,为了恰当地说明该方法,我们将在一段时间内变得迂腐。让我们通过更新New函数以接受预期参数来解决第一个错误:

func New(w io.Writer) {}

我们使用的参数满足io.Writer接口,这意味着指定的对象必须具有合适的Write方法。

使用现有接口,特别是 Go 标准库中的接口,是确保代码尽可能灵活和优雅的一种非常强大且经常是必要的方法。

接受io.Writer意味着用户可以决定将跟踪输出写入何处。这个输出可以是标准输出、文件、网络套接字、bytes.Buffer和我们的测试用例一样,甚至是一些定制的对象,只要它实现了io.Writer接口的Write方法

再次运行go test表明我们已经解决了第一个错误,我们只需要添加一个返回类型就可以越过第二个错误:

func New(w io.Writer) Tracer {}

我们声明我们的New函数将返回一个Tracer,但我们不返回任何内容,go test很高兴地抱怨:

./tracer.go:13: missing return at end of function

解决这个问题很容易;我们可以从New函数返回nil

func New(w io.Writer) Tracer {
  return nil
}

当然,我们的测试代码已经断言返回值不应该是nil,所以go test现在给我们一条失败消息:

tracer_test.go:14: Return from New should not be nil

你可以看到,严格遵守红绿原则会变得有点乏味,但至关重要的是,我们不要走得太远。如果我们一次性编写大量的实现代码,我们很可能会有单元测试没有涵盖的代码。

经过深思熟虑的核心团队甚至通过提供代码覆盖率统计数据为我们解决了这个问题,我们可以通过运行以下命令生成代码覆盖率统计数据:

go test -cover

如果所有测试都通过,添加-cover标志将告诉我们在测试执行期间有多少代码被触动。显然,我们离 100%越近越好。

实现接口

为了满足这个测试,我们需要能够从New方法正确返回的东西,因为Tracer只是一个接口,我们必须返回真实的东西。让我们在tracer.go文件中添加一个跟踪程序的实现:

type tracer struct {
  out io.Writer
}

func (t *tracer) Trace(a ...interface{}) {}

我们的实施非常简单;tracer类型有一个名为outio.Writer字段,我们将在该字段中写入跟踪输出。而Trace方法与Tracer接口所需的方法完全匹配,尽管它还没有做任何事情。

现在我们终于可以修正New方法了:

func New(w io.Writer) Tracer {
  return &tracer{out: w}
}

再次运行go test向我们表明,我们的期望没有得到满足,因为在我们调用Trace的过程中没有写入任何内容:

tracer_test.go:18: Trace should not write ''.

让我们更新我们的Trace方法,将混合参数写入指定的io.Writer字段:

func (t *tracer) Trace(a ...interface{}) {
  t.out.Write([]byte(fmt.Sprint(a...)))
  t.out.Write([]byte("\n"))
}

调用Trace方法时,我们调用out字段中存储的io.Writer上的Write,并使用fmt.Sprint格式化a参数。我们将字符串返回类型从fmt.Sprint转换为string,然后再转换为[]byte,因为这是io.Writer接口所期望的。

我们终于通过测试了吗?

go test -cover
PASS
coverage: 100.0% of statements
ok    trace	0.011s

祝贺我们已经成功地通过了测试,并且有100.0%测试覆盖率。一旦我们完成了一杯香槟,我们可以花一分钟来考虑一些关于我们的实施非常有趣的事情。

返回给用户的未报告类型

我们编写的tracer结构类型是未报告的,因为它以小写的t开头,那么我们如何能够从导出的New函数返回它呢?毕竟,用户没有收到返回的对象吗?这是完全可以接受且有效的 Go 代码;用户只会看到满足Tracer接口的对象,甚至不会知道我们的私有tracer类型。因为它们只与接口交互,所以我们的tracer实现是否暴露了其他方法或字段并不重要;他们永远不会被看到。这允许我们保持包的公共 API 干净和简单。

这种隐藏的实现技术在整个 Go 标准库中都使用,例如,ioutil.NopCloser方法是一个将正常的io.Reader转换为io.ReadCloser的函数,而Close方法什么都不做(用于将不需要关闭的io.Reader对象传递到需要io.ReadCloser类型的函数中)。对于用户来说,该方法返回io.ReadCloser,但是在引擎盖下,有一个秘密nopCloser类型隐藏了实现细节。

要亲自查看,请浏览上的 Go 标准库源代码 http://golang.org/src/pkg/io/ioutil/ioutil.go 并搜索nopCloser结构。

使用我们的新跟踪包

现在我们已经完成了trace软件包的第一个版本,我们可以在聊天应用程序中使用它,以便更好地了解用户通过用户界面发送消息时的情况。

room.go中,让我们导入新的包并调用Trace方法。我们刚刚编写的trace包的路径将取决于您的GOPATH环境变量,因为导入路径是相对于$GOPATH/src文件夹的。因此,如果您在$GOPATH/src/mycode/trace中创建trace包,则需要导入mycode/trace

更新room类型和run()方法如下:

type room struct {
  // forward is a channel that holds incoming messages
  // that should be forwarded to the other clients.
  forward chan []byte
  // join is a channel for clients wishing to join the room.
  join chan *client
  // leave is a channel for clients wishing to leave the room.
  leave chan *client
  // clients holds all current clients in this room.
  clients map[*client]bool
  // tracer will receive trace information of activity
 // in the room.
 tracer trace.Tracer
}
func (r *room) run() {
  for {
    select {
    case client := <-r.join:
      // joining
      r.clients[client] = true
      r.tracer.Trace("New client joined")
    case client := <-r.leave:
      // leaving
      delete(r.clients, client)
      close(client.send)
      r.tracer.Trace("Client left")
    case msg := <-r.forward:
      r.tracer.Trace("Message received: ", string(msg))
      // forward message to all clients
      for client := range r.clients {
        select {
        case client.send <- msg:
          // send the message
          r.tracer.Trace(" -- sent to client")
        default:
          // failed to send
          delete(r.clients, client)
          close(client.send)
          r.tracer.Trace(" -- failed to send, cleaned up client")
        }
      }
    }
  }
}

我们在room类型中添加了一个trace.Tracer字段,然后定期调用贯穿整个代码的Trace方法。如果我们运行程序并尝试发送消息,您会注意到应用程序会出现恐慌,因为tracer字段是nil。我们现在可以通过确保在创建room类型时创建并分配适当的对象来解决这个问题。更新main.go文件以执行此操作:

r := newRoom()
r.tracer = trace.New(os.Stdout)

我们正在使用New方法创建一个对象,该对象将输出发送到os.Stdout标准输出管道(这是一种表示我们希望它将输出打印到终端的技术方式)。

现在重建并运行程序,并使用两个浏览器来运行应用程序,请注意,终端现在为我们提供了一些有趣的跟踪信息:

New client joined
New client joined
Message received: Hello Chat
 -- sent to client
 -- sent to client
Message received: Good morning :)
 -- sent to client
 -- sent to client
Client left
Client left

现在,我们能够使用调试信息来了解应用程序正在做什么,这将有助于我们开发和支持我们的项目。

可选择追踪

一旦应用程序发布,我们生成的那种跟踪信息如果只是打印到某个终端上,或者更糟,如果它给我们的系统管理员带来很多噪音,那么它将变得毫无用处。另外,请记住,当我们没有为room类型设置跟踪程序时,我们的代码会恐慌,这不是一个非常用户友好的情况。为了解决这两个问题,我们将使用一个trace.Off()方法来增强trace包,该方法将返回一个满足Tracer接口的对象,但在调用Trace方法时不会执行任何操作。

让我们添加一个测试,在调用Trace之前调用Off函数以获取静默跟踪程序,以确保代码不会死机。因为跟踪不会发生,所以我们只能在测试代码中这样做。将以下测试功能添加到tracer_test.go文件中:

func TestOff(t *testing.T) {
  var silentTracer Tracer = Off()
  silentTracer.Trace("something")
}

要使其通过,请在tracer.go文件中添加以下代码:

type nilTracer struct{}
func (t *nilTracer) Trace(a ...interface{}) {}
// Off creates a Tracer that will ignore calls to Trace.
func Off() Tracer {
  return &nilTracer{}
}

我们的nilTracer结构定义了一个不做任何事情的Trace方法,对Off()方法的调用将创建一个新的nilTracer结构并返回它。请注意,我们的nilTracer结构与tracer结构的不同之处在于它不接受io.Writer;它不需要一个,因为它不会写任何东西。

现在我们通过更新room.go文件中的newRoom方法来解决第二个问题:

func newRoom() *room {
  return &room{
    forward: make(chan []byte),
    join:    make(chan *client),
    leave:   make(chan *client),
    clients: make(map[*client]bool),
    tracer:  trace.Off(),
  }
}

默认情况下,我们的room类型将使用nilTracer结构创建,对Trace的任何调用都将被忽略。您可以通过从main.go文件中删除r.tracer = trace.New(os.Stdout)行来尝试这一点:请注意,当您使用应用程序时,不会向终端写入任何内容,并且不会出现死机。

清洁包装原料药

快速浏览一下我们的trace包的 API(在此上下文中,公开的变量、方法和类型)就可以发现一个简单而明显的设计出现了:

  • New()方法
  • Off()方法
  • Tracer接口

我会非常自信地把这个包交给一个 Go 程序员,而不需要任何文档或指导,而且我很肯定他们会知道如何处理它。

在 Go 中,添加文档非常简单,只需在每个项目之前的行中添加注释即可。关于这个主题的博客文章值得一读(http://blog.golang.org/godoc-documenting-go-code ),您可以看到tracer.go的托管源代码副本,这是您如何注释trace包的示例。有关更多信息,请参阅github.com/matryer/goblueprints/blob/master/chapter1/trace/tracer.go

总结

在本章中,我们开发了一个完整的并发聊天应用程序和我们自己的简单软件包来跟踪我们的程序流,以帮助我们更好地了解引擎盖下正在发生的事情。

我们使用net/http包快速构建了一个非常强大的并发 HTTP web 服务器。在一个特定的案例中,我们随后升级了连接以打开客户端和服务器之间的 web 套接字。这意味着我们可以轻松快速地将消息传递给用户的 web 浏览器,而无需编写混乱的轮询代码。我们探讨了模板如何有助于将代码与内容分离,以及如何允许我们将数据注入模板源,从而使主机地址可配置。命令行标志帮助我们为托管应用程序的人员提供简单的配置控制,同时还允许我们指定合理的默认值。

我们的聊天应用程序利用了 Go 强大的并发功能,使我们能够在几行惯用的 Go 中编写清晰的线程代码。通过控制客户端通过通道的进出,我们能够在代码中设置同步点,防止我们试图同时修改相同的对象而破坏内存。

我们了解了http.Handler和我们自己的trace.Tracer等接口如何允许我们提供不同的实现,而不必接触使用它们的代码,在某些情况下,甚至不必向用户公开实现的名称。我们看到,仅仅通过在room类型中添加ServeHTTP方法,我们就将自定义房间的概念变成了一个有效的 HTTP 处理程序对象,它管理我们的 web 套接字连接。

实际上,我们离能够正确发布应用程序并不遥远,除了一个主要的疏忽:您无法看到谁发送了每条消息。我们没有用户甚至用户名的概念,对于一个真正的聊天应用程序来说,这是不可接受的。

在下一章中,我们将添加回复他们消息的人的姓名,以使他们感觉自己正在与其他人进行真正的对话。