WebSockets

未匹配的标注

WebSockets

你可以在这里找到本章的所有代码

在本章中,我们将学习如何使用 WebSockets 来改进我们的应用程序。

项目回顾

我们的扑克代码库中有两个应用程序

  • 命令行应用程序 : 提示用户输入游戏中的玩家人数。然后告知玩家「盲注」的筹码,它会随着时间的增长而增长。在任何时候,用户都可以输入 {Playername} wins 来完成游戏并在存储中记录胜利者。

  • Web 应用程序 :允许用户记录游戏的赢家和显示一个联赛表。与命令行应用程序共享相同存储。

下一步

产品所有者对命令行应用程序感到很兴奋,但是如果我们能将该功能带到浏览器中,他会更喜欢它。她设想了一个带有文本框的 web 页面,该文本框允许用户输入玩家的数量,当他们提交表单时,页面显示盲注,并在适当的时候自动更新它。与命令行应用程序一样,用户可以宣布获胜者,并将其保存在数据库中。

从表面上看,这听起来很简单,但我们必须始终强调采用「迭代」方法来编写软件。

首先我们需要 HTML。到目前为止,我们所有的 HTTP 端点都返回了明文或 JSON。我们 可以 使用我们知道的相同的技术(因为它们最终都是字符串),但是我们也可以使用 html/template 包来实现更简洁的解决方案。

我们还需要能够异步发送消息给用户说 The blind is now *y*,而不需要刷新浏览器。我们可以使用 WebSockets 来实现这个。

WebSocket 是一种计算机通信协议,通过一个 TCP 连接提供全双工通信通道

考虑到我们采用了许多技术,更重要的是我们先做尽可能做少量的主要的工作,然后迭代。

因此,我们要做的第一件事就是创建一个带有表单的 web 页面,以便用户记录获胜者。我们不使用普通的表单,而是使用 WebSockets 将数据发送到我们的服务器进行记录。

之后,我们将处理盲注通知,在此之前,我们将设置一些基础设施代码。

那么关于 JavaScript 的测试呢 ?

将会编写一些 JavaScript 来实现这一点,但是我不会去编写测试。

这当然是可能的,但为了简洁起见,我将不包括任何解释。

对不起各位。Lobby O'Reilly 付钱给我,让我做一个「用测试学习 JavaScript」。

首先编写测试

我们需要做的第一件事是为用户提供一些点击 /game 的 HTML。

下面是 web 服务器中相关代码的提示

type PlayerServer struct {
    store PlayerStore
    http.Handler
}

const jsonContentType = "application/json"

func NewPlayerServer(store PlayerStore) *PlayerServer {
    p := new(PlayerServer)

    p.store = store

    router := http.NewServeMux()
    router.Handle("/league", http.HandlerFunc(p.leagueHandler))
    router.Handle("/players/", http.HandlerFunc(p.playersHandler))

    p.Handler = router

    return p
}

现在我们能做的最简单的事情就是当我们 GET /game 时检查是否得到 200

func TestGame(t *testing.T) {
    t.Run("GET /game returns 200", func(t *testing.T) {
        server := NewPlayerServer(&StubPlayerStore{})

        request, _ := http.NewRequest(http.MethodGet, "/game", nil)
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusOK)
    })
}

尝试运行测试

--- FAIL: TestGame (0.00s)
=== RUN   TestGame/GET_/game_returns_200
    --- FAIL: TestGame/GET_/game_returns_200 (0.00s)
        server_test.go:109: did not get correct status, got 404, want 200

编写足够的代码让测试通过

服务器有一个路由设置,所以比较容易修复。

给我们的路由添加

router.Handle("/game", http.HandlerFunc(p.game))

然后编写 game 方法

func (p *PlayerServer) game(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
}

重构

服务器代码已经很好了,因为我们非常容易地将更多的代码插入到现有的构造良好的代码中。

我们可以通过添加一个测试助手函数 newGameRequest 来对 /game 发出请求来稍微整理一下测试。试着自己写。

func TestGame(t *testing.T) {
    t.Run("GET /game returns 200", func(t *testing.T) {
        server := NewPlayerServer(&StubPlayerStore{})

        request :=  newGameRequest()
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response, http.StatusOK)
    })
}

您还会注意到,我将 assertStatus 更改为接受 response,而不是 response.Code。我觉得这样读起来更好。

现在我们需要让端点返回一些 HTML,如下面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Let's play poker</title>
</head>
<body>
<section id="game">
    <div id="declare-winner">
        <label for="winner">Winner</label>
        <input type="text" id="winner"/>
        <button id="winner-button">Declare winner</button>
    </div>
</section>
</body>
<script type="application/javascript">

    const submitWinnerButton = document.getElementById('winner-button')
    const winnerInput = document.getElementById('winner')

    if (window['WebSocket']) {
        const conn = new WebSocket('ws://' + document.location.host + '/ws')

        submitWinnerButton.onclick = event => {
            conn.send(winnerInput.value)
        }
    }
</script>
</html>

我们有了一个非常简单的 web 页面

  • 一个文本输入,供用户输入获胜者。

  • 一个按钮来宣布获胜者。

  • 一些 JavaScript 打开一个 WebSocket 连接到我们的服务器,并处理提交按钮按下的事件

WebSocket 内置在大多数现代浏览器中,所以我们不必担心引入任何库。这个web页面对旧的浏览器不适用,但是在这种情况下我们可以接受。

如何测试返回正确的标记?

有几种方法。正如书中所强调的,重要的是你所写的测试有足够的价值来证明其成本。

  1. 使用 Selenium 之类的工具编写基于浏览器的测试。这些测试是所有方法中最「现实」的,因为它们启动某种实际的 web 浏览器,并模拟用户与之交互。这些测试可以让您对系统的工作充满信心,但是编写这些测试要比编写单元测试困难得多,运行速度也慢得多。对于我们的产品来说,这是多余的。

  2. 是否进行精确的字符串匹配。这是可以的,但这类测试最终会变得非常脆弱。当有人更改了标记时,您的测试就会失败,而实际上什么都没有被 真正破坏

  3. 检查我们调用的模板是否正确。我们将使用标准库中的模板库来提供 HTML(稍后将讨论),我们可以插入thing 来生成HTML 并监视它的调用,以检查我们是否做对了。这将对我们的代码设计产生影响,但实际上不会进行大量测试;而不是用正确的模板文件调用它。考虑到我们的项目中只有一个模板,失败的几率似乎很低。

所以在「通过测试学习 Go」这本书中,我们第一次,不打算写一个测试。

将标记放到一个名为 game.html 的文件中。

接下来更改刚刚写入的端点

func (p *PlayerServer) game(w http.ResponseWriter, r *http.Request) {
    tmpl, err := template.ParseFiles("game.html")

    if err != nil {
        http.Error(w, fmt.Sprintf("problem loading template %s", err.Error()), http.StatusInternalServerError)
        return
    }

    tmpl.Execute(w, nil)
}

html/template是一个用于创建 HTML 的 Go 包。在我们的例子中,我们称之为 template.ParseFiles,给出 html 文件的路径。假设没有错误,然后可以 Execute 模板,模板将其写入 io.Writer。在我们的例子中,我们希望它 Write 到互联网,所以我们给它我们的 http.ResponseWriter

由于我们还没有编写测试,所以最好手动测试我们的 web 服务器,以确保一切正常。去 cmd/webserver 运行 main.go 文件。访问 http://localhost:5000/game

应该 有一个关于无法找到模板的错误。你可以改变路径,使之与你的文件夹相关联,也可以拷贝 game.htmlcmd/webserver 目录。我选择创建一个符号链接(ln -s ../../game.html game.html)到项目根目录下的文件,所以如果我做了更改,它们会在运行服务器时反映出来。

如果你做了这样的更改,再次运行一应该会看到我们的 UI。

现在我们需要测试,当我们通过WebSocket连接到我们的服务器时,我们将它宣布为游戏的获胜者。

首先写测试

这是我们第一次使用外部库,这样我们就可以使用 WebSockets了。

运行 go get github.com/gorilla/websocket

这样我们将会获得优秀的库 Gorilla WebSocket 的代码。现在我们可以根据新的需求更新我们的测试。

t.Run("when we get a message over a websocket it is a winner of a game", func(t *testing.T) {
    store := &StubPlayerStore{}
    winner := "Ruth"
    server := httptest.NewServer(NewPlayerServer(store))
    defer server.Close()

    wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"

    ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
    if err != nil {
        t.Fatalf("could not open a ws connection on %s %v", wsURL, err)
    }
    defer ws.Close()

    if err := ws.WriteMessage(websocket.TextMessage, []byte(winner)); err != nil {
        t.Fatalf("could not send message over ws connection %v", err)
    }

    AssertPlayerWin(t, store, winner)
})

确保您导入了 websocket 库。我的 IDE 自动为我做了,你的也应该是这样的。

为了测试在浏览器中发生了什么,我们必须打开自己的 WebSocket 连接并对其进行写操作。

我们之前在服务器上的测试只是调用了服务器上的方法,但是现在我们需要一个到服务器的持久连接。为此,我们使用 httptest.NewServer ,它有一个 http.Handle,通过它来监听连接。

我们通过使用 websocket.DefaultDialer.Dial 尝试连接我们的服务器,并向我们的 winner 发送信息。

最后我们断言在玩家仓库检查谁是获胜者。

尝试运行测试

=== RUN   TestGame/when_we_get_a_message_over_a_websocket_it_is_a_winner_of_a_game
    --- FAIL: TestGame/when_we_get_a_message_over_a_websocket_it_is_a_winner_of_a_game (0.00s)
        server_test.go:124: could not open a ws connection on ws://127.0.0.1:55838/ws websocket: bad handshake

我们还没有改变我们的服务器接受 ws上的 WebSocket 连接,所以我们还没有握手。

编写足够的代码让测试通过

添加另一个列表到我们的路由

router.Handle("/ws", http.HandlerFunc(p.webSocket))

然后添加新的 webSocket 处理程序

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    upgrader := websocket.Upgrader{
        ReadBufferSize:  1024,
        WriteBufferSize: 1024,
    }
    upgrader.Upgrade(w, r, nil)
}

为了接受 WebSocket 连接,我们 Upgrade 了请求。如果您现在重新运行测试,那么应该会转到下一个错误。

=== RUN   TestGame/when_we_get_a_message_over_a_websocket_it_is_a_winner_of_a_game
    --- FAIL: TestGame/when_we_get_a_message_over_a_websocket_it_is_a_winner_of_a_game (0.00s)
        server_test.go:132: got 0 calls to RecordWin want 1

现在我们已经打开了一个连接,我们想要监听一条消息,然后将其记录为获胜者。

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    upgrader := websocket.Upgrader{
        ReadBufferSize:  1024,
        WriteBufferSize: 1024,
    }
    conn, _ := upgrader.Upgrade(w, r, nil)
    _, winnerMsg, _ := conn.ReadMessage()
    p.store.RecordWin(string(winnerMsg))
}

(是的,我们现在忽略了很多错误!)

conn.ReadMessage() 阻塞等待连接上的消息。一旦我们得到一个,我们就用它来 RecordWin。这将最终关闭 WebSocket 连接。

如果你尝试运行测试,它仍然会失败。

问题在于时间。在我们的 WebSocket 连接读取消息并记录 win 和我们的测试完成之间有一个延迟。可以通过在最后的断言前放置一个短 time.Sleep 来测试。

现在让我们继续,但是在测试中加入任意睡眠是非常不好的做法

time.Sleep(10  * time.Millisecond)
AssertPlayerWin(t, store, winner)

重构

我们在服务器代码和测试代码中都犯了很多错误,但是请记住,这是我们工作的最简单的方法。

我们有一个由测试支持的令人讨厌的、可怕的、可以 工作 的软件,所以现在我们可以让它变得更好,并且知道我们不会意外地破坏任何东西。

让我们从服务器代码开始。

我们可以将 upgrader 移动到包中的一个私有值,因为我们不需要在每次 WebSocket 连接请求时都重新声明它。

var wsUpgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    conn, _ := wsUpgrader.Upgrade(w, r, nil)
    _, winnerMsg, _ := conn.ReadMessage()
    p.store.RecordWin(string(winnerMsg))
}

我们对 template. parsefiles ("game.html") 的调用将在每个 GET /game 上运行,这意味着我们将在每个请求时进入文件系统,即我们不需要重新解析模板。让我们重构我们的代码,这样我们可以在 NewPlayerServer 中解析一次模板。我们必须这样做,以便该函数现在可以返回一个错误,如果我们有问题从磁盘获取模板或解析它。

下面是对 PlayerServer 的相关更改

type PlayerServer struct {
    store PlayerStore
    http.Handler
    template *template.Template
}

const htmlTemplatePath = "game.html"

func NewPlayerServer(store PlayerStore) (*PlayerServer, error) {
    p := new(PlayerServer)

    tmpl, err := template.ParseFiles("game.html")

    if err != nil {
        return nil, fmt.Errorf("problem opening %s %v", htmlTemplatePath, err)
    }

    p.template = tmpl
    p.store = store

    router := http.NewServeMux()
    router.Handle("/league", http.HandlerFunc(p.leagueHandler))
    router.Handle("/players/", http.HandlerFunc(p.playersHandler))
    router.Handle("/game", http.HandlerFunc(p.game))
    router.Handle("/ws", http.HandlerFunc(p.webSocket))

    p.Handler = router

    return p, nil
}

func (p *PlayerServer) game(w http.ResponseWriter, r *http.Request) {
    p.template.Execute(w, nil)
}

因为更改了 NewPlayerServer 的签名,我们现在有编译问题。尝试自己修复它们,如果有困难,可以参考源代码。

对于测试代码,我创建了一个名为 mustMakePlayerServer(t *testing.T, store PlayerStore) *PlayerServer 的助手,这样就可以在测试中隐藏错误消息。

func mustMakePlayerServer(t *testing.T, store PlayerStore) *PlayerServer {
    server, err := NewPlayerServer(store)
    if err != nil {
        t.Fatal("problem creating player server", err)
    }
    return server
}

类似地,我创建了另一个助手 mustDialWS,这样我可以在创建 WebSocket 连接时隐藏讨厌的错误消息。

func mustDialWS(t *testing.T, url string) *websocket.Conn {
    ws, _, err := websocket.DefaultDialer.Dial(url, nil)

    if err != nil {
        t.Fatalf("could not open a ws connection on %s %v", url, err)
    }

    return ws
}

最后,在我们的测试代码中,我们可以创建一个助手来整理发送的消息

func writeWSMessage(t *testing.T, conn *websocket.Conn, message string) {
    t.Helper()
    if err := conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil {
        t.Fatalf("could not send message over ws connection %v", err)
    }
}

现在测试通过,尝试运行服务器,并宣布获胜者在 /game。你应该可以在 /league 上看到他们的记录。请记住,每当我们获得一个获胜者,我们就会关闭连接,您将需要刷新页面来再次打开连接。

我们制作了一个简单的 web 表单,允许用户记录游戏的获胜者。让我们对其进行迭代,这样用户就可以通过提供大量的玩家来开始游戏,服务器将向客户端推送消息,告诉他们随着时间的流逝,盲注是多少。

首先更新 game.html 以更新客户端代码来满足新的需求

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Lets play poker</title>
</head>
<body>
<section id="game">
    <div id="game-start">
        <label for="player-count">Number of players</label>
        <input type="number" id="player-count"/>
        <button id="start-game">Start</button>
    </div>

    <div id="declare-winner">
        <label for="winner">Winner</label>
        <input type="text" id="winner"/>
        <button id="winner-button">Declare winner</button>
    </div>

    <div id="blind-value"/>
</section>

<section id="game-end">
    <h1>Another great game of poker everyone!</h1>
    <p><a href="/league">Go check the league table</a></p>
</section>

</body>
<script type="application/javascript">
    const startGame = document.getElementById('game-start')

    const declareWinner = document.getElementById('declare-winner')
    const submitWinnerButton = document.getElementById('winner-button')
    const winnerInput = document.getElementById('winner')

    const blindContainer = document.getElementById('blind-value')

    const gameContainer = document.getElementById('game')
    const gameEndContainer = document.getElementById('game-end')

    declareWinner.hidden = true
    gameEndContainer.hidden = true

    document.getElementById('start-game').addEventListener('click', event => {
        startGame.hidden = true
        declareWinner.hidden = false

        const numberOfPlayers = document.getElementById('player-count').value

        if (window['WebSocket']) {
            const conn = new WebSocket('ws://' + document.location.host + '/ws')

            submitWinnerButton.onclick = event => {
                conn.send(winnerInput.value)
                gameEndContainer.hidden = false
                gameContainer.hidden = true
            }

            conn.onclose = evt => {
                blindContainer.innerText = 'Connection closed'
            }

            conn.onmessage = evt => {
                blindContainer.innerText = evt.data
            }

            conn.onopen = function () {
                conn.send(numberOfPlayers)
            }
        }
    })
</script>
</html>

主要的变化是引入了一个区域来输入玩家的数量,以及一个区域来显示盲注。我们有一个小逻辑在游戏的阶段来显示/隐藏用户界面。

我们假设通过 conn.onmessage 接收到的任何消息都是盲注通知,因此我们相应地设置了 blindContainer.innerText

如何发送盲注通知? 在前一章中,我们介绍了 Game 的概念,因此我们的 CLI 代码可以调用 Game,其他一切都将得到处理,包括调度盲注通知。这是一个很好的关注点分离。

type Game interface {
    Start(numberOfPlayers int)
    Finish(winner string)
}

当用户在 CLI 中被提示有多少玩家时,它将 Start 游戏,这将启动盲警报;当用户宣布获胜者时,它将 Finish 游戏。这和我们现在的要求是一样的,只是得到输入的方式不同了;所以如果可以的话,我们应该重新利用这个概念。

我们「真正」Game 的实现是TexasHoldem

type TexasHoldem struct {
    alerter BlindAlerter
    store   PlayerStore
}

通过发送一个 BlindAlerterTexasHoldem 可以安排发送盲注通知到 任何地方

type BlindAlerter interface {
    ScheduleAlertAt(duration time.Duration, amount int)
}

提醒一下,这是我们在 CLI 中使用的 BlindAlerter 的实现。

func StdOutAlerter(duration time.Duration, amount int) {
    time.AfterFunc(duration, func() {
        fmt.Fprintf(os.Stdout, "Blind is now %d\n", amount)
    })
}

这在 CLI 中是有效的,因为我们 总是希望将通知发送到 os.Stdout,但这将不会在我们的 web 服务器上工作。对于每个请求,我们都会得到一个新的 http.ResponseWriter。然后将其升级为 *websocket.Conn。因此,我们无法知道何时构建依赖关系,何时需要发送通知到何处。

出于这个原因,我们需要更改 BlindAlerter.ScheduleAlertAt,以便它为通知提供一个目的地,以便我们可以在我们的 web 服务器中重用它。

打开 BlindAlerter.go 文件并且加入参数 to io.Writer

type BlindAlerter interface {
    ScheduleAlertAt(duration time.Duration, amount int, to io.Writer)
}

type BlindAlerterFunc func(duration time.Duration, amount int, to io.Writer)

func (a BlindAlerterFunc) ScheduleAlertAt(duration time.Duration, amount int, to io.Writer) {
    a(duration, amount, to)
}

StdoutAlerter 这个概念不适合我们的新模型所以把它重命名为 Alerter

func Alerter(duration time.Duration, amount int, to io.Writer) {
    time.AfterFunc(duration, func() {
        fmt.Fprintf(to, "Blind is now %d\n", amount)
    })
}

如果你尝试编译,它会失败在 TexasHoldem,因为它调用 ScheduleAlertAt 没有目的地,现在 再次编译硬编码到os.Stdout

试着运行测试,它们会失败,因为 SpyBlindAlerter 不再实现 BlindAlerter,通过更新 ScheduleAlertAt 的签名来修复这个问题,运行测试,我们应该仍然是绿色的。

TexasHoldem 知道在哪里发送盲注通知是毫无意义的。让我们现在更新 Game,以便当你开始一个游戏你宣布通知应该去 哪里

type Game interface {
    Start(numberOfPlayers int, alertsDestination io.Writer)
    Finish(winner string)
}

让编译器告诉您需要修复什么。改变也没那么糟糕:

  • 更新 TexasHoldem 让它正确的实现 Game

  • CLI 中,当我们开始游戏时,传递我们的 out 属性(cli.game.Start(numberOfPlayers, cli.out))

  • TexasHoldem 的测试中,我使用 game.Start(5, ioutil.Discard) 修复编译问题并将通知输出配置为丢弃

如果你做的一切都是对的,那么一切都应该是绿色的!现在我们可以尝试在 Server 中使用 Game

首先编写测试

CLIServer 的要求是一样的!只是传递机制不同。

让我们来看看我们的 CLI 测试来获得启发。

t.Run("start game with 3 players and finish game with 'Chris' as winner", func(t *testing.T) {
    game := &GameSpy{}

    out := &bytes.Buffer{}
    in := userSends("3", "Chris wins")

    poker.NewCLI(in, out, game).PlayPoker()

    assertMessagesSentToUser(t, out, poker.PlayerPrompt)
    assertGameStartedWith(t, game, 3)
    assertFinishCalledWith(t, game, "Chris")
})

看起来我们应该能够使用 GameSpy 测试出类似的结果

用以下代码替换旧的 websocket 测试

t.Run("start a game with 3 players and declare Ruth the winner", func(t *testing.T) {
    game := &poker.GameSpy{}
    winner := "Ruth"
    server := httptest.NewServer(mustMakePlayerServer(t, dummyPlayerStore, game))
    ws := mustDialWS(t, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws")

    defer server.Close()
    defer ws.Close()

    writeWSMessage(t, ws, "3")
    writeWSMessage(t, ws, winner)

    time.Sleep(10 * time.Millisecond)
    assertGameStartedWith(t, game, 3)
    assertFinishCalledWith(t, game, winner)
})
  • 如前所述,我们创建了一个 spy Game,并将其传递到 mustMakePlayerServer(请确保更新帮助程序以支持此操作)。

  • 然后我们发送游戏的 web 套接字消息。

  • 最后,我们断言游戏的开始和结束都符合我们的预期。

尝试运行测试

在其他测试中,围绕 mustMakePlayerServer 会有许多编译错误. 引入一个未导出的变量 dummyGame,并在所有未编译的测试中使用它

var (
    dummyGame = &GameSpy{}
)

最后一个错误是我们试图将 Game 传递给 NewPlayerServer,但它还不支持

./server_test.go:21:38: too many arguments in call to "github.com/quii/learn-go-with-tests/WebSockets/v2".NewPlayerServer
    have ("github.com/quii/learn-go-with-tests/WebSockets/v2".PlayerStore, "github.com/quii/learn-go-with-tests/WebSockets/v2".Game)
    want ("github.com/quii/learn-go-with-tests/WebSockets/v2".PlayerStore)

编写最少量的代码让测试跑起来并检查失败输出

现在只需将它添加为一个参数,以便运行测试

func  NewPlayerServer(store PlayerStore, game Game)  (*PlayerServer,  error)  {

最后!

=== RUN   TestGame/start_a_game_with_3_players_and_declare_Ruth_the_winner
--- FAIL: TestGame (0.01s)
    --- FAIL: TestGame/start_a_game_with_3_players_and_declare_Ruth_the_winner (0.01s)
        server_test.go:146: wanted Start called with 3 but got 0
        server_test.go:147: expected finish called with 'Ruth' but got ''
FAIL

编写足够的代码让测试通过

我们需要将 Game 作为字段添加到 PlayerServer 中,这样当它收到请求时就可以使用它。

type PlayerServer struct {
    store PlayerStore
    http.Handler
    template *template.Template
    game Game
}

(我们已经有了一个名为 game 的方法将它重命名为 playGame)

接下来,让我们在构造函数中分配它

func NewPlayerServer(store PlayerStore, game Game) (*PlayerServer, error) {
    p := new(PlayerServer)

    tmpl, err := template.ParseFiles("game.html")

    if err != nil {
        return nil, fmt.Errorf("problem opening %s %v", htmlTemplatePath, err)
    }

    p.game = game

    // etc

现在我们可以在 webSocket 中使用我们的 Game 了。

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    conn, _ := wsUpgrader.Upgrade(w, r, nil)

    _, numberOfPlayersMsg, _ := conn.ReadMessage()
    numberOfPlayers, _ := strconv.Atoi(string(numberOfPlayersMsg))
    p.game.Start(numberOfPlayers, ioutil.Discard) //todo: Don't discard the blinds messages!

    _, winner, _ := conn.ReadMessage()
    p.game.Finish(string(winner))
}

万岁!测试通过。

我们不会在任何地方发送这种盲目的信息,因为我们需要 对此 进行思考。当我们调用 game.Start 时,我们发送给 ioutil.Discard,它将丢弃所有写给它的消息。

现在启动 web 服务器。你需要更新 main.go 去传递一个 GamePlayerServer

func main() {
    db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)

    if err != nil {
        log.Fatalf("problem opening %s %v", dbFileName, err)
    }

    store, err := poker.NewFileSystemPlayerStore(db)

    if err != nil {
        log.Fatalf("problem creating file system player store, %v ", err)
    }

    game := poker.NewTexasHoldem(poker.BlindAlerterFunc(poker.Alerter), store)

    server, err := poker.NewPlayerServer(store, game)

    if err != nil {
        log.Fatalf("problem creating player server %v", err)
    }

    if err := http.ListenAndServe(":5000", server); err != nil {
        log.Fatalf("could not listen on port 5000 %v", err)
    }
}

考虑到我们还没有收到盲注通知,这个应用程序确实可以工作!我们成功地将 GamePlayerServer 进行了复用,它处理了所有的细节。一旦我们弄清楚了如何将我们的盲注通知发送到 web sockets 而不是丢弃它们,它就 应该 能够正常工作了。
在此之前,让我们整理一些代码。

重构

我们使用 WebSockets 的方式是相当基础的,错误处理也是相当幼稚的,所以我想把它封装到一个类型中,以便从服务器代码中去除这种混乱。我们可能希望稍后重新访问它,但是现在,这样可以稍微整理一下

type playerServerWS struct {
    *websocket.Conn
}

func newPlayerServerWS(w http.ResponseWriter, r *http.Request) *playerServerWS {
    conn, err := wsUpgrader.Upgrade(w, r, nil)

    if err != nil {
        log.Printf("problem upgrading connection to WebSockets %v\n", err)
    }

    return &playerServerWS{conn}
}

func (w *playerServerWS) WaitForMsg() string {
    _, msg, err := w.ReadMessage()
    if err != nil {
        log.Printf("error reading from websocket %v\n", err)
    }
    return string(msg)
}

现在服务器代码稍微简化了一些

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    ws := newPlayerServerWS(w, r)

    numberOfPlayersMsg := ws.WaitForMsg()
    numberOfPlayers, _ := strconv.Atoi(numberOfPlayersMsg)
    p.game.Start(numberOfPlayers, ioutil.Discard) //todo: Don't discard the blinds messages!

    winner := ws.WaitForMsg()
    p.game.Finish(winner)
}

让我们 写一个测试!

有时当我们不确定如何做某事时,最好只是玩玩,尝试一下!确保你的工作首先被提交,因为一旦我们想出了一个方法,我们应该通过测试来驱动它。

我们有问题的代码行是

p.game.Start(numberOfPlayers, ioutil.Discard)  //todo: Don't discard the blinds messages!

我们需要在游戏中通过 io.Writer 来编写盲注通知。

如果我们能从以前传递我们的 playerServerWS 就好了? 它是我们 WebSocket 的封装,所以它 感觉 我们应该能够将它发送到我们的 Game 中去发送消息。

试试吧:

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    ws := newPlayerServerWS(w, r)

    numberOfPlayersMsg := ws.WaitForMsg()
    numberOfPlayers, _ := strconv.Atoi(numberOfPlayersMsg)
    p.game.Start(numberOfPlayers, ws) 
    //etc...

编译器报错

./server.go:71:14: cannot use ws (type *playerServerWS) as type io.Writer in argument to p.game.Start:
    *playerServerWS does not implement io.Writer (missing Write method)

这似乎是一件显而易见的事情,就是让 playerServerWS 执行 io.Writer。为此,我们使用底层的 *websocket.Conn来使用 WriteMessage 将消息发送到 websocket。

func (w *playerServerWS) Write(p []byte) (n int, err error) {
    err = w.WriteMessage(1, p)

    if err != nil {
        return 0, err
    }

    return len(p), nil
}

这个看起来太简单了!尝试运行应用程序,看看它是否工作。

预先修改 TexasHoldem,使盲注增量时间更短,以便您可以看到它的行动

blindIncrement := time.Duration(5+numberOfPlayers)  * time.Second // (rather than a minute)

你应该看到它正在工作!浏览器中的盲注筹码会神奇地增加。

现在让我们恢复代码并考虑如何测试它。为了 实现 它,我们所做的只是通过 StartGameplayerServerWS 而不ioutil.Discard。因此,这可能会让你认为,我们或许应该监视这个调用,以验证它是否有效。

监视是很棒的,并帮助我们检查实施细节,但我们应该始终尝试和支持测试 真正的 行为,如果我们可以,因为当你决定重构,往往是监视测试开始失败,因为他们通常检查的实施细节是你试图改变的。

我们的测试当前打开一个到正在运行的服务器的 websocket 连接,并发送消息让它完成任务。同样,我们应该能够测试服务器通过 websocket 连接发回的消息。

首先编写测试

我们将编辑现有的测试。

目前,当您调用 Start 时,我们的 GameSpy 不发送任何数据到 out。我们应该更改它,以便我们可以配置它来发送一个固定的消息,然后我们可以检查该消息是否被发送到 websocket。

type GameSpy struct {
    StartCalled     bool
    StartCalledWith int
    BlindAlert      []byte

    FinishedCalled   bool
    FinishCalledWith string
}

添加 BlindAlert 字段。

更新 GameSpy Start 发送消息到 out.

func (g *GameSpy) Start(numberOfPlayers int, out io.Writer) {
    g.StartCalled = true
    g.StartCalledWith = numberOfPlayers
    out.Write(g.BlindAlert)
}

这意味着当我们运行 PlayerServer 时,当它试图 Start 游戏时,如果一切正常,它将通过 websocket 发送消息。

最后,我们可以更新我们的测试

t.Run("start a game with 3 players, send some blind alerts down WS and declare Ruth the winner", func(t *testing.T) {
    wantedBlindAlert := "Blind is 100"
    winner := "Ruth"

    game := &GameSpy{BlindAlert: []byte(wantedBlindAlert)}
    server := httptest.NewServer(mustMakePlayerServer(t, dummyPlayerStore, game))
    ws := mustDialWS(t, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws")

    defer server.Close()
    defer ws.Close()

    writeWSMessage(t, ws, "3")
    writeWSMessage(t, ws, winner)

    time.Sleep(10 * time.Millisecond)
    assertGameStartedWith(t, game, 3)
    assertFinishCalledWith(t, game, winner)

    _, gotBlindAlert, _ := ws.ReadMessage()

    if string(gotBlindAlert) != wantedBlindAlert {
        t.Errorf("got blind alert %q, want %q", string(gotBlindAlert), wantedBlindAlert)
    }
})
  • 我们添加了一个 wantedBlindAlert ,并将 GameSpy 配置为调用 Start 时将其发送到 out

  • 我们希望它在 websocket 连接中被发送,所以我们添加了一个对 ws.ReadMessage() 的调用,以等待发送的消息,然后检查它是否是我们期望的消息。

尝试运行测试

你会发现测试永远挂起。这是因为 ws.ReadMessage() 在它收到消息之前会一直阻塞,而这条消息永远不会收到。

编写最少量的代码让测试跑起来并检查失败输出

我们不应该有挂起的测试,所以让我们介绍一种处理想要超时的代码的方法。

func within(t *testing.T, d time.Duration, assert func()) {
    t.Helper()

    done := make(chan struct{}, 1)

    go func() {
        assert()
        done <- struct{}{}
    }()

    select {
    case <-time.After(d):
        t.Error("timed out")
    case <-done:
    }
}

within 所做的是将函数 assert 作为参数,然后在 go 协程中运行它。如果/当函数完成时,它将通过 done 通道发出信号。

在这种情况下,我们使用 select 语句来等待通道发送消息。从这里开始,assert 函数和 time.After 之间的竞争就开始了。当持续时间发生时,它将发送一个信号。

最后,我为我们的断言创建了一个辅助函数,目的是使事情更简洁一些

func assertWebsocketGotMsg(t *testing.T, ws *websocket.Conn, want string) {
    _, msg, _ := ws.ReadMessage()
    if string(msg) != want {
        t.Errorf(`got "%s", want "%s"`, string(msg), want)
    }
}

现在测试是这样的

t.Run("start a game with 3 players, send some blind alerts down WS and declare Ruth the winner", func(t *testing.T) {
    wantedBlindAlert := "Blind is 100"
    winner := "Ruth"

    game := &GameSpy{BlindAlert: []byte(wantedBlindAlert)}
    server := httptest.NewServer(mustMakePlayerServer(t, dummyPlayerStore, game))
    ws := mustDialWS(t, "ws"+strings.TrimPrefix(server.URL, "http")+"/ws")

    defer server.Close()
    defer ws.Close()

    writeWSMessage(t, ws, "3")
    writeWSMessage(t, ws, winner)

    time.Sleep(tenMS)

    assertGameStartedWith(t, game, 3)
    assertFinishCalledWith(t, game, winner)
    within(t, tenMS, func() { assertWebsocketGotMsg(t, ws, wantedBlindAlert) })
})

现在,如果你运行测试

=== RUN   TestGame
=== RUN   TestGame/start_a_game_with_3_players,_send_some_blind_alerts_down_WS_and_declare_Ruth_the_winner
--- FAIL: TestGame (0.02s)
    --- FAIL: TestGame/start_a_game_with_3_players,_send_some_blind_alerts_down_WS_and_declare_Ruth_the_winner (0.02s)
        server_test.go:143: timed out
        server_test.go:150: got "", want "Blind is 100"

编写足够的代码让测试通过

最后,我们现在可以更改服务器代码,以便在游戏启动时将 WebSocket 连接发送到游戏

func (p *PlayerServer) webSocket(w http.ResponseWriter, r *http.Request) {
    ws := newPlayerServerWS(w, r)

    numberOfPlayersMsg := ws.WaitForMsg()
    numberOfPlayers, _ := strconv.Atoi(numberOfPlayersMsg)
    p.game.Start(numberOfPlayers, ws)

    winner := ws.WaitForMsg()
    p.game.Finish(winner)
}

重构

服务器代码是一个非常小的变化,所以这里没有太多的变化,但测试代码仍然有 time.Sleep。因为我们必须等待服务器异步执行它的工作。

我们可以重构我们的助手 assertGameStartedWithassertFinishCalledWith,以便他们可以在失败之前短时间内重试他们的断言。

这是你如何能做到 assertFinishCalledWith的,你可以使用相同的方法对于其他助手。

func assertFinishCalledWith(t *testing.T, game *GameSpy, winner string) {
    t.Helper()

    passed := retryUntil(500*time.Millisecond, func() bool {
        return game.FinishCalledWith == winner
    })

    if !passed {
        t.Errorf("expected finish called with %q but got %q", winner, game.FinishCalledWith)
    }
}

下面是 retryUntil 的定义

func retryUntil(d time.Duration, f func() bool) bool {
    deadline := time.Now().Add(d)
    for time.Now().Before(deadline) {
        if f() {
            return true
        }
    }
    return false
}

结束

我们的应用程序现在已经完成了。可以通过 web 浏览器启动一个扑克游戏,用户可以通过 WebSockets 了解到盲注的筹码。在游戏结束后,他们可以用我们几章前写的代码记录赢家。玩家可以通过网站的 /league 端点找到谁是最好的(或最幸运的)扑克玩家。

在整个过程中,我们犯了一些错误,但是在 TDD 流程中,我们从来没有远离工作软件。我们可以自由地不断迭代和试验。

最后一章将回顾我们的方法,我们已经达到的设计,并解决一些遗留问题。

我们在这一章中讨论了几件事

WebSockets

  • 在客户端和服务器之间发送消息的快捷方式,不需要客户端保持轮询服务器. 我们的客户端和服务端代码都非常简单。

  • 测试很简单,但是您必须小心测试的异步性

处理测试中可能延迟或永远无法完成的代码

  • 创建帮助函数来重试断言并添加超时。

  • 我们可以使用 go 协程来确保断言不会阻塞任何东西,然后使用通道让它们发出结束或结束的信号。

  • time 包有一些有用的功能,这些功能还可以通过频道及时发送关于事件的信号,这样我们就可以设置超时。

原文地址 Learn Go with Tests

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~