IO 和排序
在上一章中,我们通过添加新的服务器访问地址 /league 来迭代我们的应用程序。在此过程中,我们学习了如何处理 JSON、嵌入类型和路由。
服务器重启后软件会丢失所有得分,产品负责人对此感到不安。这是因为我们存储的实现是在内存里。对于我们没有解释 /league 的访问地址应该按赢的次数排序返回玩家列表,她也很不满意。
目前为止的代码
// server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// PlayerStore stores score information about players
type PlayerStore interface {
GetPlayerScore(name string) int
RecordWin(name string)
GetLeague() []Player
}
// Player stores a name with a number of wins
type Player struct {
Name string
Wins int
}
// PlayerServer is a HTTP interface for player information
type PlayerServer struct {
store PlayerStore
http.Handler
}
const jsonContentType = "application/json"
// NewPlayerServer creates a PlayerServer with routing configured
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
}
func (p *PlayerServer) leagueHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(p.store.GetLeague())
w.Header().Set("content-type", jsonContentType)
w.WriteHeader(http.StatusOK)
}
func (p *PlayerServer) playersHandler(w http.ResponseWriter, r *http.Request) {
player := r.URL.Path[len("/players/"):]
switch r.Method {
case http.MethodPost:
p.processWin(w, player)
case http.MethodGet:
p.showScore(w, player)
}
}
func (p *PlayerServer) showScore(w http.ResponseWriter, player string) {
score := p.store.GetPlayerScore(player)
if score == 0 {
w.WriteHeader(http.StatusNotFound)
}
fmt.Fprint(w, score)
}
func (p *PlayerServer) processWin(w http.ResponseWriter, player string) {
p.store.RecordWin(player)
w.WriteHeader(http.StatusAccepted)
}你可以在本章顶部的链接中找到相应的测试。
存储数据
满足这个需求的数据库有很多,但我们会使用一种非常简单的方法。我们将把这个应用程序的数据以 JSON 的格式存储到文件中。
这使得数据具有很强的可移植性,并且实现起来相对简单。
它的伸缩性不高,但考虑到这是一个原型,至少现在是没问题的。如果我们的环境变得不再合适,换成其它的存储方式也会非常简单,因为我们使用的是 PlayerStore 的抽象。
我们将暂时保留 InMemoryPlayerStore,以便在开发新的存储实现时还能通过集成测试。一旦我们确信新实现足以通过集成测试,我们会替换然后删除 InMemoryPlayerStore。
首先编写测试
现在,你应该已经熟悉以下标准库相关的接口,用于读取数据(io.Reader)、写入数据(io.Writer)的接口,以及如何使用标准库来测试这些函数,而不必使用真正的文件。
为了完成这项工作,我们需要实现 PlayerStore,因此我们调用需要实现的方法来编写测试。我们将从 GetLeague 开始。
我们使用 strings.NewReader 会返回一个 Reader,这是我们的 FileSystemStore 函数中用来读取数据的。在 main 中我们将打开一个文件,它也是一个 Reader。
尝试运行测试
编写最少量的代码让测试运行起来,然后检查错误输出
让我们在新的文件中定义 FileSystemStore
再次尝试运行测试
报错是因为我们传入了不需要的 Reader 参数,并且 GetLeague 函数还没有定义。
再试一次...
编写足够的代码使测试通过
我们之前已经从 reader 中读取了 JSON 数据
现在测试应该通过了。
重构
我们以前就这样做过!服务器的测试代码必须从响应中解码 JSON 数据。
我们试着把它提炼为一个函数。
创建一个名为 league.go 的新文件,输入以下代码。
在我们的实现和 server_test.go 的辅助函数 getLeagueFromResponse 中调用这个函数
我们还没有处理解析错误的方法,但是我们还是继续向前推进吧。
寻找问题
我们的实现中有一个缺陷。首先注意 io.Reader 是如何定义的。
你可以想象它一个一个字节读取文件直到结束。如果你再读一遍会发生什么?
在当前测试的末尾添加以下内容。
我们希望它通过测试,但是如果你运行会发现它并没有通过。
这里的问题是我们的 Reader 已经到了结尾,没什么可读的了。我们需要一种方法让它回到开始位置。
ReadSeeker 是标准库中的另一个可以提供帮助的接口。
还记得嵌入吗?这是由 Reader 和 Seeker 组成的接口
这感觉不错,我们可以更改 FileSystemStore 来替代这个接口吗?
尝试运行测试,它现在通过了!很高兴我们在测试中使用的 string.NewReader 也实现了 ReadSeeker,所以我们不需要做任何其他的改变。
接下来我们将实现 GetPlayerScore。
首先编写测试
尝试运行测试
./FileSystemStore_test.go:38:15: store.GetPlayerScore undefined (type FileSystemPlayerStore has no field or method GetPlayerScore)
编写最少量的代码让测试运行起来,然后检查错误输出
我们需要将方法添加到新类型中,以便编译测试。
现在它可以编译并且测试失败
编写足够的代码使测试通过
我们可以遍历 league 寻找玩家并返回他们的得分
重构
你会看到许多辅助函数需要重构,这些将留给你来实现
最后,我们需要用 RecordWin 来记录得分。
首先编写测试
我们的方法写的相当短视。我们不能(很容易地)只更新文件中 JSON 的一「行」。我们需要在每次写入时存储整个数据新的表现形式。
我们应该怎么编写?我们通常会使用一个 Writer,但我们已经有了 ReadSeeker。我们可能有两个依赖项,但是标准库已经为我们提供了一个接口 ReadWriteSeeker,我们需要对文件做的处理它都可以满足。
我们来更新一下类型
查看是否通过编译
strings.Reader 没有实现 ReadWriteSeeker 并不奇怪,这时我们该怎么办呢?
我们有两个选择
为每个测试创建一个临时文件。
*os.File实现ReadWriteSeeker。好处是它变得更像集成测试,我们真的是从文件系统中读取和写入,所以我们对此更有信心。缺点是我们更喜欢单元测试,因为它们更快而且通常更简单。我们还需要做更多关于创建临时文件的工作,然后确保在测试之后删除它们。使用第三方库。github.com/mattetti 已经编写了一个 filebuffer 库,它实现了我们需要的接口,并且不触及文件系统。
这两种选择都没有问题,但是如果选择使用第三方库,我将不得不解释依赖管理!所以还是用文件代替吧。
在添加测试之前,我们需要通过用 os.File 替换 strings.Reader 来使其他测试编译通过。
让我们创建一个辅助函数,它将创建包含一些数据的临时文件
TempFile 创建一个临时文件供我们使用。我们传入的 "db" 值是在它将创建的随机文件名上加上的前缀。这是为了确保它不会与其他文件发生意外冲突。
你会注意到,我们不仅返回 ReadWriteSeeker(文件),而且还返回一个函数。我们需要确保在测试完成后删除该文件。我们不希望将文件的细节泄露到测试中,因为它很容易出错,对读者来说也没什么意思。通过返回 removeFile 函数,我们可以处理辅助函数中的细节,调用者只需运行 deferred cleanDatabase()。
运行测试,他们应该可以通过了!这里有大量的更改,但是现在感觉我们已经完成了接口定义,从现在开始添加新的测试应该非常容易了。
让我们执行第一次迭代,为现有的玩家记录一次胜利
尝试运行测试
./FileSystemStore_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)
编写最少量的代码让测试运行起来,然后检查错误输出
添加新的方法
我们的实现是空的,因此旧的得分将会返回。
编写足够的代码使测试通过
你可能会问,为什么我要用 league[i].Wins++ 而不是 player.Wins++。
当你在一个切片上取值时,将返回当前循环的索引(我们示例中的 i)和该索引中的元素的副本。更改副本 Wins 的值不会对我们迭代的 league 产生任何影响。因此,我们需要通过使用 league[i] 来获取对实际值的引用,然后更改该值。
如果你运行这些测试,它们应该可以通过了。
重构
在 GetPlayerScore 和 RecordWin 中,我们遍历 []Player,按名称查找 player。
我们可以在 FileSystemStore 的内部重构这个公共代码,但对我来说,它可能还有用,我们可以将其提升为新的类型。到目前为止,操作「League」都是用 []Player,但我们可以创造一种新的类型 League。这使其他开发人员更容易理解,然后我们可以将有用的方法附加到该类型上供我们使用。
在 league.go 添加一下代码
现在如果任何有 League 的人都可以很容易找到给定的玩家。
更改我们的 PlayerStore 接口以返回 League 而不是 []Player。试着重新运行测试,你会遇到编译问题,因为我们修改了接口。但是这很容易修复,只要将返回类型从 []Player 改为 League 就行了。
这使我们可以简化 FileSystemStore 的方法。
这看起来好多了,我们可以在 League 中找到其他可以被重构的功能。
我们现在需要处理记录新玩家获胜的场景。
首先编写测试
尝试并运行测试
编写足够的代码使测试通过
我们只需要处理查找返回 nil 的情况因为它找不到 player。
效果看起来不错,因此我们现在可以在集成测试中使用我们的新的 Store。这将使我们对软件的工作更有信心,然后我们可以删除冗余的 InMemoryPlayerStore。
在 TestRecordingWinsAndRetrievingThem 中,替换之前的记录。
测试通过后就可以删除 InMemoryPlayerStore 了。main.go 现在会出现编译问题,这将促使我们现在在「真实」代码中使用我们的新存储。
我们创建了一个文件作为数据库。
第 2 个参数
os.OpenFile允许你定义打开文件的权限,在我们的例子中,O_RDWR意味着我们想要读写权限,os.O_CREATE是指如果文件不存在,则创建该文件。第 3 个参数表示设置文件的权限,在我们的示例中,所有用户都可以读写文件。(详情请参阅 superuser.com)。
重启运行程序,现在将持久化数据到文件中。
更多的重构和性能问题
每当有人调用 GetLeague() 或 GetPlayerScore() 时,我们就从头读取该文件,并将其解析为 JSON。我们不应该这样做,因为 FileSystemStore 完全负责 league 的状态。我们只是希望在开始时使用该文件来获取当前状态,并在数据更改时更新它。
我们可以创建一个构造函数,该构造函数可以为我们执行一些初始化操作,并将 league 作为值存储在我们的 FileSystemStore 中,以便在读取中使用。
这样,我们只需从磁盘读取一次。我们现在可以替换以前的所有从磁盘上获得 league 的调用,并且只使用 f.league。
运行测试将会提示初始化 FileSystemPlayerStore,因此只需通过调用我们新的构造函数来修复它们。
另一个问题
在我们处理文件的过程中有一些非常天真的行为,这可能会在以后产生非常严重的错误。
当我们 Recordwin 时,我们返回到文件的开头,然后写入新的数据,但是如果新的数据比之前的数据要小怎么办?
在我们目前的情况下,这是不可能的。我们从不编辑或删除得分,因此数据只会变得更大,但是这样的代码是不负责任的,出现删除场景的结果是不可想象的。
但是我们要怎么测试这种问题呢?我们需要做的是首先重构我们的代码,这样就可以将我们所编写的数据和正在写入的分开。然后我们可以分别测试它是否以我们期望的方式运行。
我们将创建一个新类型来封装我们的「当写入时,从头部开始」功能。我把它叫做 Tape。创建一个包含以下内容的新文件
注意,我们现在只实现了 Write,因为它封装了 Seek 部分。这意味着我们的 FileSystemStore 可以只具有对 Writer 的引用。
更新构造函数以使用 Tape
最后,我们可以通过从 RecordWin 中删除 Seek 调用来获得我们想要的惊人回报。是的,这感觉并不多,但至少这意味着如果我们做任何其它类型的写入操作,我们可以依赖 write 来表达我们对它的需求。此外,它现在将允许我们分别测试可能存在问题的代码并修复它。
让我们编写一个测试,我们想用比原始内容更小的东西来更新文件的整个内容。在 tape_test.go 中:
首先编写测试
我们只需要创建一个文件,尝试用我们的 tape 来写,再读一遍,看看文件里有什么。
尝试运行测试
就像我们想的一样!它只写我们想要的数据,而不写其他数据。
编写足够的代码使测试通过
os.File 文件有一个 truncate 函数,可以让我们有效地清空文件。我们应该能够调用它来得到我们想要的功能。
修改 tape 为以下内容
编译器会在许多我们期望一个 io.ReadWriteSeeker 类型但是我们传入 *os.File 的地方失败。你现在应该可以自己修复这些问题了,但是如果你遇到困难,请检查源代码。
一旦重构完成,我们 TestTape_Write 的测试就应该通过了!
一个另外的小重构
在 RecordWin 中,我们有行 json.NewEncoder(f.database).Encode(f.league)。
我们不需要在每次编写代码时创建一个新的编码器,我们可以在构造函数中初始化一个编码器并使用它。
在我们的类型中存储对编码器的引用。
在构造器中初始化它
在 RecordWin 中使用它。
刚刚我们不是打破了一些规则?测试私有的东西?没有接口?
测试私有的类型
的确,一般来说,你不应该测试私有的东西,因为这有时会导致你的测试与实现的耦合过于紧密,这可能会阻碍将来的重构。
然而,我们不能忘记测试应该给我们信心。
如果添加任何类型的编辑或删除功能,我们对这些实现是否能运行就没有信心了。我们不想留下这样的代码,特别是如果有不止一个人在处理这些代码,他们可能不知道我们最初的方法有什么缺点。
最后,这只是一个测试!如果我们决定改变它的工作方式,仅仅删除测试并不是什么灾难,但是我们至少实现了对未来维护者的要求。
接口
我们从使用 io.Reader 开始编写代码。因为那是对我们新的 PlayerStore 进行单元测试最简单的方法。当我们开发代码时,我们转而使用 io.ReadWriter 然后是 io.ReadWriteSeeker。然后我们发现,除了 *os.File 之外,标准库中没有任何实际实现的东西。我们本来决定编写自己的或者使用开源的库,但是仅仅为测试使用临时文件就显得很实用了。
最后我们需要 Truncate,它也在 *os.File 中。我们可以选择创建自己的接口实现这些需求。
但这有什么好处呢?请记住,我们并不是在模拟,文件系统存储采取除 *os.File 之外的任何类型都是不现实的。所以我们不需要接口给我们的多态性。
不要害怕像我们这里所做的那样去改变类型和做新的实验。使用静态类型语言的好处是编译器可以帮助你完成每一个更改。
错误处理
在开始排序之前,我们应该确保对当前代码感到满意,并删除可能存在的任何技术债务。尽可能快地使用软件(脱离红色状态)是一个重要的原则,但这并不意味着我们应该忽略出错的场景!
如果我们回到 FileSystemStore.go。我们在构造函数中有 league, _:= NewLeague(f.database)。
如果 NewLeague 无法从我们提供的io.Reader 中解析 league,它会返回一个错误。
在我们测试失败的时候,忽略这一点是很实际的。如果我们同时处理它,我们将同时处理两件事。
如果我们的构造函数能够返回一个错误,我们就这样做。
请记住,提供有用的错误信息非常重要(就像你写的测试一样)。人们在网上开玩笑说大多数 Go 代码都是
这绝对不是习惯用语。 为你的错误添加上下文信息(例如你正在做什么导致的错误)使操作你的软件更加容易。
如果你尝试编译,将会得到一些错误。
在 main 中,我们要退出程序,打印错误。
在测试中,我们应该断言没有错误。我们可以编写辅助函数来协助处理。
使用这个辅助函数处理其他编译问题。最后,你应该得到一个失败的测试。
我们不能解析 league,因为文件是空的。我们以前没有出错,因为我们一直都忽略了它们。
通过将一些有效的 JSON 数据放入其中来修复我们的大型集成测试,然后我们可以为这个场景编写一个特定的测试。
现在所有的测试都通过了,我们需要处理文件为空的场景。
首先编写测试
尝试运行测试
编写足够的代码使测试通过
将构造函数更改为以下内容
file.Stat 返回我们的文件的统计数据。我们可以检查文件的大小,如果它是空的,我们就会编写一个空的 JSON 数组,然后 Seek 到开始位置,为剩下的代码做准备。
重构
我们的构造函数现在有点混乱,我们可以将初始化代码提取到函数中
排序
我们的产品负责人想让 /league 返回按得分排序的玩家。
这里主要要做的决定是,在软件的什么位置处理这个问题。如果我们使用的是「真实的」数据库,我们会使用像 ORDER BY 这样的东西,所以排序非常快,所以出于这个原因,应该由 PlayerStore 的实现负责。
首先编写测试
我们可以在 TestFileSystemStore 中的第一个测试上更新断言
JSON 输入的顺序是错误的,我们的 want 将检查它是否以正确的顺序返回给调用者。
尝试运行测试
编写足够的代码使测试通过
根据给定的比较函数,Slice 对提供的切片进行排序。
真的很简单!
总结
讨论的内容
Seeker接口以及它与Reader和Writer的关系。处理文件读写。
为测试创建辅助函数,隐藏文件中所有杂乱的内容。
使用
sort.Slice对切片排序。利用编译器帮助我们安全地对应用程序进行结构更改。
打破规则
软件工程中的大多数规则并非铁律,只是 80% 的时间在工作中都是最佳实践。
当我们发现以前不测试内部函数的「规则」对我们没有帮助时,我们就打破了这个规则。
当打破规则时,了解你所做的权衡是很重要的。在我们的例子中这样做没有问题,因为它只是一个测试,如果不这样做的话,将很难执行这个场景。
为了能够打破规则,你首先必须理解它们。可以跟学习吉他做类比,不管你认为自己多有创意,你都必须理解和练习基础。
软件的功能
我们创建了一个 HTTP API,你可以利用它创建玩家并增加他们的得分。
我们可以将包含每个人得分的联盟数据作为 JSON 返回。
数据以 JSON 文件的形式存储。
作者:Chris James 译者:Donng 校对:pityonline
Last updated