본문 바로가기
Language/Go

Go - Reading files

by ocwokocw 2022. 3. 13.

- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/reading-files

- test-double: https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/

 

- 개요

이번 챕터에서는 file을 읽고 데이터를 얻는 방법에 대해 알아본다.
 
만약 친구와 blog 소프트웨어를 만든다고 가정해보자. 저자들은 게시물을 markdown으로 작성하며 file의 상단에는 메타데이터가 존재한다. 시작시에 웹서버는 게시물을 생성하기 위해 folder를 읽고, 별도의 NewHandler 함수가 해당 게시물들을 blog의 웹서버를 위한 데이터소스로서 사용할것이다.

 

- Example

 
blog post 파일들이 있는 folder를 게시물들로 전환하는 package를 작성해보자.
 
예제 world.md 파일
 
Title: Hello, TDD world!
Description: First post on our wonderful blog
Tags: tdd, go
---
Hello world!

The body of posts starts after the `---`
 
예제 struct
 
type Post struct {
	Title, Description, Body string
	Tags                     []string
}
 

 

- 반복적인 테스트 기반 개발

여태까지 그랬듯이 간단한 예제부터 시작해서 차츰 목표를 향해 아주 조금씩 나아가는 방법을 취할 예정이다. 이런 접근법을 취하려면 하나의 업무를 쪼개는 능력이 필요하지만 bottom-up 접근법을 취하는 함정에 빠지지 않도록 주의해야 한다.
 
우선 시작부터 과도한 상상을 하면 안된다. 특히 점진적으로 발전을 하면서 목표에 도달하는 방식으로 하는 개발은 성격이 급한 한국인의 특성상 참고 하기가 쉽지 않다. 예를 들어 처음부터 BlogPostFileParser와 같이 모든기능이 들어있는 모듈을 만들려는 유혹을 참아내야 한다.
 
이렇게 한번에 너무 많은 기능을 구축하는건 점진적인 방법이 아니고, TDD가 우리에게 제공하는 이점인 계속해서 빠른 feedback 주는 이점을 취할 수 없다. 켄트백은 다음과 같이 말했다.
 
낙관주의는 프로그래밍의 위험이며 피드백은 치료이다.
 
우리의 접근방식은 실제 소비자가 생각하는 가치를 가능한 빨리 전달해주는데 초점을 맞추어야 한다. 일단 한번 소비자가 원하는 작은 부분이라도 전달하고 나면 요구사항의 나머지 반복적인 공정은 간다해지는 경향이 있다.

 

- 우리가 원하는 test 유형

시작할 때 스스로 목표와 마인드셋을 상기해야 한다.
 
  • 우리가 원하는 test를 작성해야 한다. 우리가 코드를 작성할때에는 사용자의 관점에서 어떻게 코드를 사용할것인지 생각해보아야 한다.
  • how가 아니라 what과 why에 집중해야 한다.
 
현재 우리가 원하는 기능은 특정 folder를 넘겨주면 게시물을 반환하는 기능이다. 이런 기능을 사용하는 코드는 아래와 같이 될 가능성이 크다.
 
var posts []blogposts.Post
posts = blogposts.NewPostsFromFS("some-folder")
 
이런 test를 작성하기 위해서는 해당 test folder안에 예제 게시물을 넣어놓아야 한다. 아주 크게 잘못된건 없지만 몇 가지 trade-off 사항이 존재한다.
 
  • 각 test에서 특정 행위를 test 하기 위해 새로운 file을 생성해야 한다.
  • test를 수행하는데 있어서 file 불러오기 실패와 같은 행위가 관여될 수 있다.
  • file system에 접근해야 하기 때문에 test가 느릴 수 있다.
 
위의 사항 외에도 file system의 구현 세부사항과 불필요한 결합도가 생기는 문제도 존재한다.

 

- file system 추상화(Go 1.16)

Go 1.16부터는 file system에 대한 추상화 io/fs package를 소개했다.
 
fs package는 file system에 대한 기본 인터페이스를 정의하고 있다. file system은 host os 뿐만 아니라 다른 package에 의해서도 제공될 수 있다.
 
이런 특징은 구체적인 file system에 대한 결합도를 줄이고 필요에 따라 다른 구현세부사항을 주입할 수 있게 해준다.
 
interface를 생산자 측면에서 새로운 embed.FS형은 zip.Reader와 마찬가지로 fs.FS를 구현한다. 새로운 os.DirFS 함수는 OS file tree에서 지원하는 fs.FS의 구현사항을 제공한다.
 
만약 이 인터페이스를 이용하면 package의 사용자는 사용할 표준라이브러리에 내장된 여러 선택사항을 가질 수 있게 된다. Go 표준라이브러리에 정의된 인터페이스를 활용하는법을 배우는것은 package들간의 결합도를 없애는데 필수적이다. 이런 package들은 생각했던것과 다르게 문제를 최소화하면서 다른 문맥에서 재사용될 수 있다.
 
우리의 경우 사용자가 게시물들이 Go 실행파일에 내장되기 보다는 실제 filesystem에 file로서 존재하길 원하지 않을까? 어느쪽이든 코드를 신경쓸 필요는 없다.
 
우리가 작성한 test에서 testing/fstest package는 net/http/httptest와 비슷하게 사용해야할 io/FS 구현세부사항을 제공해준다.
 
여태까지 나온 사항을 생각해봤을 때 사용할 코드를 다시 작성해보면 아래와 같이 수정할 수 있다.
 
var posts blogposts.Post
posts = blogposts.NewPostsFromFS(someFS)

 

- Test 작성

역시 test를 작성할때에는 가능한 최소한으로 또한 유용하게 작성해야 한다. 우선 디렉토리내에서 모든 파일을 읽는 부분을 먼저 작성해보도록 하자. []Post 에서 반환한 수가 가상의 파일 시스템에서의 파일 수와 같은지를 체크한다.
 
package blogposts_test

import (
	"testing"
	"testing/fstest"
)

func TestNewBlogPosts(t *testing.T) {
	fs := fstest.MapFS{
		"hello world.md":  {Data: []byte("hi")},
		"hello-world2.md": {Data: []byte("hola")},
	}

	posts := blogposts.NewPostsFromFS(fs)

	if len(posts) != len(fs) {
		t.Errorf("got %d posts, wanted %d posts", len(posts), len(fs))
	}
}
 
test package는 blogposts_test인점에 주목해보자. TDD가 잘 수행되기 위해서는 소비자 입장에서 작성해야 한다.(API를 사용하는 입장에서 간단하게 사용할 수 있도록 작성해야 한다는 의미이다.) test를 할 때에는 내부적인 세부사항을 신경쓰면 안된다. package 이름에 _test를 붙임으로써 패키지에서 exported된 멤버에만 접근할 수 있다.
 
Test 코드에서 testing/fstest를 import 하였는데 fstest.MapFS 타입을 사용하기 위함이다. 이를 사용하여 가상 파일 시스템인 fstest.MapFS를 우리의 패키지로 넘길것이다.
 
MapFS는 test에서 사용하기 위한 간단한 in-memory 파일 시스템이다. 이렇게하면 test file 들을 더 간단하게 유지보수할 수 있고, 더 빠르게 실행할 수 있다.
 
마침내 소비자가 실제 API를 사용하는것과 같이 호출한 후, post의 수가 정확한지 검사하는 test 코드를 완성하였다. 현재는 blogposts 가 정의되어 있지 않기 때문에 에러가 발생할것이다.
 
package가 존재하지 않으므로 blogposts.go 파일을 생성한 후 blogposts package 안으로 넣어줘야 한다. 그 후 해당 package를 test 에서 import 한다. 또 NewPostsFromFS 함수도 없기 때문에 여전히 에러가 날것이다.
 
이런 과정은 test가 실행될 수 있게 함수의 뼈대를 작성하는데 집중하게 해준다. 여러번 강조했지만 이 시점에서 절대 앞서나가 생각하면 안된다. 오직 test를 어떻게 작동시킬지와 우리가 기대한대로 실패하는지에만 집중해야 한다. 만약 이런 단계를 건너뛰면 유용하지 않은 test 코드가 작성될 수 있다.
 
package blogposts

import "testing/fstest"

type Post struct {
}

func NewPostsFromFS(fileSystem fstest.MapFS) []Post {
	return nil
}
 
test를 수행하면 우리가 실패한대로 동작할것이다. 이제 test가 통과하기 위한 최소한의 코드만 작성해보자.
 
func NewPostsFromFS(fileSystem fstest.MapFS) []Post {
	return []Post{{}, {}}
}
 
위와 같은 "Sliming"(test를 pass하기 위해 가장 최소한희, 하드 코딩된) 코드는 객체를 스켈레톤화 하는데 유용하다. 인터페이스를 설계하고 로직을 수행하는 2 가지 관심사에 대해 한번에 하나씩만 집중할 수 있도록 해준다.
 
관심사를 줄였으므로 우리는 디렉토리를 읽고 각 파일마다 post를 만들어야 한다. 하지만 아직 파일을 열거나 파싱하는것에 대해서 신경쓸 필요는 없다.
 
func NewPostsFromFS(fileSystem fstest.MapFS) []Post {
	dir, _ := fs.ReadDir(fileSystem, ".")
	var posts []Post
	for range dir {
		posts = append(posts, Post{})
	}
	return posts
}
 
fs.ReadDir 은 주어딘 fs.FS 내의 디렉토리를 읽고 []DirEntry를 반환한다.
 
fs.ReadDir에서 error가 발생할 수 있기 때문에 이걸 고려해줘야 하지 않을까란 생각이 들 수 있지만, 우리가 집중해야 하는 부분은 설계가 아니라 test를 통과하는것이므로 지금은 _를 통해 error를 무시해주도록 하자.
 
나머지 부분은 목록을 순회할때마다 Post를 생성하고 slice를 반환하는것과 같이 직관적 코드이다.
 
test는 통과했지만 외부 package에서는 이를 곧바로 사용할 수 없는데 현재 fstest.MapFS 세부 구현체에 결합되어있기 때문이다. 그러므로 NewPostsFromFS 함수의 인자를 표준 라이브러리의 인터페이스를 받을 수 있게 바꿔주도록 하자.
 
func NewPostsFromFS(fileSystem fs.FS) []Post {
	dir, _ := fs.ReadDir(fileSystem, ".")
	var posts []Post
	for range dir {
		posts = append(posts, Post{})
	}
	return posts
}
 
 

- Error handling

이전에 error 처리에 관해서 다룬적이 있다. 반복적으로 개선하기 전에 file을 다룰 때 erorr가 발생할 수 있는 부분에 대한 처리를 해주어야 한다. 디렉토리를 읽는것 외에도 개별 파일들을 열 때 문제가 발생할 수 있다. error를 반환할 수 있게 API를 변경해주자.
 
func TestNewBlogPosts(t *testing.T) {
	fs := fstest.MapFS{
		"hello world.md":  {Data: []byte("hi")},
		"hello-world2.md": {Data: []byte("hola")},
	}

	posts, err := blogposts.NewPostsFromFS(fs)

	if err != nil {
		t.Fatal(err)
	}

	if len(posts) != len(fs) {
		t.Errorf("got %d posts, wanted %d posts", len(posts), len(fs))
	}
}
 
반환된 수가 일치하지 않는다고 할테니 NewPostsFromFS 내부도 변경해주도록 하자.
 
func NewPostsFromFS(fileSystem fs.FS) ([]Post, error) {
	dir, err := fs.ReadDir(fileSystem, ".")
	if err != nil {
		return nil, err
	}
	var posts []Post
	for range dir {
		posts = append(posts, Post{})
	}
	return posts, nil
}
 
TDD 실무자는 fs.ReadDir로 부터 발생한 error를 전파하는 코드를 작성하기 전에 test가 실패하는것을 보지 못했다고 지적할 수 있다. 이를 확인하기 위해 실패하는 fs.FS 를 주입하는 새로운 test를 작성해주도록 하자. 그렇다고해서 실패하는 파일을 만드는것은 번거로우므로 error를 반환하는 fs.ReadDir test-double을 만들어주도록 하자. (test-double, 테스트를 진행하기 어려운 경우 이를 대신해서 테스트를 진행할 수 있도록 만들어 주는 객체, 영화 촬영시 위험한 장면을 대신하는 스턴트 더블에서 유래되었다.)
 
type StubFailingFS struct {
}

func (s StubFailingFS) Open(name string) (fs.File, error) {
	return nil, errors.New("oh no, i always fail")
}
 
fs.FS 인터페이스를 구현했다. 이로써 다른 경우의 시나리오를 테스트하기 위한 test-double을 만들었다.
 
어떤 경우에는 error handling을 test 하는것이 실용적이지만 우리의 경우에는 error로 인해서 크게 로직이 달라지지 않고 단지 전파만 하기 때문에 새로운 test를 작성해야할 정도의 가치는 없다.
 
다음 단계는 Post 형을 확장해서 더 유용한 데이터를 가질 수 있도록 변경하는것이다.

- Post type 확장

처음에 정의한 blog post 스키마에는 제목 field가 있었다.
 
test file 들의 내용을 변경해서 적절한 제목을 할당하고 단언문에서 정확하게 파싱이 됐는지 확인하는 test 코드를 작성해보자.
 
func TestNewBlogPosts(t *testing.T) {
	fs := fstest.MapFS{
		"hello world.md":  {Data: []byte("Title: Post 1")},
		"hello-world2.md": {Data: []byte("Title: Post 2")},
	}

	// rest of test code cut for brevity
	got := posts[0]
	want := blogposts.Post{Title: "Post 1"}

	if !reflect.DeepEqual(got, want) {
		t.Errorf("got %+v, want %+v", got, want)
	}
}
 
Post의 Title field가 없으므로 test가 실패할것이다. Post에 Title field를 추가해주고 test를 실행해보자.
 
type Post struct {
	Title string
}
 

- File read


test가 실패하는것을 확인했을것이다. 이제 파일을 열고 제목을 추출하는데 성공하는 최소한의 코드를 작성해보자.
 
func NewPostsFromFS(fileSystem fs.FS) ([]Post, error) {
	dir, err := fs.ReadDir(fileSystem, ".")
	if err != nil {
		return nil, err
	}
	var posts []Post
	for _, f := range dir {
		post, err := getPost(fileSystem, f)
		if err != nil {
			return nil, err //todo: needs clarification, should we totally fail if one file fails? or just ignore?
		}
		posts = append(posts, post)
	}
	return posts, nil
}

func getPost(fileSystem fs.FS, f fs.DirEntry) (Post, error) {
	postFile, err := fileSystem.Open(f.Name())
	if err != nil {
		return Post{}, err
	}
	defer postFile.Close()

	postData, err := io.ReadAll(postFile)
	if err != nil {
		return Post{}, err
	}

	post := Post{Title: string(postData)[7:]}
	return post, nil
}
 
이번 코드를 작성하는 목적은 우아하고 깔끔한 코드를 작성하는게 아니라 단순히 software가 돌아가는 최소한의 코드를 작성하는것이므로 조바심내지 않아도 된다.
 
사실 위의 코드에서 아주 사소한 기능만 구현했는데도 코드를 은근히 많이 작성했으며, 에러 처리 부분은 가정을 하고 작성했다. 점진적인 접근법을 취하면 요구사항이 불완전하다는 피드백을 곧바로 받을 수 있다.
 
fs.FS가 제공하는 Open 메소드를 이용하여 파일을 열었다. 파일로부터 데이터를 읽고 추가적인 파싱과정없이 단순히 string을 slicing하여 Title: 문자열을 잘라냈다.
 
파일 내용을 파싱하는 코드와 파일을 여는 코드를 분리하면 코드가 더 간단해지고 이해하기 쉬워진다.
 
func getPost(fileSystem fs.FS, f fs.DirEntry) (Post, error) {
	postFile, err := fileSystem.Open(f.Name())
	if err != nil {
		return Post{}, err
	}
	defer postFile.Close()
	return newPost(postFile)
}

func newPost(postFile fs.File) (Post, error) {
	postData, err := io.ReadAll(postFile)
	if err != nil {
		return Post{}, err
	}

	post := Post{Title: string(postData)[7:]}
	return post, nil
}
 
새로운 함수와 메소드를 추출할때에는 argument에 대해 주의를 기울여야 한다. 결합과 응집력에 대해 생각해보자. newPost 는 fs.File 형과 결합되어야 하는가? fs.File의 데이터와 메소드를 모두 사용하고 있는가?
 
newPost의 경우 필요한 인자는 io.ReadAll에 필요한 io.Reader가 전부이다. io.Reader를 인자로 취하면 해당 함수에 대한 결합도를 낮출 수 있다.
 
func newPost(postFile io.Reader) (Post, error) {
	postData, err := io.ReadAll(postFile)
	if err != nil {
		return Post{}, err
	}

	post := Post{Title: string(postData)[7:]}
	return post, nil
}
 
getPost 함수도 이와 비슷한 적용이 가능한데, 해당 함수가 취하는 fs.DirEntry 인자도 파일 이름을 얻기 위해 단순히 Name()을 호출하고 있다. 따라서 fs.DirEntry형이 굳이 필요하지 않다. 커플링을 줄이기 위해 string형의 파일 이름을 넘겨주자.
 
func NewPostsFromFS(fileSystem fs.FS) ([]Post, error) {
	dir, err := fs.ReadDir(fileSystem, ".")
	if err != nil {
		return nil, err
	}
	var posts []Post
	for _, f := range dir {
		post, err := getPost(fileSystem, f.Name())
		if err != nil {
			return nil, err //todo: needs clarification, should we totally fail if one file fails? or just ignore?
		}
		posts = append(posts, post)
	}
	return posts, nil
}

func getPost(fileSystem fs.FS, fileName string) (Post, error) {
	postFile, err := fileSystem.Open(fileName)
	if err != nil {
		return Post{}, err
	}
	defer postFile.Close()
	return newPost(postFile)
}

func newPost(postFile io.Reader) (Post, error) {
	postData, err := io.ReadAll(postFile)
	if err != nil {
		return Post{}, err
	}

	post := Post{Title: string(postData)[7:]}
	return post, nil
}
 
이제 newPost만 신경쓰면 된다. 파일을 여는것과 순회하는 관심사를 완료했으므로 이제 Post형에 맞는 데이터를 추출하는것에 집중해보자. 코드가 작동하는데에는 아무 이상 없지만 논리적으로 연관되어 있는 것들은 한데 모여있는게 좋으므로 Post 형과 newPost를 새로운 post.go 파일로 옮겨주도록 하자.

 

- Post 형 추출

file의 다음 행, description을 추출하는 test case로 확장해보자.
 
func TestNewBlogPosts(t *testing.T) {
	const (
		firstBody = `Title: Post 1
Description: Description 1`
		secondBody = `Title: Post 2
Description: Description 2`
	)

	fs := fstest.MapFS{
		"hello world.md":  {Data: []byte(firstBody)},
		"hello-world2.md": {Data: []byte(secondBody)},
	}

	// rest of test code cut for brevity
	assertPost(t, posts[0], blogposts.Post{
		Title:       "Post 1",
		Description: "Description 1",
	})

}
 
test는 당연히 실패할것이다 Post struct에 description을 추가해주도록 하자.
 
type Post struct {
	Title       string
	Description string
}
 
행 단위로 데이터를 스캔하는것을 도와주는 간편한 표준 라이브러리 bufio.Scanner를 이용해보자. Scanner는 행으로 구분된 text 파일과 같은 데이터를 읽는 경우 편리한 인터페이스를 제공한다.
 
func newPost(postFile io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postFile)

	scanner.Scan()
	titleLine := scanner.Text()

	scanner.Scan()
	descriptionLine := scanner.Text()

	return Post{Title: titleLine[7:], Description: descriptionLine[13:]}, nil
}
 
인자로 io.Reader를 취하므로 함수의 인자를 변경하지 않아도 된다.
 
사용법은 Scan으로 행을 읽고 Text를 이용해서 데이터를 추출하면 된다. 이 함수는 error를 반환하지 않는다. 지금은 반환형에서 error를 지우고 싶겠지만 유효하지 않은 file 구조를 다루어야 하므로 그대로 남겨두도록 하자.
 
이제 리팩토링을 해보자.
 
Scan을 하고 텍스트를 읽는 동작이 반복된다. DRY 원칙을 준수하여 아래와 같이 수정해주자.
 
func newPost(postFile io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postFile)

	readLine := func() string {
		scanner.Scan()
		return scanner.Text()
	}

	title := readLine()[7:]
	description := readLine()[13:]

	return Post{Title: title, Description: description}, nil
}
 
이번 리팩토링에서는 단지 행을 읽는것에 대해 what과 how를 분리해서 코드를 더 선언적으로 만들었다. 제목과 내용을 파싱할 때 쓰이는 7과 13은 매직넘버가 될 가능성이 높으므로 상수로 빼주도록 하자.
 
const (
	titleSeparator       = "Title: "
	descriptionSeparator = "Description: "
)

func newPost(postFile io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postFile)

	readLine := func() string {
		scanner.Scan()
		return scanner.Text()
	}

	title := readLine()[len(titleSeparator):]
	description := readLine()[len(descriptionSeparator):]

	return Post{Title: title, Description: description}, nil
}
 
사실 위의 코드도 썩 좋다고 말할수는 없다. 매직 넘버를 지우기 위해 tag를 상수로 선언했는데 strings.TrimPrefix를 이용하면 이런 tag 접두사를 날릴 수 있다.
 
func newPost(postBody io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postBody)

	readMetaLine := func(tagName string) string {
		scanner.Scan()
		return strings.TrimPrefix(scanner.Text(), tagName)
	}

	return Post{
		Title:       readMetaLine(titleSeparator),
		Description: readMetaLine(descriptionSeparator),
	}, nil
}
 
위의 코드가 실제로 좋은지 여부를 떠나 좋다고 생각하여 리팩토링을 실시했다. 리팩토링 상태는 세부구현에서 자유롭게 해주며 여전히 정확히 동작하는지 확인하기 위한 테스트를 계속 수행할 수 있다. 그러다가 test를 통과하지 못하면 언제든지 되돌릴 수 있다. TDD 접근법은 생각난 아이디어를 적용할 수 있는 기회와 안정감을 줘서 좋은 코드를 작성할 자신감을 심어준다.
 
다음으로 post의 tag를 추출해보자. 이제 Tag를 추출하는 기능을 구현해보면서 점진적인 접근법에 익숙해지고 언제든지 코드가 돌아간다는 자신감을 가지면서 TDD를 익혀보자. 코드는 아래와 같은 형태가 될 것이다.
 
func TestNewBlogPosts(t *testing.T) {
	const (
		firstBody = `Title: Post 1
Description: Description 1
Tags: tdd, go`
		secondBody = `Title: Post 2
Description: Description 2
Tags: rust, borrow-checker`
	)

    // rest of test code cut for brevity
    assertPost(t, posts[0], blogposts.Post{
        Title:       "Post 1",
        Description: "Description 1",
        Tags:        []string{"tdd", "go"},
    })
}
 
const (
	titleSeparator       = "Title: "
	descriptionSeparator = "Description: "
	tagsSeparator        = "Tags: "
)

func newPost(postBody io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postBody)

	readMetaLine := func(tagName string) string {
		scanner.Scan()
		return strings.TrimPrefix(scanner.Text(), tagName)
	}

	return Post{
		Title:       readMetaLine(titleSeparator),
		Description: readMetaLine(descriptionSeparator),
		Tags:        strings.Split(readMetaLine(tagsSeparator), ", "),
	}, nil
}
 
tag를 추출하는 과정에서 readMetaLine 을 재사용했고, 문자열을 자르기 위해 strings.Split을 사용했다. 이제 body 만 추출하면 될것 같다.
 
test 데이터에 body 정보를 추가해보자.
 
	const (
		firstBody = `Title: Post 1
Description: Description 1
Tags: tdd, go
---
Hello
World`
		secondBody = `Title: Post 2
Description: Description 2
Tags: rust, borrow-checker
---
B
L
M`
    )
 
test 정보를 변경했으니 단언에도 body를 추가해주자.
 
	assertPost(t, posts[0], blogposts.Post{
        Title:       "Post 1",
        Description: "Description 1",
        Tags:        []string{"tdd", "go"},
        Body: `Hello
World`,
    })
 
--- 구분자를 무시해주고 더이상 scan 을 못할때까지 계속 scan을 해준다.
 
func newPost(postBody io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postBody)

	readMetaLine := func(tagName string) string {
		scanner.Scan()
		return strings.TrimPrefix(scanner.Text(), tagName)
	}

	title := readMetaLine(titleSeparator)
	description := readMetaLine(descriptionSeparator)
	tags := strings.Split(readMetaLine(tagsSeparator), ", ")

	scanner.Scan() // ignore a line

	buf := bytes.Buffer{}
	for scanner.Scan() {
		fmt.Fprintln(&buf, scanner.Text())
	}
	body := strings.TrimSuffix(buf.String(), "\n")

	return Post{
		Title:       title,
		Description: description,
		Tags:        tags,
		Body:        body,
	}, nil
}
 
scanner.Scan()을 bool을 반환하는데 더 scan할 데이터가 있는지 여부를 가리킨다. 그래서 끝까지 scan을 해야하는 for문에 이 메소드를 사용할 수 있다.
 
scan 할 때마다 fmt.Fprintln을 사용해서 data를 buffer에 쓴다. Fprint가 아닌 Fprintln인 이유는 scan이 줄바꿈을 지우기 때문에 추가해줘야 하기 때문이다. 이렇게 되면 마지막 라인에는 없는 줄바꿈이 들어가게 되는데 이를 scan이 모두 끝난 후 buffer에서 줄바꿈 접미사를 잘라준다.
 
func newPost(postBody io.Reader) (Post, error) {
	scanner := bufio.NewScanner(postBody)

	readMetaLine := func(tagName string) string {
		scanner.Scan()
		return strings.TrimPrefix(scanner.Text(), tagName)
	}

	return Post{
		Title:       readMetaLine(titleSeparator),
		Description: readMetaLine(descriptionSeparator),
		Tags:        strings.Split(readMetaLine(tagsSeparator), ", "),
		Body:        readBody(scanner),
	}, nil
}

func readBody(scanner *bufio.Scanner) string {
	scanner.Scan() // ignore a line
	buf := bytes.Buffer{}
	for scanner.Scan() {
		fmt.Fprintln(&buf, scanner.Text())
	}
	return strings.TrimSuffix(buf.String(), "\n")
}
 
함수에서 데이터의 나머지를 얻는 과정을 캡슐화해서 나중에 코드를 읽을 독자가 더 쉽게 이해할 수 있도록 해주었다.
 
 

'Language > Go' 카테고리의 다른 글

Effective Go - New 와 Make  (0) 2022.05.07
Effective Go - Functions  (0) 2022.05.07
Go - Context  (0) 2022.02.27
Go - Sync  (0) 2022.01.20
Go - reflection  (0) 2022.01.15

댓글