IO 和排序

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

上一章中,我们通过添加新的服务器访问地址 /league 来迭代我们的应用程序。在此过程中,我们学习了如何处理 JSON、嵌入类型和路由。

服务器重启后软件会丢失所有得分,产品负责人对此感到不安。这是因为我们存储的实现是在内存里。对于我们没有解释 /league 的访问地址应该按赢的次数排序返回玩家列表,她也很不满意。

目前为止的代码

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

你可以在本章顶部的链接中找到相应的测试。

存储数据

满足这个需求的数据库有很多,但我们会使用一种非常简单的方法。我们将把这个应用程序的数据以 JSON 的格式存储到文件中。

这使得数据具有很强的可移植性,并且实现起来相对简单。

它的伸缩性不高,但考虑到这是一个原型,至少现在是没问题的。如果我们的环境变得不再合适,换成其它的存储方式也会非常简单,因为我们使用的是 PlayerStore 的抽象。

我们将暂时保留 InMemoryPlayerStore,以便在开发新的存储实现时还能通过集成测试。一旦我们确信新实现足以通过集成测试,我们会替换然后删除 InMemoryPlayerStore

首先编写测试

现在,你应该已经熟悉以下标准库相关的接口,用于读取数据(io.Reader)、写入数据(io.Writer)的接口,以及如何使用标准库来测试这些函数,而不必使用真正的文件。

为了完成这项工作,我们需要实现 PlayerStore,因此我们调用需要实现的方法来编写测试。我们将从 GetLeague 开始。

  1. func TestFileSystemStore(t *testing.T) {
  2. t.Run("/league from a reader", func(t *testing.T) {
  3. database := strings.NewReader(`[
  4. {"Name": "Cleo", "Wins": 10},
  5. {"Name": "Chris", "Wins": 33}]`)
  6. store := FileSystemStore{database}
  7. got := store.GetLeague()
  8. want := []Player{
  9. {"Cleo", 10},
  10. {"Chris", 33},
  11. }
  12. assertLeague(t, got, want)
  13. })
  14. }

我们使用 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

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

让我们在新的文件中定义 FileSystemStore

  1. type FileSystemStore struct {}

再次尝试运行测试

  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)

报错是因为我们传入了不需要的 Reader 参数,并且 GetLeague 函数还没有定义。

  1. type FileSystemStore struct {
  2. database io.Reader
  3. }
  4. func (f *FileSystemStore) GetLeague() []Player {
  5. return nil
  6. }

再试一次…

  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}]

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

我们之前已经从 reader 中读取了 JSON 数据

  1. func (f *FileSystemStore) GetLeague() []Player {
  2. var league []Player
  3. json.NewDecoder(f.database).Decode(&league)
  4. return league
  5. }

现在测试应该通过了。

重构

我们以前就这样做过!服务器的测试代码必须从响应中解码 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. return league, err
  8. }

在我们的实现和 server_test.go 的辅助函数 getLeagueFromResponse 中调用这个函数

  1. func (f *FileSystemStore) GetLeague() []Player {
  2. league, _ := NewLeague(f.database)
  3. return league
  4. }

我们还没有处理解析错误的方法,但是我们还是继续向前推进吧。

寻找问题

我们的实现中有一个缺陷。首先注意 io.Reader 是如何定义的。

  1. type Reader interface {
  2. Read(p []byte) (n int, err error)
  3. }

你可以想象它一个一个字节读取文件直到结束。如果你再读一遍会发生什么?

在当前测试的末尾添加以下内容。

  1. // read again
  2. got = store.GetLeague()
  3. assertLeague(t, got, want)

我们希望它通过测试,但是如果你运行会发现它并没有通过。

这里的问题是我们的 Reader 已经到了结尾,没什么可读的了。我们需要一种方法让它回到开始位置。

ReadSeeker 是标准库中的另一个可以提供帮助的接口。

  1. type ReadSeeker interface {
  2. Reader
  3. Seeker
  4. }

还记得嵌入吗?这是由 ReaderSeeker 组成的接口

  1. type Seeker interface {
  2. Seek(offset int64, whence int) (int64, error)
  3. }

这感觉不错,我们可以更改 FileSystemStore 来替代这个接口吗?

  1. type FileSystemStore struct {
  2. database io.ReadSeeker
  3. }
  4. func (f *FileSystemStore) GetLeague() []Player {
  5. f.database.Seek(0, 0)
  6. league, _ := NewLeague(f.database)
  7. return league
  8. }

尝试运行测试,它现在通过了!很高兴我们在测试中使用的 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. store := FileSystemPlayerStore{database}
  6. got := store.GetPlayerScore("Chris")
  7. want := 33
  8. if got != want {
  9. t.Errorf("got %d want %d", got, want)
  10. }
  11. })

尝试运行测试

./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. }

现在它可以编译并且测试失败

  1. === RUN TestFileSystemStore/get_player_score
  2. --- FAIL: TestFileSystemStore//get_player_score (0.00s)
  3. FileSystemStore_test.go:43: got 0 want 33

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

我们可以遍历 league 寻找玩家并返回他们的得分

  1. func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
  2. var wins int
  3. for _, player := range f.GetLeague() {
  4. if player.Name == name {
  5. wins = player.Wins
  6. break
  7. }
  8. }
  9. return wins
  10. }

重构

你会看到许多辅助函数需要重构,这些将留给你来实现

  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. store := FileSystemPlayerStore{database}
  6. got := store.GetPlayerScore("Chris")
  7. want := 33
  8. assertScoreEquals(t, got, want)
  9. })

最后,我们需要用 RecordWin 来记录得分。

首先编写测试

我们的方法写的相当短视。我们不能(很容易地)只更新文件中 JSON 的一「行」。我们需要在每次写入时存储整个数据新的表现形式。

我们应该怎么编写?我们通常会使用一个 Writer,但我们已经有了 ReadSeeker。我们可能有两个依赖项,但是标准库已经为我们提供了一个接口 ReadWriteSeeker,我们需要对文件做的处理它都可以满足。

我们来更新一下类型

  1. type FileSystemPlayerStore struct {
  2. database io.ReadWriteSeeker
  3. }

查看是否通过编译

  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)

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. tmpfile, err := ioutil.TempFile("", "db")
  4. if err != nil {
  5. t.Fatalf("could not create temp file %v", err)
  6. }
  7. tmpfile.Write([]byte(initialData))
  8. removeFile := func() {
  9. os.Remove(tmpfile.Name())
  10. }
  11. return tmpfile, removeFile
  12. }

TempFile 创建一个临时文件供我们使用。我们传入的 "db" 值是在它将创建的随机文件名上加上的前缀。这是为了确保它不会与其他文件发生意外冲突。

你会注意到,我们不仅返回 ReadWriteSeeker(文件),而且还返回一个函数。我们需要确保在测试完成后删除该文件。我们不希望将文件的细节泄露到测试中,因为它很容易出错,对读者来说也没什么意思。通过返回 removeFile 函数,我们可以处理辅助函数中的细节,调用者只需运行 deferred cleanDatabase()

  1. func TestFileSystemStore(t *testing.T) {
  2. t.Run("league from a reader", func(t *testing.T) {
  3. database, cleanDatabase := createTempFile(t, `[
  4. {"Name": "Cleo", "Wins": 10},
  5. {"Name": "Chris", "Wins": 33}]`)
  6. defer cleanDatabase()
  7. store := FileSystemPlayerStore{database}
  8. got := store.GetLeague()
  9. want := []Player{
  10. {"Cleo", 10},
  11. {"Chris", 33},
  12. }
  13. assertLeague(t, got, want)
  14. // read again
  15. got = store.GetLeague()
  16. assertLeague(t, got, want)
  17. })
  18. t.Run("get player score", func(t *testing.T) {
  19. database, cleanDatabase := createTempFile(t, `[
  20. {"Name": "Cleo", "Wins": 10},
  21. {"Name": "Chris", "Wins": 33}]`)
  22. defer cleanDatabase()
  23. store := FileSystemPlayerStore{database}
  24. got := store.GetPlayerScore("Chris")
  25. want := 33
  26. assertScoreEquals(t, got, want)
  27. })
  28. }

运行测试,他们应该可以通过了!这里有大量的更改,但是现在感觉我们已经完成了接口定义,从现在开始添加新的测试应该非常容易了。

让我们执行第一次迭代,为现有的玩家记录一次胜利

  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. store := FileSystemPlayerStore{database}
  7. store.RecordWin("Chris")
  8. got := store.GetPlayerScore("Chris")
  9. want := 34
  10. assertScoreEquals(t, got, want)
  11. })

尝试运行测试

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

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

添加新的方法

  1. func (f *FileSystemPlayerStore) RecordWin(name string) {
  2. }
  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

我们的实现是空的,因此旧的得分将会返回。

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

  1. func (f *FileSystemPlayerStore) RecordWin(name string) {
  2. league := f.GetLeague()
  3. for i, player := range league {
  4. if player.Name == name {
  5. league[i].Wins++
  6. }
  7. }
  8. f.database.Seek(0,0)
  9. json.NewEncoder(f.database).Encode(league)
  10. }

你可能会问,为什么我要用 league[i].Wins++ 而不是 player.Wins++

当你在一个切片上取值时,将返回当前循环的索引(我们示例中的 i)和该索引中的元素的副本。更改副本 Wins 的值不会对我们迭代的 league 产生任何影响。因此,我们需要通过使用 league[i] 来获取对实际值的引用,然后更改该值。

如果你运行这些测试,它们应该可以通过了。

重构

GetPlayerScoreRecordWin 中,我们遍历 []Player,按名称查找 player

我们可以在 FileSystemStore 的内部重构这个公共代码,但对我来说,它可能还有用,我们可以将其提升为新的类型。到目前为止,操作「League」都是用 []Player,但我们可以创造一种新的类型 League。这使其他开发人员更容易理解,然后我们可以将有用的方法附加到该类型上供我们使用。

league.go 添加一下代码

  1. type League []Player
  2. func (l League) Find(name string) *Player {
  3. for i, p := range l {
  4. if p.Name==name {
  5. return &l[i]
  6. }
  7. }
  8. return nil
  9. }

现在如果任何有 League 的人都可以很容易找到给定的玩家。

更改我们的 PlayerStore 接口以返回 League 而不是 []Player。试着重新运行测试,你会遇到编译问题,因为我们修改了接口。但是这很容易修复,只要将返回类型从 []Player 改为 League 就行了。

这使我们可以简化 FileSystemStore 的方法。

  1. func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
  2. player := f.GetLeague().Find(name)
  3. if player != nil {
  4. return player.Wins
  5. }
  6. return 0
  7. }
  8. func (f *FileSystemPlayerStore) RecordWin(name string) {
  9. league := f.GetLeague()
  10. player := league.Find(name)
  11. if player != nil {
  12. player.Wins++
  13. }
  14. f.database.Seek(0, 0)
  15. json.NewEncoder(f.database).Encode(league)
  16. }

这看起来好多了,我们可以在 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. store := FileSystemPlayerStore{database}
  7. store.RecordWin("Pepper")
  8. got := store.GetPlayerScore("Pepper")
  9. want := 1
  10. assertScoreEquals(t, got, want)
  11. })

尝试并运行测试

  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

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

我们只需要处理查找返回 nil 的情况因为它找不到 player

  1. func (f *FileSystemPlayerStore) RecordWin(name string) {
  2. league := f.GetLeague()
  3. player := league.Find(name)
  4. if player != nil {
  5. player.Wins++
  6. } else {
  7. league = append(league, Player{name, 1})
  8. }
  9. f.database.Seek(0, 0)
  10. json.NewEncoder(f.database).Encode(league)
  11. }

效果看起来不错,因此我们现在可以在集成测试中使用我们的新的 Store。这将使我们对软件的工作更有信心,然后我们可以删除冗余的 InMemoryPlayerStore

TestRecordingWinsAndRetrievingThem 中,替换之前的记录。

  1. database, cleanDatabase := createTempFile(t, "")
  2. defer cleanDatabase()
  3. store := &FileSystemPlayerStore{database}

测试通过后就可以删除 InMemoryPlayerStore 了。main.go 现在会出现编译问题,这将促使我们现在在「真实」代码中使用我们的新存储。

  1. package main
  2. import (
  3. "log"
  4. "net/http"
  5. "os"
  6. )
  7. const dbFileName = "game.db.json"
  8. func main() {
  9. db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)
  10. if err != nil {
  11. log.Fatalf("problem opening %s %v", dbFileName, err)
  12. }
  13. store := &FileSystemPlayerStore{db}
  14. server := NewPlayerServer(store)
  15. if err := http.ListenAndServe(":5000", server); err != nil {
  16. log.Fatalf("could not listen on port 5000 %v", err)
  17. }
  18. }
  • 我们创建了一个文件作为数据库。
  • 第 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. func NewFileSystemPlayerStore(database io.ReadWriteSeeker) *FileSystemPlayerStore {
  6. database.Seek(0, 0)
  7. league, _ := NewLeague(database)
  8. return &FileSystemPlayerStore{
  9. database:database,
  10. league:league,
  11. }
  12. }

这样,我们只需从磁盘读取一次。我们现在可以替换以前的所有从磁盘上获得 league 的调用,并且只使用 f.league

  1. func (f *FileSystemPlayerStore) GetLeague() League {
  2. return f.league
  3. }
  4. func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
  5. player := f.league.Find(name)
  6. if player != nil {
  7. return player.Wins
  8. }
  9. return 0
  10. }
  11. func (f *FileSystemPlayerStore) RecordWin(name string) {
  12. player := f.league.Find(name)
  13. if player != nil {
  14. player.Wins++
  15. } else {
  16. f.league = append(f.league, Player{name, 1})
  17. }
  18. f.database.Seek(0, 0)
  19. json.NewEncoder(f.database).Encode(f.league)
  20. }

运行测试将会提示初始化 FileSystemPlayerStore,因此只需通过调用我们新的构造函数来修复它们。

另一个问题

在我们处理文件的过程中有一些非常天真的行为,这可能会在以后产生非常严重的错误。

当我们 Recordwin 时,我们返回到文件的开头,然后写入新的数据,但是如果新的数据比之前的数据要小怎么办?

在我们目前的情况下,这是不可能的。我们从不编辑或删除得分,因此数据只会变得更大,但是这样的代码是不负责任的,出现删除场景的结果是不可想象的。

但是我们要怎么测试这种问题呢?我们需要做的是首先重构我们的代码,这样就可以将我们所编写的数据和正在写入的分开。然后我们可以分别测试它是否以我们期望的方式运行。

我们将创建一个新类型来封装我们的「当写入时,从头部开始」功能。我把它叫做 Tape。创建一个包含以下内容的新文件

  1. package main
  2. import "io"
  3. type tape struct {
  4. file io.ReadWriteSeeker
  5. }
  6. func (t *tape) Write(p []byte) (n int, err error) {
  7. t.file.Seek(0, 0)
  8. return t.file.Write(p)
  9. }

注意,我们现在只实现了 Write,因为它封装了 Seek 部分。这意味着我们的 FileSystemStore 可以只具有对 Writer 的引用。

  1. type FileSystemPlayerStore struct {
  2. database io.Writer
  3. league League
  4. }

更新构造函数以使用 Tape

  1. func NewFileSystemPlayerStore(database io.ReadWriteSeeker) *FileSystemPlayerStore {
  2. database.Seek(0, 0)
  3. league, _ := NewLeague(database)
  4. return &FileSystemPlayerStore{
  5. database: &tape{database},
  6. league: league,
  7. }
  8. }

最后,我们可以通过从 RecordWin 中删除 Seek 调用来获得我们想要的惊人回报。是的,这感觉并不多,但至少这意味着如果我们做任何其它类型的写入操作,我们可以依赖 write 来表达我们对它的需求。此外,它现在将允许我们分别测试可能存在问题的代码并修复它。

让我们编写一个测试,我们想用比原始内容更小的东西来更新文件的整个内容。在 tape_test.go 中:

首先编写测试

我们只需要创建一个文件,尝试用我们的 tape 来写,再读一遍,看看文件里有什么。

  1. func TestTape_Write(t *testing.T) {
  2. file, clean := createTempFile(t, "12345")
  3. defer clean()
  4. tape := &tape{file}
  5. tape.Write([]byte("abc"))
  6. file.Seek(0, 0)
  7. newFileContents, _ := ioutil.ReadAll(file)
  8. got := string(newFileContents)
  9. want := "abc"
  10. if got != want {
  11. t.Errorf("got '%s' want '%s'", got, want)
  12. }
  13. }

尝试运行测试

  1. === RUN TestTape_Write
  2. --- FAIL: TestTape_Write (0.00s)
  3. tape_test.go:23: got 'abc45' want 'abc'

就像我们想的一样!它只写我们想要的数据,而不写其他数据。

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

os.File 文件有一个 truncate 函数,可以让我们有效地清空文件。我们应该能够调用它来得到我们想要的功能。

修改 tape 为以下内容

  1. type tape struct {
  2. file *os.File
  3. }
  4. func (t *tape) Write(p []byte) (n int, err error) {
  5. t.file.Truncate(0)
  6. t.file.Seek(0, 0)
  7. return t.file.Write(p)
  8. }

编译器会在许多我们期望一个 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. }

在构造器中初始化它

  1. func NewFileSystemPlayerStore(file *os.File) *FileSystemPlayerStore {
  2. file.Seek(0, 0)
  3. league, _ := NewLeague(file)
  4. return &FileSystemPlayerStore{
  5. database: json.NewEncoder(&tape{file}),
  6. league: league,
  7. }
  8. }

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. }

但这有什么好处呢?请记住,我们并不是在模拟,文件系统存储采取除 *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. if err != nil {
  5. return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
  6. }
  7. return &FileSystemPlayerStore{
  8. database:&tape{file},
  9. league:league,
  10. }, nil
  11. }

请记住,提供有用的错误信息非常重要(就像你写的测试一样)。人们在网上开玩笑说大多数 Go 代码都是

  1. if err != nil {
  2. return err
  3. }

这绝对不是习惯用语。 为你的错误添加上下文信息(例如你正在做什么导致的错误)使操作你的软件更加容易。

如果你尝试编译,将会得到一些错误。

  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

在 main 中,我们要退出程序,打印错误。

  1. store, err := NewFileSystemPlayerStore(db)
  2. if err != nil {
  3. log.Fatalf("problem creating file system player store, %v ", err)
  4. }

在测试中,我们应该断言没有错误。我们可以编写辅助函数来协助处理。

  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. }

使用这个辅助函数处理其他编译问题。最后,你应该得到一个失败的测试。

  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

我们不能解析 league,因为文件是空的。我们以前没有出错,因为我们一直都忽略了它们。

通过将一些有效的 JSON 数据放入其中来修复我们的大型集成测试,然后我们可以为这个场景编写一个特定的测试。

  1. func TestRecordingWinsAndRetrievingThem(t *testing.T) {
  2. database, cleanDatabase := createTempFile(t, `[]`)
  3. //etc...

现在所有的测试都通过了,我们需要处理文件为空的场景。

首先编写测试

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

尝试运行测试

  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

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

将构造函数更改为以下内容

  1. func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
  2. file.Seek(0, 0)
  3. info, err := file.Stat()
  4. if err != nil {
  5. return nil, fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
  6. }
  7. if info.Size()==0 {
  8. file.Write([]byte("[]"))
  9. file.Seek(0, 0)
  10. }
  11. league, err := NewLeague(file)
  12. if err != nil {
  13. return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
  14. }
  15. return &FileSystemPlayerStore{
  16. database:&tape{file},
  17. league:league,
  18. }, nil
  19. }

file.Stat 返回我们的文件的统计数据。我们可以检查文件的大小,如果它是空的,我们就会编写一个空的 JSON 数组,然后 Seek 到开始位置,为剩下的代码做准备。

重构

我们的构造函数现在有点混乱,我们可以将初始化代码提取到函数中

  1. func initialisePlayerDBFile(file *os.File) error {
  2. file.Seek(0, 0)
  3. info, err := file.Stat()
  4. if err != nil {
  5. return fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
  6. }
  7. if info.Size()==0 {
  8. file.Write([]byte("[]"))
  9. file.Seek(0, 0)
  10. }
  11. return nil
  12. }
  1. func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
  2. err := initialisePlayerDBFile(file)
  3. if err != nil {
  4. return nil, fmt.Errorf("problem initialising player db file, %v", err)
  5. }
  6. league, err := NewLeague(file)
  7. if err != nil {
  8. return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
  9. }
  10. return &FileSystemPlayerStore{
  11. database:&tape{file},
  12. league:league,
  13. }, nil
  14. }

排序

我们的产品负责人想让 /league 返回按得分排序的玩家。

这里主要要做的决定是,在软件的什么位置处理这个问题。如果我们使用的是「真实的」数据库,我们会使用像 ORDER BY 这样的东西,所以排序非常快,所以出于这个原因,应该由 PlayerStore 的实现负责。

首先编写测试

我们可以在 TestFileSystemStore 中的第一个测试上更新断言

  1. t.Run("league sorted", func(t *testing.T) {
  2. database, cleanDatabase := createTempFile(t, `[
  3. {"Name": "Cleo", "Wins": 10},
  4. {"Name": "Chris", "Wins": 33}]`)
  5. defer cleanDatabase()
  6. store := FileSystemPlayerStore{database}
  7. got, err := store.GetLeague()
  8. assertNoError(t, err)
  9. want := []Player{
  10. {"Chris", 33},
  11. {"Cleo", 10},
  12. }
  13. assertLeague(t, got, want)
  14. // read again
  15. got, err = store.GetLeague()
  16. assertNoError(t, err)
  17. assertLeague(t, got, want)
  18. })

JSON 输入的顺序是错误的,我们的 want 将检查它是否以正确的顺序返回给调用者。

尝试运行测试

  1. === RUN TestFileSystemStore/league_from_a_reader,_sorted
  2. --- FAIL: TestFileSystemStore/league_from_a_reader,_sorted (0.00s)
  3. FileSystemStore_test.go:46: got [{Cleo 10} {Chris 33}] want [{Chris 33} {Cleo 10}]
  4. FileSystemStore_test.go:51: got [{Cleo 10} {Chris 33}] want [{Chris 33} {Cleo 10}]

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

  1. func (f *FileSystemPlayerStore) GetLeague() League {
  2. sort.Slice(f.league, func(i, j int) bool {
  3. return f.league[i].Wins > f.league[j].Wins
  4. })
  5. return f.league
  6. }

sort.Slice

根据给定的比较函数,Slice 对提供的切片进行排序。

真的很简单!

总结

讨论的内容

  • Seeker 接口以及它与 ReaderWriter 的关系。
  • 处理文件读写。
  • 为测试创建辅助函数,隐藏文件中所有杂乱的内容。
  • 使用 sort.Slice 对切片排序。
  • 利用编译器帮助我们安全地对应用程序进行结构更改。

打破规则

  • 软件工程中的大多数规则并非铁律,只是 80% 的时间在工作中都是最佳实践。
  • 当我们发现以前不测试内部函数的「规则」对我们没有帮助时,我们就打破了这个规则。
  • 当打破规则时,了解你所做的权衡是很重要的。在我们的例子中这样做没有问题,因为它只是一个测试,如果不这样做的话,将很难执行这个场景。
  • 为了能够打破规则,你首先必须理解它们。可以跟学习吉他做类比,不管你认为自己多有创意,你都必须理解和练习基础。

软件的功能

  • 我们创建了一个 HTTP API,你可以利用它创建玩家并增加他们的得分。
  • 我们可以将包含每个人得分的联盟数据作为 JSON 返回。
  • 数据以 JSON 文件的形式存储。

作者:Chris James译者:Donng校对:pityonline

本文由 GCTT 原创编译,Go 中文网 荣誉推出