IO 및 sorting
이전 챕터에서 우리는 어플리케이션에 새로운 엔드포인트
league
를 추가하는 과정을 계속 반복해왔다. 그 과정에서 JSON을 다루는 법, 타입을 임베딩하는 법 그리고 라우팅하는 법을 배울 수 있었다.우리의 프로덕트 오너는 서버가 재시작 될 때 소프트웨어가 선수들의 점수들을 잃을까 조금 불안해한다. 왜냐하면 우리가 스토어를 인메모리로 구현했기 때문이다. 게다가 그녀는 우리가
league
엔드포인트가 이긴 횟수를 기준으로 정렬한 선수들을 반환해야 하는 것을 이해하지 못하는 것이 만족스러워하지 않는다!// server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// PlayerStore는 선수들에 대한 점수 정보를 저장한다
type PlayerStore interface {
GetPlayerScore(name string) int
RecordWin(name string)
GetLeague() []Player
}
// Player는 이긴 횟수와 함께 이름을 저장한다
type Player struct {
Name string
Wins int
}
// PlayerServer는 사용자 정보를 위한 HTTP 인터페이스이다
type PlayerServer struct {
store PlayerStore
http.Handler
}
const jsonContentType = "application/json"
// NewPlayerServer는 라우팅이 정의된 PlayerServer를 생성한다
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) {
w.Header().Set("content-type", jsonContentType)
json.NewEncoder(w).Encode(p.store.GetLeague())
}
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)
}
// in_memory_player_store.go
package main
func NewInMemoryPlayerStore() *InMemoryPlayerStore {
return &InMemoryPlayerStore{map[string]int{}}
}
type InMemoryPlayerStore struct {
store map[string]int
}
func (i *InMemoryPlayerStore) GetLeague() []Player {
var league []Player
for name, wins := range i.store {
league = append(league, Player{name, wins})
}
return league
}
func (i *InMemoryPlayerStore) RecordWin(name string) {
i.store[name]++
}
func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {
return i.store[name]
}
// main.go
package main
import (
"log"
"net/http"
)
func main() {
server := NewPlayerServer(NewInMemoryPlayerStore())
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
코드에 해당하는 테스트들은 챕터 상단의 링크에서 확인할 수 있다.
이를 위해 사용할 수 있는 데이터베이스는 수십가지가 있지만 우리는 매우 간단한 접근 방식을 선택할 것 이다. 우리는 이 어플리케이션의 데이터를 파일에 JSON으로 저장할 것이다.
이 접근 방식은 데이터를 매우 이동이 쉬운 형태로 유지하고 상대적으로 구현이 쉽다.
이는 확장에 특별히 좋은 형태는 아니지만 프로토타입으로 지금으로서는 충분하다. 우리는
PlayerStore
를 추상화했기 때문에 만약 우리의 환경이 변하고 더 이상 적절하지 않다면 간단히 다른 무언가로 간단히 변경할 수 있다.지금 당장 우리는
InMemoryPlayerStore
를 유지할 것이기 때문에 통합 테스트들은 우리가 새로운 스토어를 개발하는 동안에도 계속 통과할 것이다. 우리는 새로운 구현이 통합 테스트를 충분히 통과할 것이라는 확신을 가지게 됬을 때 이를 교체하고 InMemoryPlayerStore
를 삭제할 것이다.이제 데이터를 읽고(
io.Reader
), 쓰는(io.Writer
) 표준 라이브러리들의 인터페이스와 실제 파일들을 사용하지 않고 이런 기능들을 테스트하기 위해 표준 라이브러리를 사용하는 법에 익숙해져야 한다.이 작업을 완료하기 위해 우리는
PlayStore
를 구현해야 한다. 그리고 스토어가 우리가 구현해야 하는 메서드를 호출할 수 있도록 하는 테스트를 작성해야 한다. GetLeague
부터 시작해보자.//file_system_store_test.go
func TestFileSystemStore(t *testing.T) {
t.Run("league from a reader", func(t *testing.T) {
database := strings.NewReader(`[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
store := FileSystemPlayerStore{database}
got := store.GetLeague()
want := []Player{
{"Cleo", 10},
{"Chris", 33},
}
assertLeague(t, got, want)
})
}
우리는
FileSystemPlayerStore
가 데이터를 읽을 수 있도록 하는 Reader
를 반환하는 strings.NewReader
를 사용중이다. main
에 파일 을 추가할 것이고, 이 파일 또한 Reader
이다.# github.com/quii/learn-go-with-tests/io/v1
./file_system_store_test.go:15:12: undefined: FileSystemPlayerStore
새로운 파일에
FileSystemPlayerStore
를 정의한다.//file_system_store.go
type FileSystemPlayerStore struct {}
다시 시도한다.
# github.com/quii/learn-go-with-tests/io/v1
./file_system_store_test.go:15:28: too many values in struct initializer
./file_system_store_test.go:17:15: store.GetLeague undefined (type FileSystemPlayerStore has no field or method GetLeague)
우리가
Reader
를 전달했지만 입력을 받을 준비가 되지 않았고, GetLeague
가 아직 정의되어 있지 않기 때문에 컴파일 에러가 발생한다.//file_system_store.go
type FileSystemPlayerStore struct {
database io.Reader
}
func (f *FileSystemPlayerStore) GetLeague() []Player {
return nil
}
한번 더 시도한다...
=== RUN TestFileSystemStore//league_from_a_reader
--- FAIL: TestFileSystemStore//league_from_a_reader (0.00s)
file_system_store_test.go:24: got [] want [{Cleo 10} {Chris 33}]
우리는 전에 Reader로부터 JSON을 읽어왔다.
//file_system_store.go
func (f *FileSystemPlayerStore) GetLeague() []Player {
var league []Player
json.NewDecoder(f.database).Decode(&league)
return league
}
테스트를 통과해야 한다.
우리는 이전에 이것을 했었다! 서버를 위한 테스트 코드는 응답으로부터 JSON을 디코딩 해야한다.
함수에 DRY(Do not Repeat Yourself)를 적용해보자.
league.go
라는 새로운 파일을 생성해 안에 넣는다.//league.go
func NewLeague(rdr io.Reader) ([]Player, error) {
var league []Player
err := json.NewDecoder(rdr).Decode(&league)
if err != nil {
err = fmt.Errorf("problem parsing league, %v", err)
}
return league, err
}
구현과
server_test.go
안에 있는 getLeagueFromResponse
테스트 헬퍼에서 이를 호출한다.//file_system_store.go
func (f *FileSystemPlayerStore) GetLeague() []Player {
league, _ := NewLeague(f.database)
return league
}
아직 파싱 에러를 처리할 방법을 가지고 있지는 않지만 계속 진행해보자.
우리의 구현에는 한가지 흠이 있다. 무엇보다도 우리 스스로
io.Reader
가 어떻게 정의되어 있는지 다시 생각해보자.type Reader interface {
Read(p []byte) (n int, err error)
}
파일에서 보이듯, 당신은 끝까지 바이트 단위로 읽어나가는 것을 생각해낼 수 있을 것이다. 만약
Read
를 두 번 시도한다면 어떻게 될까?아래 내용을 현재 테스트의 끝에 추가하자.
//file_system_store_test.go
// 다시 읽는다.
got = store.GetLeague()
assertLeague(t, got, want)
테스트가 통과하기를 원하지만, 만약 당신이 테스트를 실행했다면 통과하지 못했을 것이다.
문제는
Reader
가 끝에 다다랐을 때 더 이상 읽을 것이 없다는 것이다. 우리는 처음으로 돌아가라고 말해줄 방법이 필요하다.type ReadSeeker interface {
Reader
Seeker
}
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
괜찮아 보인다.
FileSystemPlayerStore
을 이 인터페이스로 바꿀 수 있을까?//file_system_store.go
type FileSystemPlayerStore struct {
database io.ReadSeeker
}
func (f *FileSystemPlayerStore) GetLeague() []Player {
f.database.Seek(0, 0)
league, _ := NewLeague(f.database)
return league
}
테스트를 실행해보자. 이제 테스트가 통과되었다! 운이 좋게도 우리가 테스트에 사용한
string.NewReader
도 ReadSeeker
를 구현하고 있어서 더 이상 변경할 필요가 없다.다음으로 우리는
GetPlayerScore
를 구현할 것이다.//file_system_store_test.go
t.Run("get player score", func(t *testing.T) {
database := strings.NewReader(`[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
store := FileSystemPlayerStore{database}
got := store.GetPlayerScore("Chris")
want := 33
if got != want {
t.Errorf("got %d want %d", got, want)
}
})
./file_system_store_test.go:38:15: store.GetPlayerScore undefined (type FileSystemPlayerStore has no field or method GetPlayerScore)
우리는 테스트가 컴파일 될 수 있도록 새로운 타입에 메서드를 추가해야 한다.
//file_system_store.go
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
return 0
}
이제 컴파일은 성공하고 테스트는 실패한다.
=== RUN TestFileSystemStore/get_player_score
--- FAIL: TestFileSystemStore//get_player_score (0.00s)
file_system_store_test.go:43: got 0 want 33
우리는 league를 순회하며 선수를 찾고 그들의 점수를 반환할 수 있다.
//file_system_store.go
func (f *FileSystemPlayerStore) GetPlayerScore(name string) int {
var wins int
for _, player := range f.GetLeague() {
if player.Name == name {
wins = player.Wins
break
}
}
return wins
}
당신은 테스트를 보조하기 위한 많은 리팩토링 방법들을 봤을 것이기 때문에 당신이 해낼 수 있도록 남겨둘 것이다.
//file_system_store_test.go
t.Run("get player score", func(t *testing.T) {
database := strings.NewReader(`[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
store := FileSystemPlayerStore{database}
got := store.GetPlayerScore("Chris")
want := 33
assertScoreEquals(t, got, want)
})
마지막으로
RecorddWin
으로 점수들을 기록해야 한다.우리의 접근 방법은 쓰기에 상당히 근시안적이다. 우리는 파일 안에 있는 JSON의 한 "줄"만을 (간단히) 업데이트할 수 없다. 그렇기 때문에 모든 쓰기마다 우리 데이터 베이스 전체의 새로운 표현을 저장해야만 한다.
어떻게 쓰기를 할 수 있을까? 우리는 보통
Writer
를 사용했지만, 우리는 이미 우리만의 ReadSeeker
를 가지고 있다. 잠재적으로 우리는 2개의 의존성을 가질 수 있지만, 표준 라이브러리는 이미 파일에 필요한 모든 작업을 수행할 수 있는 ReadWriteSeeker
인터페이스를 가지고 있다.우리의 타입을 바꿔보자.
//file_system_store.go
type FileSystemPlayerStore struct {
database io.ReadWriteSeeker
}
컴파일이 되는지 확 인하자.
./file_system_store_test.go:15:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
./file_system_store_test.go:36:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
strings.Reader
가 ReadWriteSeeker
를 구현하지 못한다는 것은 그리 놀라운 일이 아니다. 그렇다면 무엇을 해야할까?우리는 두가지를 선택할 수 있다.
- 각 각의 테스트를 위한 임시 파일을 생성한다.
*os.File
은ReadWriteSeeker
를 구현한다. Create a temporary file for each test.*os.File
implementsReadWriteSeeker
. 이 방법의 장점은 더 통합 테스트에 가까워진다는 것이다. 우리는 실제 파일 시스템에서 읽고 쓰고 있기 때문에 더 높은 수준의 신뢰성을 얻을 수 있습니다. 단점은 단위 테스트가 더 빠르고 일반적으로 간단하기 때문에 더 선호된다는 것이다. 또한 임시 파일들을 만들어내고 테스트 이후에 파일들이 지워졌는지 확인하기 위한 일들을 더 해야만한다.
이 중 특별히 틀린 답이 있다 생각하지는 않지만, 써드파티 라이브러리를 사용하는 것을 선택한다면 의존성 관리에 대해서 설명을 해야만 한다! 그러니 우리는 파일을 사용할 것이다.
테스트를 추가하기 전에
os.File
을 strings.Reader
로 바꿔서 테스트가 컴파일 될 수 있도록 해야한다.데이터가 포함된 임시 파일을 생성하는 헬퍼 함수를 만들어보자.
//file_system_store_test.go
func createTempFile(t testing.TB, initialData string) (io.ReadWriteSeeker, func()) {
t.Helper()
tmpfile, err := ioutil.TempFile("", "db")
if err != nil {
t.Fatalf("could not create temp file %v", err)
}
tmpfile.Write([]byte(initialData))
removeFile := func() {
tmpfile.Close()
os.Remove(tmpfile.Name())
}
return tmpfile, removeFile
}
TempFile은 우리가 사용할 수 있는 임시 파일을 생성한다. 우리가 넘긴
"db"
값은 만들어질 임의 파일 이름에 붙는 접두사입니다. 이렇게 함으로써 다른 파일들과 우연히 충돌하는 것을 방지합니다.당신은
ReadWriteSeeker
(파일) 뿐만 아니라 함수 또한 반환하고 있다는 것을 알고 있어야 합니다. 테스트가 끝나면 파일이 삭제되어야 한다는 것을 확실히 해야합니다. 에러가 발생하기 쉽고 Reader 무관심하기 때문에 테스트에 파일들의 세부사항들을 유출하고 싶지 않다. removeFile
함수를 반환함으로써, 헬퍼 안의 세부사항들 을 관리할 수 있고 모든 호출자는 defer cleanDatabase()
만 실행하기만 하면 된다.//file_system_store_test.go
func TestFileSystemStore(t *testing.T) {
t.Run("league from a reader", func(t *testing.T) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
defer cleanDatabase()
store := FileSystemPlayerStore{database}
got := store.GetLeague()
want := []Player{
{"Cleo", 10},
{"Chris", 33},
}
assertLeague(t, got, want)
// 다시 읽는다.
got = store.GetLeague()
assertLeague(t, got, want)
})
t.Run("get player score", func(t *testing.T) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
defer cleanDatabase()
store := FileSystemPlayerStore{database}
got := store.GetPlayerScore("Chris")
want := 33
assertScoreEquals(t, got, want)
})
}
테스트를 실행하면 통과될 것이다! 상당히 많은 변경사항들이 있지만 드디어 인터페이스의 정의가 끝났다는 느낌이 들고, 이제부터 새로운 테스트를 추가하기가 매우 쉬워졌다.
기존 선수들의 승리를 기록하는 첫번째 반복을 시작해보자.
//file_system_store_test.go
t.Run("store wins for existing players", func(t *testing.T) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
defer cleanDatabase()
store := FileSystemPlayerStore{database}
store.RecordWin("Chris")
got := store.GetPlayerScore("Chris")
want := 34
assertScoreEquals(t, got, want)
})
./file_system_store_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)
새로운 메서드를 추가한다.
//file_system_store.go
func (f *FileSystemPlayerStore) RecordWin(name string) {
}
=== RUN TestFileSystemStore/store_wins_for_existing_players
--- FAIL: TestFileSystemStore/store_wins_for_existing_players (0.00s)
file_system_store_test.go:71: got 33 want 34
구현이 비어 있어서 오래된 점수가 반환된다.
//file_system_store.go
func (f *FileSystemPlayerStore) RecordWin(name string) {
league := f.GetLeague()
for i, player := range league {
if player.Name == name {
league[i].Wins++
}
}
f.database.Seek(0,0)
json.NewEncoder(f.database).Encode(league)
}