IO 和排序
上一章中,我们通过添加新的服务器访问地址 /league 来迭代我们的应用程序。在此过程中,我们学习了如何处理 JSON、嵌入类型和路由。
服务器重启后软件会丢失所有得分,产品负责人对此感到不安。这是因为我们存储的实现是在内存里。对于我们没有解释 /league 的访问地址应该按赢的次数排序返回玩家列表,她也很不满意。

目前为止的代码

1
// server.go
2
package main
3
4
import (
5
"encoding/json"
6
"fmt"
7
"net/http"
8
)
9
10
// PlayerStore stores score information about players
11
type PlayerStore interface {
12
GetPlayerScore(name string) int
13
RecordWin(name string)
14
GetLeague() []Player
15
}
16
17
// Player stores a name with a number of wins
18
type Player struct {
19
Name string
20
Wins int
21
}
22
23
// PlayerServer is a HTTP interface for player information
24
type PlayerServer struct {
25
store PlayerStore
26
http.Handler
27
}
28
29
const jsonContentType = "application/json"
30
31
// NewPlayerServer creates a PlayerServer with routing configured
32
func NewPlayerServer(store PlayerStore) *PlayerServer {
33
p := new(PlayerServer)
34
35
p.store = store
36
37
router := http.NewServeMux()
38
router.Handle("/league", http.HandlerFunc(p.leagueHandler))
39
router.Handle("/players/", http.HandlerFunc(p.playersHandler))
40
41
p.Handler = router
42
43
return p
44
}
45
46
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
47
json.NewEncoder(w).Encode(p.store.GetLeague())
48
w.Header().Set("content-type", jsonContentType)
49
w.WriteHeader(http.StatusOK)
50
}
51
52
func (p *PlayerServer) playersHandler(w http.ResponseWriter, r *http.Request) {
53
player := r.URL.Path[len("/players/"):]
54
55
switch r.Method {
56
case http.MethodPost:
57
p.processWin(w, player)
58
case http.MethodGet:
59
p.showScore(w, player)
60
}
61
}
62
63
func (p *PlayerServer) showScore(w http.ResponseWriter, player string) {
64
score := p.store.GetPlayerScore(player)
65
66
if score == 0 {
67
w.WriteHeader(http.StatusNotFound)
68
}
69
70
fmt.Fprint(w, score)
71
}
72
73
func (p *PlayerServer) processWin(w http.ResponseWriter, player string) {
74
p.store.RecordWin(player)
75
w.WriteHeader(http.StatusAccepted)
76
}
Copied!
1
// InMemoryPlayerStore.go
2
package main
3
4
func NewInMemoryPlayerStore() *InMemoryPlayerStore {
5
return &InMemoryPlayerStore{map[string]int{}}
6
}
7
8
type InMemoryPlayerStore struct {
9
store map[string]int
10
}
11
12
func (i *InMemoryPlayerStore) GetLeague() []Player {
13
var league []Player
14
for name, wins := range i.store {
15
league = append(league, Player{name, wins})
16
}
17
return league
18
}
19
20
func (i *InMemoryPlayerStore) RecordWin(name string) {
21
i.store[name]++
22
}
23
24
func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {
25
return i.store[name]
26
}
Copied!
1
// main.go
2
package main
3
4
import (
5
"log"
6
"net/http"
7
)
8
9
func main() {
10
server := NewPlayerServer(NewInMemoryPlayerStore())
11
12
if err := http.ListenAndServe(":5000", server); err != nil {
13
log.Fatalf("could not listen on port 5000 %v", err)
14
}
15
}
Copied!
你可以在本章顶部的链接中找到相应的测试。

存储数据

满足这个需求的数据库有很多,但我们会使用一种非常简单的方法。我们将把这个应用程序的数据以 JSON 的格式存储到文件中。
这使得数据具有很强的可移植性,并且实现起来相对简单。
它的伸缩性不高,但考虑到这是一个原型,至少现在是没问题的。如果我们的环境变得不再合适,换成其它的存储方式也会非常简单,因为我们使用的是 PlayerStore 的抽象。
我们将暂时保留 InMemoryPlayerStore,以便在开发新的存储实现时还能通过集成测试。一旦我们确信新实现足以通过集成测试,我们会替换然后删除 InMemoryPlayerStore

首先编写测试

现在,你应该已经熟悉以下标准库相关的接口,用于读取数据(io.Reader)、写入数据(io.Writer)的接口,以及如何使用标准库来测试这些函数,而不必使用真正的文件。
为了完成这项工作,我们需要实现 PlayerStore,因此我们调用需要实现的方法来编写测试。我们将从 GetLeague 开始。
1
func TestFileSystemStore(t *testing.T) {
2
3
t.Run("/league from a reader", func(t *testing.T) {
4
database := strings.NewReader(`[
5
{"Name": "Cleo", "Wins": 10},
6
{"Name": "Chris", "Wins": 33}]`)
7
8
store := FileSystemStore{database}
9
10
got := store.GetLeague()
11
12
want := []Player{
13
{"Cleo", 10},
14
{"Chris", 33},
15
}
16
17
assertLeague(t, got, want)
18
})
19
}
Copied!
我们使用 strings.NewReader 会返回一个 Reader,这是我们的 FileSystemStore 函数中用来读取数据的。在 main 中我们将打开一个文件,它也是一个 Reader

尝试运行测试

1
# github.com/quii/learn-go-with-tests/json-and-io/v7
2
./FileSystemStore_test.go:15:12: undefined: FileSystemStore
Copied!

编写最少量的代码让测试运行起来,然后检查错误输出

让我们在新的文件中定义 FileSystemStore
1
type FileSystemStore struct {}
Copied!
再次尝试运行测试
1
# github.com/quii/learn-go-with-tests/json-and-io/v7
2
./FileSystemStore_test.go:15:28: too many values in struct initializer
3
./FileSystemStore_test.go:17:15: store.GetLeague undefined (type FileSystemStore has no field or method GetLeague)
Copied!
报错是因为我们传入了不需要的 Reader 参数,并且 GetLeague 函数还没有定义。
1
type FileSystemStore struct {
2
database io.Reader
3
}
4
5
func (f *FileSystemStore) GetLeague() []Player {
6
return nil
7
}
Copied!
再试一次...
1
=== RUN TestFileSystemStore//league_from_a_reader
2
--- FAIL: TestFileSystemStore//league_from_a_reader (0.00s)
3
FileSystemStore_test.go:24: got [] want [{Cleo 10} {Chris 33}]
Copied!

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

我们之前已经从 reader 中读取了 JSON 数据
1
func (f *FileSystemStore) GetLeague() []Player {
2
var league []Player
3
json.NewDecoder(f.database).Decode(&league)
4
return league
5
}
Copied!
现在测试应该通过了。

重构

我们以前就这样做过!服务器的测试代码必须从响应中解码 JSON 数据。
我们试着把它提炼为一个函数。
创建一个名为 league.go 的新文件,输入以下代码。
1
func NewLeague(rdr io.Reader) ([]Player, error) {
2
var league []Player
3
err := json.NewDecoder(rdr).Decode(&league)
4
if err != nil {
5
err = fmt.Errorf("problem parsing league, %v", err)
6
}
7
8
return league, err
9
}
Copied!
在我们的实现和 server_test.go 的辅助函数 getLeagueFromResponse 中调用这个函数
1
func (f *FileSystemStore) GetLeague() []Player {
2
league, _ := NewLeague(f.database)
3
return league
4
}
Copied!
我们还没有处理解析错误的方法,但是我们还是继续向前推进吧。

寻找问题

我们的实现中有一个缺陷。首先注意 io.Reader 是如何定义的。
1
type Reader interface {
2
Read(p []byte) (n int, err error)
3
}
Copied!
你可以想象它一个一个字节读取文件直到结束。如果你再读一遍会发生什么?
在当前测试的末尾添加以下内容。
1
// read again
2
got = store.GetLeague()
3
assertLeague(t, got, want)
Copied!
我们希望它通过测试,但是如果你运行会发现它并没有通过。
这里的问题是我们的 Reader 已经到了结尾,没什么可读的了。我们需要一种方法让它回到开始位置。
ReadSeeker 是标准库中的另一个可以提供帮助的接口。
1
type ReadSeeker interface {
2
Reader
3
Seeker
4
}
Copied!
还记得嵌入吗?这是由 ReaderSeeker 组成的接口
1
type Seeker interface {
2
Seek(offset int64, whence int) (int64, error)
3
}
Copied!
这感觉不错,我们可以更改 FileSystemStore 来替代这个接口吗?
1
type FileSystemStore struct {
2
database io.ReadSeeker
3
}
4
5
func (f *FileSystemStore) GetLeague() []Player {
6
f.database.Seek(0, 0)
7
league, _ := NewLeague(f.database)
8
return league
9
}
Copied!
尝试运行测试,它现在通过了!很高兴我们在测试中使用的 string.NewReader 也实现了 ReadSeeker,所以我们不需要做任何其他的改变。
接下来我们将实现 GetPlayerScore

首先编写测试

1
t.Run("get player score", func(t *testing.T) {
2
database := strings.NewReader(`[
3
{"Name": "Cleo", "Wins": 10},
4
{"Name": "Chris", "Wins": 33}]`)
5
6
store := FileSystemPlayerStore{database}
7
8
got := store.GetPlayerScore("Chris")
9
10
want := 33
11
12
if got != want {
13
t.Errorf("got %d want %d", got, want)
14
}
15
})
Copied!

尝试运行测试

./FileSystemStore_test.go:38:15: store.GetPlayerScore undefined (type FileSystemPlayerStore has no field or method GetPlayerScore)

编写最少量的代码让测试运行起来,然后检查错误输出

我们需要将方法添加到新类型中,以便编译测试。
1
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
2
return 0
3
}
Copied!
现在它可以编译并且测试失败
1
=== RUN TestFileSystemStore/get_player_score
2
--- FAIL: TestFileSystemStore//get_player_score (0.00s)
3
FileSystemStore_test.go:43: got 0 want 33
Copied!

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

我们可以遍历 league 寻找玩家并返回他们的得分
1
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
2
3
var wins int
4
5
for _, player := range f.GetLeague() {
6
if player.Name == name {
7
wins = player.Wins
8
break
9
}
10
}
11
12
return wins
13
}
Copied!

重构

你会看到许多辅助函数需要重构,这些将留给你来实现
1
t.Run("/get player score", func(t *testing.T) {
2
database := strings.NewReader(`[
3
{"Name": "Cleo", "Wins": 10},
4
{"Name": "Chris", "Wins": 33}]`)
5
6
store := FileSystemPlayerStore{database}
7
8
got := store.GetPlayerScore("Chris")
9
want := 33
10
assertScoreEquals(t, got, want)
11
})
Copied!
最后,我们需要用 RecordWin 来记录得分。

首先编写测试

我们的方法写的相当短视。我们不能(很容易地)只更新文件中 JSON 的一「行」。我们需要在每次写入时存储整个数据新的表现形式。
我们应该怎么编写?我们通常会使用一个 Writer,但我们已经有了 ReadSeeker。我们可能有两个依赖项,但是标准库已经为我们提供了一个接口 ReadWriteSeeker,我们需要对文件做的处理它都可以满足。
我们来更新一下类型
1
type FileSystemPlayerStore struct {
2
database io.ReadWriteSeeker
3
}
Copied!
查看是否通过编译
1
./FileSystemStore_test.go:15:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
2
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
3
./FileSystemStore_test.go:36:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
4
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
Copied!
strings.Reader 没有实现 ReadWriteSeeker 并不奇怪,这时我们该怎么办呢?
我们有两个选择
    为每个测试创建一个临时文件。*os.File 实现 ReadWriteSeeker。好处是它变得更像集成测试,我们真的是从文件系统中读取和写入,所以我们对此更有信心。缺点是我们更喜欢单元测试,因为它们更快而且通常更简单。我们还需要做更多关于创建临时文件的工作,然后确保在测试之后删除它们。
    使用第三方库。github.com/mattetti 已经编写了一个 filebuffer 库,它实现了我们需要的接口,并且不触及文件系统。
这两种选择都没有问题,但是如果选择使用第三方库,我将不得不解释依赖管理!所以还是用文件代替吧。
在添加测试之前,我们需要通过用 os.File 替换 strings.Reader 来使其他测试编译通过。
让我们创建一个辅助函数,它将创建包含一些数据的临时文件
1
func createTempFile(t *testing.T, initialData string) (io.ReadWriteSeeker, func()) {
2
t.Helper()
3
4
tmpfile, err := ioutil.TempFile("", "db")
5
6
if err != nil {
7
t.Fatalf("could not create temp file %v", err)
8
}
9
10
tmpfile.Write([]byte(initialData))
11
12
removeFile := func() {
13
os.Remove(tmpfile.Name())
14
}
15
16
return tmpfile, removeFile
17
}
Copied!
TempFile 创建一个临时文件供我们使用。我们传入的 "db" 值是在它将创建的随机文件名上加上的前缀。这是为了确保它不会与其他文件发生意外冲突。
你会注意到,我们不仅返回 ReadWriteSeeker(文件),而且还返回一个函数。我们需要确保在测试完成后删除该文件。我们不希望将文件的细节泄露到测试中,因为它很容易出错,对读者来说也没什么意思。通过返回 removeFile 函数,我们可以处理辅助函数中的细节,调用者只需运行 deferred cleanDatabase()
1
func TestFileSystemStore(t *testing.T) {
2
3
t.Run("league from a reader", func(t *testing.T) {
4
database, cleanDatabase := createTempFile(t, `[
5
{"Name": "Cleo", "Wins": 10},
6
{"Name": "Chris", "Wins": 33}]`)
7
defer cleanDatabase()
8
9
store := FileSystemPlayerStore{database}
10
11
got := store.GetLeague()
12
13
want := []Player{
14
{"Cleo", 10},
15
{"Chris", 33},
16
}
17
18
assertLeague(t, got, want)
19
20
// read again
21
got = store.GetLeague()
22
assertLeague(t, got, want)
23
})
24
25
t.Run("get player score", func(t *testing.T) {
26
database, cleanDatabase := createTempFile(t, `[
27
{"Name": "Cleo", "Wins": 10},
28
{"Name": "Chris", "Wins": 33}]`)
29
defer cleanDatabase()
30
31
store := FileSystemPlayerStore{database}
32
33
got := store.GetPlayerScore("Chris")
34
want := 33
35
assertScoreEquals(t, got, want)
36
})
37
}
Copied!
运行测试,他们应该可以通过了!这里有大量的更改,但是现在感觉我们已经完成了接口定义,从现在开始添加新的测试应该非常容易了。
让我们执行第一次迭代,为现有的玩家记录一次胜利
1
t.Run("store wins for existing players", func(t *testing.T) {
2
database, cleanDatabase := createTempFile(t, `[
3
{"Name": "Cleo", "Wins": 10},
4
{"Name": "Chris", "Wins": 33}]`)
5
defer cleanDatabase()
6
7
store := FileSystemPlayerStore{database}
8
9
store.RecordWin("Chris")
10
11
got := store.GetPlayerScore("Chris")
12
want := 34
13
assertScoreEquals(t, got, want)
14
})
Copied!

尝试运行测试

./FileSystemStore_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)

编写最少量的代码让测试运行起来,然后检查错误输出

添加新的方法
1
func (f *FileSystemPlayerStore) RecordWin(name string) {
2
3
}
Copied!
1
=== RUN TestFileSystemStore/store_wins_for_existing_players
2
--- FAIL: TestFileSystemStore/store_wins_for_existing_players (0.00s)
3
FileSystemStore_test.go:71: got 33 want 34
Copied!
我们的实现是空的,因此旧的得分将会返回。

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

1
func (f *FileSystemPlayerStore) RecordWin(name string) {
2
league := f.GetLeague()
3
4
for i, player := range league {
5
if player.Name == name {
6
league[i].Wins++
7
}
8
}
9
10
f.database.Seek(0,0)
11
json.NewEncoder(f.database).Encode(league)
12
}
Copied!
你可能会问,为什么我要用 league[i].Wins++ 而不是 player.Wins++
当你在一个切片上取值时,将返回当前循环的索引(我们示例中的 i)和该索引中的元素的副本。更改副本 Wins 的值不会对我们迭代的 league 产生任何影响。因此,我们需要通过使用 league[i] 来获取对实际值的引用,然后更改该值。
如果你运行这些测试,它们应该可以通过了。

重构

GetPlayerScoreRecordWin 中,我们遍历 []Player,按名称查找 player
我们可以在 FileSystemStore 的内部重构这个公共代码,但对我来说,它可能还有用,我们可以将其提升为新的类型。到目前为止,操作「League」都是用 []Player,但我们可以创造一种新的类型 League。这使其他开发人员更容易理解,然后我们可以将有用的方法附加到该类型上供我们使用。
league.go 添加一下代码
1
type League []Player
2
3
func (l League) Find(name string) *Player {
4
for i, p := range l {
5
if p.Name==name {
6
return &l[i]
7
}
8
}
9
return nil
10
}
Copied!
现在如果任何有 League 的人都可以很容易找到给定的玩家。
更改我们的 PlayerStore 接口以返回 League 而不是 []Player。试着重新运行测试,你会遇到编译问题,因为我们修改了接口。但是这很容易修复,只要将返回类型从 []Player 改为 League 就行了。
这使我们可以简化 FileSystemStore 的方法。
1
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
2
3
player := f.GetLeague().Find(name)
4
5
if player != nil {
6
return player.Wins
7
}
8
9
return 0
10
}
11
12
func (f *FileSystemPlayerStore) RecordWin(name string) {
13
league := f.GetLeague()
14
player := league.Find(name)
15
16
if player != nil {
17
player.Wins++
18
}
19
20
f.database.Seek(0, 0)
21
json.NewEncoder(f.database).Encode(league)
22
}
Copied!
这看起来好多了,我们可以在 League 中找到其他可以被重构的功能。
我们现在需要处理记录新玩家获胜的场景。

首先编写测试

1
t.Run("store wins for existing players", func(t *testing.T) {
2
database, cleanDatabase := createTempFile(t, `[
3
{"Name": "Cleo", "Wins": 10},
4
{"Name": "Chris", "Wins": 33}]`)
5
defer cleanDatabase()
6
7
store := FileSystemPlayerStore{database}
8
9
store.RecordWin("Pepper")
10
11
got := store.GetPlayerScore("Pepper")
12
want := 1
13
assertScoreEquals(t, got, want)
14
})
Copied!

尝试并运行测试

1
=== RUN TestFileSystemStore/store_wins_for_existing_players#01
2
--- FAIL: TestFileSystemStore/store_wins_for_existing_players#01 (0.00s)
3
FileSystemStore_test.go:86: got 0 want 1
Copied!

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

我们只需要处理查找返回 nil 的情况因为它找不到 player
1
func (f *FileSystemPlayerStore) RecordWin(name string) {
2
league := f.GetLeague()
3
player := league.Find(name)
4
5
if player != nil {
6
player.Wins++
7
} else {
8
league = append(league, Player{name, 1})
9
}
10
11
f.database.Seek(0, 0)
12
json.NewEncoder(f.database).Encode(league)
13
}
Copied!
效果看起来不错,因此我们现在可以在集成测试中使用我们的新的 Store。这将使我们对软件的工作更有信心,然后我们可以删除冗余的 InMemoryPlayerStore
TestRecordingWinsAndRetrievingThem 中,替换之前的记录。
1
database, cleanDatabase := createTempFile(t, "")
2
defer cleanDatabase()
3
store := &FileSystemPlayerStore{database}
Copied!
测试通过后就可以删除 InMemoryPlayerStore 了。main.go 现在会出现编译问题,这将促使我们现在在「真实」代码中使用我们的新存储。
1
package main
2
3
import (
4
"log"
5
"net/http"
6
"os"
7
)
8
9
const dbFileName = "game.db.json"
10
11
func main() {
12
db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)
13
14
if err != nil {
15
log.Fatalf("problem opening %s %v", dbFileName, err)
16
}
17
18
store := &FileSystemPlayerStore{db}
19
server := NewPlayerServer(store)
20
21
if err := http.ListenAndServe(":5000", server); err != nil {
22
log.Fatalf("could not listen on port 5000 %v", err)
23
}
24
}
Copied!
    我们创建了一个文件作为数据库。
    第 2 个参数 os.OpenFile 允许你定义打开文件的权限,在我们的例子中,O_RDWR 意味着我们想要读写权限,os.O_CREATE 是指如果文件不存在,则创建该文件。
    第 3 个参数表示设置文件的权限,在我们的示例中,所有用户都可以读写文件。(详情请参阅 superuser.com)
重启运行程序,现在将持久化数据到文件中。

更多的重构和性能问题

每当有人调用 GetLeague()GetPlayerScore() 时,我们就从头读取该文件,并将其解析为 JSON。我们不应该这样做,因为 FileSystemStore 完全负责 league 的状态。我们只是希望在开始时使用该文件来获取当前状态,并在数据更改时更新它。
我们可以创建一个构造函数,该构造函数可以为我们执行一些初始化操作,并将 league 作为值存储在我们的 FileSystemStore 中,以便在读取中使用。
1
type FileSystemPlayerStore struct {
2
database io.ReadWriteSeeker
3
league League
4
}
5
6
func NewFileSystemPlayerStore(database io.ReadWriteSeeker) *FileSystemPlayerStore {
7
database.Seek(0, 0)
8
league, _ := NewLeague(database)
9
return &FileSystemPlayerStore{
10
database:database,
11
league:league,
12
}
13
}
Copied!
这样,我们只需从磁盘读取一次。我们现在可以替换以前的所有从磁盘上获得 league 的调用,并且只使用 f.league
1
func (f *FileSystemPlayerStore) GetLeague() League {
2
return f.league
3
}
4
5
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
6
7
player := f.league.Find(name)
8
9
if player != nil {
10
return player.Wins
11
}
12
13
return 0
14
}
15
16
func (f *FileSystemPlayerStore) RecordWin(name string) {
17
player := f.league.Find(name)
18
19
if player != nil {
20
player.Wins++
21
} else {
22
f.league = append(f.league, Player{name, 1})
23
}
24
25
f.database.Seek(0, 0)
26
json.NewEncoder(f.database).Encode(f.league)
27
}
Copied!
运行测试将会提示初始化 FileSystemPlayerStore,因此只需通过调用我们新的构造函数来修复它们。

另一个问题

在我们处理文件的过程中有一些非常天真的行为,这可能会在以后产生非常严重的错误。
当我们 Recordwin 时,我们返回到文件的开头,然后写入新的数据,但是如果新的数据比之前的数据要小怎么办?
在我们目前的情况下,这是不可能的。我们从不编辑或删除得分,因此数据只会变得更大,但是这样的代码是不负责任的,出现删除场景的结果是不可想象的。
但是我们要怎么测试这种问题呢?我们需要做的是首先重构我们的代码,这样就可以将我们所编写的数据和正在写入的分开。然后我们可以分别测试它是否以我们期望的方式运行。
我们将创建一个新类型来封装我们的「当写入时,从头部开始」功能。我把它叫做 Tape。创建一个包含以下内容的新文件
1
package main
2
3
import "io"
4
5
type tape struct {
6
file io.ReadWriteSeeker
7
}
8
9
func (t *tape) Write(p []byte) (n int, err error) {
10
t.file.Seek(0, 0)
11
return t.file.Write(p)
12
}
Copied!
注意,我们现在只实现了 Write,因为它封装了 Seek 部分。这意味着我们的 FileSystemStore 可以只具有对 Writer 的引用。
1
type FileSystemPlayerStore struct {
2
database io.Writer
3
league League
4
}
Copied!
更新构造函数以使用 Tape
1
func NewFileSystemPlayerStore(database io.ReadWriteSeeker) *FileSystemPlayerStore {
2
database.Seek(0, 0)
3
league, _ := NewLeague(database)
4
5
return &FileSystemPlayerStore{
6
database: &tape{database},
7
league: league,
8
}
9
}
Copied!
最后,我们可以通过从 RecordWin 中删除 Seek 调用来获得我们想要的惊人回报。是的,这感觉并不多,但至少这意味着如果我们做任何其它类型的写入操作,我们可以依赖 write 来表达我们对它的需求。此外,它现在将允许我们分别测试可能存在问题的代码并修复它。
让我们编写一个测试,我们想用比原始内容更小的东西来更新文件的整个内容。在 tape_test.go 中:

首先编写测试

我们只需要创建一个文件,尝试用我们的 tape 来写,再读一遍,看看文件里有什么。
1
func TestTape_Write(t *testing.T) {
2
file, clean := createTempFile(t, "12345")
3
defer clean()
4
5
tape := &tape{file}
6
7
tape.Write([]byte("abc"))
8
9
file.Seek(0, 0)
10
newFileContents, _ := ioutil.ReadAll(file)
11
12
got := string(newFileContents)
13
want := "abc"
14
15
if got != want {
16
t.Errorf("got '%s' want '%s'", got, want)
17
}
18
}
Copied!

尝试运行测试

1
=== RUN TestTape_Write
2
--- FAIL: TestTape_Write (0.00s)
3
tape_test.go:23: got 'abc45' want 'abc'
Copied!
就像我们想的一样!它只写我们想要的数据,而不写其他数据。

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

os.File 文件有一个 truncate 函数,可以让我们有效地清空文件。我们应该能够调用它来得到我们想要的功能。
修改 tape 为以下内容
1
type tape struct {
2
file *os.File
3
}
4
5
func (t *tape) Write(p []byte) (n int, err error) {
6
t.file.Truncate(0)
7
t.file.Seek(0, 0)
8
return t.file.Write(p)
9
}
Copied!
编译器会在许多我们期望一个 io.ReadWriteSeeker 类型但是我们传入 *os.File 的地方失败。你现在应该可以自己修复这些问题了,但是如果你遇到困难,请检查源代码。
一旦重构完成,我们 TestTape_Write 的测试就应该通过了!

一个另外的小重构

RecordWin 中,我们有行 json.NewEncoder(f.database).Encode(f.league)
我们不需要在每次编写代码时创建一个新的编码器,我们可以在构造函数中初始化一个编码器并使用它。
在我们的类型中存储对编码器的引用。
1
type FileSystemPlayerStore struct {
2
database *json.Encoder
3
league League
4
}
Copied!
在构造器中初始化它
1
func NewFileSystemPlayerStore(file *os.File) *FileSystemPlayerStore {
2
file.Seek(0, 0)
3
league, _ := NewLeague(file)
4
5
return &FileSystemPlayerStore{
6
database: json.NewEncoder(&tape{file}),
7
league: league,
8
}
9
}
Copied!
RecordWin 中使用它。

刚刚我们不是打破了一些规则?测试私有的东西?没有接口?

测试私有的类型

的确,一般来说,你不应该测试私有的东西,因为这有时会导致你的测试与实现的耦合过于紧密,这可能会阻碍将来的重构。
然而,我们不能忘记测试应该给我们信心。
如果添加任何类型的编辑或删除功能,我们对这些实现是否能运行就没有信心了。我们不想留下这样的代码,特别是如果有不止一个人在处理这些代码,他们可能不知道我们最初的方法有什么缺点。
最后,这只是一个测试!如果我们决定改变它的工作方式,仅仅删除测试并不是什么灾难,但是我们至少实现了对未来维护者的要求。

接口

我们从使用 io.Reader 开始编写代码。因为那是对我们新的 PlayerStore 进行单元测试最简单的方法。当我们开发代码时,我们转而使用 io.ReadWriter 然后是 io.ReadWriteSeeker。然后我们发现,除了 *os.File 之外,标准库中没有任何实际实现的东西。我们本来决定编写自己的或者使用开源的库,但是仅仅为测试使用临时文件就显得很实用了。
最后我们需要 Truncate,它也在 *os.File 中。我们可以选择创建自己的接口实现这些需求。
1
type ReadWriteSeekTruncate interface {
2
io.ReadWriteSeeker
3
Truncate(size int64) error
4
}
Copied!
但这有什么好处呢?请记住,我们并不是在模拟,文件系统存储采取除 *os.File 之外的任何类型都是不现实的。所以我们不需要接口给我们的多态性。
不要害怕像我们这里所做的那样去改变类型和做新的实验。使用静态类型语言的好处是编译器可以帮助你完成每一个更改。

错误处理

在开始排序之前,我们应该确保对当前代码感到满意,并删除可能存在的任何技术债务。尽可能快地使用软件(脱离红色状态)是一个重要的原则,但这并不意味着我们应该忽略出错的场景!
如果我们回到 FileSystemStore.go。我们在构造函数中有 league, _:= NewLeague(f.database)
如果 NewLeague 无法从我们提供的io.Reader 中解析 league,它会返回一个错误。
在我们测试失败的时候,忽略这一点是很实际的。如果我们同时处理它,我们将同时处理两件事。
如果我们的构造函数能够返回一个错误,我们就这样做。
1
func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
2
file.Seek(0, 0)
3
league, err := NewLeague(file)
4
5
if err != nil {
6
return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
7
}
8
9
return &FileSystemPlayerStore{
10
database:&tape{file},
11
league:league,
12
}, nil
13
}
Copied!
请记住,提供有用的错误信息非常重要(就像你写的测试一样)。人们在网上开玩笑说大多数 Go 代码都是
1
if err != nil {
2
return err
3
}
Copied!
这绝对不是习惯用语。 为你的错误添加上下文信息(例如你正在做什么导致的错误)使操作你的软件更加容易。
如果你尝试编译,将会得到一些错误。
1
./main.go:18:35: multiple-value NewFileSystemPlayerStore() in single-value context
2
./FileSystemStore_test.go:35:36: multiple-value NewFileSystemPlayerStore() in single-value context
3
./FileSystemStore_test.go:57:36: multiple-value NewFileSystemPlayerStore() in single-value context
4
./FileSystemStore_test.go:70:36: multiple-value NewFileSystemPlayerStore() in single-value context
5
./FileSystemStore_test.go:85:36: multiple-value NewFileSystemPlayerStore() in single-value context
6
./server_integration_test.go:12:35: multiple-value NewFileSystemPlayerStore() in single-value context
Copied!
在 main 中,我们要退出程序,打印错误。
1
store, err := NewFileSystemPlayerStore(db)
2
3
if err != nil {
4
log.Fatalf("problem creating file system player store, %v ", err)
5
}
Copied!
在测试中,我们应该断言没有错误。我们可以编写辅助函数来协助处理。
1
func assertNoError(t *testing.T, err error) {
2
t.Helper()
3
if err != nil {
4
t.Fatalf("didnt expect an error but got one, %v", err)
5
}
6
}
Copied!
使用这个辅助函数处理其他编译问题。最后,你应该得到一个失败的测试。
1
=== RUN TestRecordingWinsAndRetrievingThem
2
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
3
server_integration_test.go:14: didnt expect an error but got one, problem loading player store from file /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db841037437, problem parsing league, EOF
Copied!
我们不能解析 league,因为文件是空的。我们以前没有出错,因为我们一直都忽略了它们。
通过将一些有效的 JSON 数据放入其中来修复我们的大型集成测试,然后我们可以为这个场景编写一个特定的测试。
1
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
2
database, cleanDatabase := createTempFile(t, `[]`)
3
//etc...
Copied!
现在所有的测试都通过了,我们需要处理文件为空的场景。

首先编写测试

1
t.Run("works with an empty file", func(t *testing.T) {
2
database, cleanDatabase := createTempFile(t, "")
3
defer cleanDatabase()
4
5
_, err := NewFileSystemPlayerStore(database)
6
7
assertNoError(t, err)
8
})
Copied!

尝试运行测试

1
=== RUN TestFileSystemStore/works_with_an_empty_file
2
--- FAIL: TestFileSystemStore/works_with_an_empty_file (0.00s)
3
FileSystemStore_test.go:108: didnt expect an error but got one, problem loading player store from file /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db019548018, problem parsing league, EOF
Copied!

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

将构造函数更改为以下内容
1
func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
2
3
file.Seek(0, 0)
4
5
info, err := file.Stat()
6
7
if err != nil {
8
return nil, fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
9
}
10
11
if info.Size()==0 {
12
file.Write([]byte("[]"))
13
file.Seek(0, 0)
14
}
15
16
league, err := NewLeague(file)
17
18
if err != nil {
19
return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
20
}
21
22
return &FileSystemPlayerStore{
23
database:&tape{file},
24
league:league,
25
}, nil
26
}
Copied!
file.Stat 返回我们的文件的统计数据。我们可以检查文件的大小,如果它是空的,我们就会编写一个空的 JSON 数组,然后 Seek 到开始位置,为剩下的代码做准备。

重构

我们的构造函数现在有点混乱,我们可以将初始化代码提取到函数中
1
func initialisePlayerDBFile(file *os.File) error {
2
file.Seek(0, 0)
3
4
info, err := file.Stat()
5
6
if err != nil {
7
return fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
8
}
9
10
if info.Size()==0 {
11
file.Write([]byte("[]"))
12
file.Seek(0, 0)
13
}
14
15
return nil
16
}
Copied!