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页面对旧的浏览器不适用,但是在这种情况下我们可以接受。
如何测试返回正确的标记?
有几种方法。正如书中所强调的,重要的是你所写的测试有足够的价值来证明其成本。
-
使用 Selenium 之类的工具编写基于浏览器的测试。这些测试是所有方法中最「现实」的,因为它们启动某种实际的 web 浏览器,并模拟用户与之交互。这些测试可以让您对系统的工作充满信心,但是编写这些测试要比编写单元测试困难得多,运行速度也慢得多。对于我们的产品来说,这是多余的。
-
是否进行精确的字符串匹配。这是可以的,但这类测试最终会变得非常脆弱。当有人更改了标记时,您的测试就会失败,而实际上什么都没有被 真正破坏。
-
检查我们调用的模板是否正确。我们将使用标准库中的模板库来提供 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.html
到 cmd/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
}
通过发送一个 BlindAlerter
,TexasHoldem
可以安排发送盲注通知到 任何地方
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
。
首先编写测试
CLI
和 Server
的要求是一样的!只是传递机制不同。
让我们来看看我们的 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
去传递一个 Game
给 PlayerServer
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)
}
}
考虑到我们还没有收到盲注通知,这个应用程序确实可以工作!我们成功地将 Game
与 PlayerServer
进行了复用,它处理了所有的细节。一旦我们弄清楚了如何将我们的盲注通知发送到 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)
你应该看到它正在工作!浏览器中的盲注筹码会神奇地增加。
现在让我们恢复代码并考虑如何测试它。为了 实现 它,我们所做的只是通过 StartGame
的 playerServerWS
而不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
。因为我们必须等待服务器异步执行它的工作。
我们可以重构我们的助手 assertGameStartedWith
和 assertFinishCalledWith
,以便他们可以在失败之前短时间内重试他们的断言。
这是你如何能做到 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