go의 장점 중 하나는 표준 라이브러리를 제공한다는 점이다. 해당 챕터에서는 가장 많이 사용되는 패키지와 디자인와 사용성이 관용적인 go의 원칙을 잘따르는 패키지들에 대해서 알아볼 것이다.
데이터를 읽어 쓸 일이있는데, go의 입력/출력의 철학 중심은 io
패키지에서 찾아볼 수 있따. 특히, 해당 패키지에 정의된 io.Reader
와 io.Writer
는 go에서 정말 자주 사용되는 인터페이스이다.
io.Reader
와 io.Writer
는 단일 메서드로 정의되어 있다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Writer(p []byte) (n int, err error)
}
io.Writer
인터페이스의 Write
메서드는 인터페이스 구현에 쓰여지는 바이트 슬라이스를 인자로 받는다. 만약 쓰여진 바이트 수와 잘못된 경우에 오류를 반환한다.
io.Reader
의 Read
메서드는 더 재밌는데, 반환 파라미터를 통해 데이터를 반환 받는 것보다 슬라이스 입력 파라미터를 구현으로 전달하고 이 슬라이스에 데이터를 쓴다. len(p)
바이트만큼 슬라이스에 쓰여 질 것이다.
왜 io.Reader
의 Read
메서드는 슬라이스를 입력 파라미터로 받고 데이터를 쓸까? 이런 식으로 정의된 것에는 괜찮은 이유가 있다. io.Reader
가 동작하는 방식을 이해하기 위한 대표적인 함수를 작성해보자.
func countLetters(r io.Reader) (map[string]int, error) {
buf := make([]byte, 2048)
out := map[string]int{}
for {
n, err := r.Read(buf)
for _, b := range buf[:n] {
if (b > 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
out[string(b)]++
}
}
if err == io.EOF {
return out, nil
}
if err != nil {
return nil, err
}
}
}
3가지 주목할 점이 있는데,
첫번째는 버퍼를 하나 생성하고, r.Read
호출에 재사용한다는 것이다. 잠재적으로 큰 데이터 소스에서 읽기 위해 단일 메모리 할당을 사용하도록 한다. Read
메서드는 []byte
를 반환하도록 설계되어 있다면, 매 단일 호출마다 새로운 할당이 필요하다. 각 할당은 힙 메모리 끝에 도달할 것이고, 가비지 컬렉터에게 꽤 많은 작업을 만들게 할 것이다.
할당을 더 줄이려면 프로그램이 시작할 때 버퍼 풀을 생성하도록 한다. 그런 다음 함수가 시작할 때 풀에서 버퍼를 가져오고 종료될 때 반환한다. 이렇게 io.Reader
에 슬라이스를 전달함으로서 메모리 할당을 개발자의 통제 하에 둘 수 있다.
두 번째는 버퍼에 얼마나 많은 바이트가 쓰여졌는 지 알기 위해 r.Read
에서 반환된 n
값을 사용하여 buf
슬라이스의 하위 슬라이스를 순회하면서 읽은 데이터를 처리할 수 있다.
마지막으로 r.Read
에서 io.EOF
가 오류로 반환되면 r
에서 읽기는 완료되었다고 판단한다. 이것은 io.Reader
에서 읽을 데이터가 남아있지 않다는 것을 의미한다. io.EOF
가 반환되면 처리를 완료하고 결과를 반환한다.
여기에 io.Reader
의 Read
메서드에 관한 일반적이지 않은 것이 하나 있다. 함수나 메서드가 반환값으로 오류를 반환하는 대부분의 경우는 처리하기 전에 오류가 아닌 값이 반환된 것인지 확인한다. 데이터 스트림의 끝이나 예상치 못한 조건에서 발생한 오류 전에 읽은 데이터가 있을 수 있기 때문에 Read
를 위해서는 반대로 처리한다. 즉, n
으로 데이터를 먼저 읽고, err
를 처리하는 것이다. 어차피 err
가 났다면 n
은 0또는 n값이기 때문에 0
이어도 문제가 없다.
만약, 예상치 못하게 io.Reader
의 Read
에서 끝에 도달했다면 다른 센티넬 오류인 io.ErrUnexpectedEOF
를 반환한다. 예상치 못한 상태를 나타내기위해서 Err
문자열로 시작한다는 것을 알 수 있다. 즉, EOF
와는 다르다는 것이다.
io.Reader
와 io.Writer
는 아주 간단한 인터페이스이기 때문에 다양한 방식으로 구현될 수 있다. strings.NewReader
함수를 사용하여 문자열로부터 io.Reader
를 생성할 수 있다.
func main() {
s := "The quick brown fox jumped over the lazy dog"
sr := strings.NewReader(s)
counts, err := countLetters(sr)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(counts)
}
결과는 다음과 같다.
map[T:1 a:1 b:1 c:1 d:2 e:4 f:1 g:1 h:2 i:1 j:1 k:1 l:1 m:1 n:1 o:4 p:1 q:1 r:2 t:1 u:2 v:1 w:1 x:1 y:1 z:1]
이전에 인터페이스는 type-safe한 덕 타이핑이라고 하였다. 위의 io.Reader
와 io.Writer
의 구현은 종종 데코레이터 패턴으로 함께 연결된다.
countLetters
는 io.Reader
에 의존하기 때문에 .gzip
으로 압축된 파일에서도 동일하게 사용할 수 있다. 따라서 .gzip
파일을 읽는 Read
메서드를 구현한 구조체를 받기만하면 된다.
func buildGZipReader(fileName string) (*gzip.Reader, func(), error) {
r, err := os.Open(fileName)
if err != nil {
return nil, nil, err
}
gr, err := gzip.NewReader(r)
if err != nil {
return nil, nil, err
}
return gr, func() {
gr.Close()
r.Close()
}, nil
}
이 함수는 데코레이터 패턴으로 io.Reader
를 구현한 타입을 알맞게 wrapping하는 방법을 보여준다. os.Open
으로 *os.File
을 반환한다. 그리고 이 객체를 gzip.NewReader
에 넘겨 *gzip.Reader
인스턴스를 만든다. 반환할 때는 *gzip.Reader
와 *os.File
을 앋아주는 클로저를 반환한다.
*gzip.Reader
는 io.Reader
를 구현한 것이기 때문에 이전에 *strings.Reader
으로 사용한 것과 같이 counterLetters
에서 사용할 수 있다.
func main() {
r, closer, err := buildGZipReader("my_data.txt.gz")
if err != nil {
fmt.Println(err)
return
}
defer closer()
counts, err := countLetters(r)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(counts)
}
읽기와 쓰기를 위한 표준 인터페이스를 가지고 있기 때문에 io
패키지 내에 io.Reader
에서 io.Writer
로 복사하기 위한 io.Copy
라는 표준함수도 있다. 기존 io.Reader
와 io.Writer
인스턴스에 새로운 기능을 추가하기 위한 다른 표준함수도 있다.
io.MultiReader
: 여러 io.Reader
인스턴스에서 차례로 읽는 io.Reader
를 반환한다.io.LimitReader
: 제공된 io.Reader
에서 특정 바이트 수만큼 읽어들이는 io.Reader
를 반환한다.io.MultiWriter
: 동시에 여러 io.Writer
인스턴스에 쓰기를 하는 io.Writer
를 반환한다.표준 라이브러리의 다른 패키지들은 io.Reader
와 io.Writer
로 작업하기 위한 자체 타입과 함수를 제공한다.
io
에서 정의한 io.Closer
나 io.Seeker
와 같은 다른 단일 메서드 인터페이스가 있다.
type Closer interface {
Close() error
}
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
io.Closer
인스터페이스는 읽기와 쓰기가 완료되었을 때, 정리할 필요가 있는 os.File
과 같은 타입에서 구현된다. 대부분 Close
는 defer
를 통해서 호출된다.
f, err := os.open(fileName)
if err != nil {
return nil, err
}
defer f.Close()
만약
loop
내에서 자원을 열었다면defer
를 쓰지 않아야 한다. 왜냐하면defer
는 함수가 종료되어 스택 프레임에서 빠져나올 때야 실행되기 때문에loop
내에서의defer
를 통한 자원 반환은 실제로loop
내에서 자원이 반환되지 않는다. 대신에 매loop
마다Close
를 호출하는 것이 좋다.
io.Seeker
인터페이스는 자원의 임의 접근을 위해 사용된다. whence
의 유효한 값은 io.SeekStart
, io.SeekCurrent
및 io.SeekEnd
상수이다. 이것은 사용자 지정 타입을 사용하여 더 명확하게 운영이 되었어야 했지만, 초반에 관리를 소홀히 하여 whence
는 타입이 int
가 되었다.
io
패키지는 다양한 방뻡으로 이런 4개의 인터페이스를 결합시키는 인터페이스를 정의한다. io.ReadCloser
, io.ReadSeeker
, io.ReadWriteCloser
, io.ReadWriteSeeker
, io.ReadWriter
, io.WriterCloser
및 io.WriterSeeker
를 포함한다.
함수가 데이터를 가지고 어떤 행동을 하는 지를 지정하기 위해 위의 인터페이스를 사용한다. 가령 파라미터로 os.File
을 사용하는 것이 아니라, 파라미터로 어떤 일을 할 것인지 정확히 지정하기 위해 인터페이스를 사용하는 것이 좋다. 파라미터를 인터페이스로 만들면 함수를 더 범용적으로 만들 뿐만 아니라, 의도를 명확히 할 수 있다. 또한, 자체적으로 데이터 소스와 싱크를 작성하는 경우 코드를 이러한 인터페이스와 호환되도록 만들어야 한다. 일반적으로 io
에 정의된 인터페이스처럼 단순하고, 분리된 인터페이스를 만들기 위해 노력해야한다.
ioutil
패키지는 바이트 슬라이스로 전체를 읽어들이는 io.Reader
구현, 파일에 읽기와 쓰기 그리고 임시 파일로 동작하는 것과 같은 것을 위한 몇 가지 간단한 유틸리티를 제공한다. ioutil.ReadAll
, ioutil.ReadFile
, ioutil.WriterFile
함수는 작은 데이터 소스를 위헤서는 괜찮지만 큰 데이터를 읽기 위해서는 bufio
패키지에 있는 Reader
, Writer
및 Scanner
를 사용하는 것이 좋다.
ioutil
패키지는 go type에 메서드를 추가하는 좋은 패턴을 보여준다. 만약 개발자가 io.Reader
를 구현하지만 io.Closer
를 가지지 않는 타입을 만들었다고 하자. 이를 io.ReadCloser
를 받는 함수에 전달할 필요가 있다면, 개발자의 io.Reader
를 ioutil.NopCloser
로 넘겨면 된다. 그리고, io.ReadCloser
를 구현하는 타입을 얻으면 된다. 구현을 보면 더 쉽게 이해할 수 있다.
type nopCloser struct {
io.Reader
}
func (nopCloser) Close() error { return nil }
func NopCloser(r io.Reader) io.ReadCloser {
return nopCloser{r}
}
NopCloser
는 ioutil.NopCloser
의 구현을 보여준다. 위의 예시와 같이 한 타입이 특정 메서드를 구현하지 않아서 인터페이스를 만족하지 않은 경우, 해당 타입을 다른 타입에 임베딩하도록 하고, 다른 타입은 해당 인터페이스를 만족하도록 하는 것이다.
언제든 인터페이스를 충족하기 위해 특정 타입에 추가적인 메서드를 넣을 필요가 있다면, 임베딩 타입 패턴을 사용하도록 하자. (embedded type pattern)
ioutil.NopCloser
함수는 함수에서 인터페이스를 반환하지 않도록 하는 규칙을 위반했지만, 표준 라이브러리의 일부이기 때문에 동일하게 유지되는 것을 보장하기 위한 인터페이스용 단순 어댑터이다.
go 표준 라이브러리는 time
이라는 시간과 관련된 자원을 포함한다. 시간을 표현하기 위해 사용하는 time.Duration
과 time.Time
이라는 두개의 주요 타입이 있다.
기간은 int64
를 기반으로하는 time.Duration
으로 표시한다. go가 나타낼 수 있는 최소 시간은 1 nano seconds이지만 time
패키지는 time.Duration
타입의 상수를 정의하여 나노초, 마이크로초, 밀리초, 초, 분, 시간을 나타낸다. 가령, 2시간 30분의 기간을 표현하기 위해서 다음과 같이 쓸 수 있다.
d := 2 * time.Hour + 30 * time.Minute // d는 time.Duration 타입
이런 상수는 time.Duration
의 사용에서 가독성과 타입 세이프를 제공한다. 이것은 타입 상수의 좋은 사례를 보여준다.
go는 time.ParseDuration
함수를 사용하여 time.Duration
으로 구문 분석 할 수 있는 일련의 숫자인 문자열 타입을 정의한다. go 표준 라이브러리 설명은 다음과 같다.
duration string은 '3000ms', '-1.5h' 혹은 '2h45m'과 같이 선택적 분수와 단위 접미사가 있는 부호있는 일련의 10진수이다. 유효한 시간 단위는 'ns', 'us','ms','s','m','h' 이다.
time.Duration
에 정의된 몇 가지 메서드가 있다. 하나는 fmt.Stringer
인터페이스를 충족해서 String
메서드를 통해 포매팅된 기간 문자열을 반환한다. 또한, 시간, 분, 초, 밀리초, 마이크로초, 혹은 나노초의 숫자로 값을 얻기 위한 메서드도 있다. Truncate
와 Round
메서드는 time.Duration
을 지정된 time.Duration
의 단위로 자르거나 반올림한다.
시간의 순간은
time.Time
타입으로 표현되며 표준 시간대가 표함된다.time.Now
함수로 현재 시간에 대한 참조를 획득한다. 이것은 현재 지역 시간이 설정된time.Time
인스턴스를 반환한다.
참고로, time.Time
은 두 시간이 동일한 지 비교하기 위해서 ==
을 사용하지 않고 Equal
메서드를 사용해야 한다.
time.Parse
함수는 문자열에서 time.Time
으로 변환하고, Format
메서드는 time.Time
을 문자열로 변환한다. go는 자체적으로 날짜 및 시간 포맷팅 언어를 사용한다. 포맷을 지정하기 위해서는 2006년 1월 2일 오후 3:04:05 MST
의 날짜 및 시간 포매팅 아이디어에 의존한다. 이는 공식이니 굳이 외울 필요는 없지만, 필요할 때마다 찾아보는 것이 좋다.
가령 다음의 코드를 보도록 하자.
func TimeParse() error {
t, err := time.Parse("2006-01-02 15:04:05 -0700", "2016-12-03 00:00:00 +0000")
if err != nil {
return err
}
fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))
return nil
}
2006-01-02 15:04:05 -0700
은 위에서 언급한 2006년 1월 2일 오후 3:04:05 MST
이다. -0700
은 MST가 UTC보다 7시간 전이기 때문이다.
이제 해당 형식으로 시간을 파싱하여 time.Time
으로 변환하겠다는 것이고, 두 번재 인자가 그 대상이다. 따라서 2016년 12월 03일 00시가 맞다. 해당 시간을 다시 문자열로 바꿀 때에도 위의 공식인 2006년 1월 2일 오후 3:04:05 MST
와 동일한 시간으로 형식을 맞춰주면 된다.
따라서, t.Format("January 2, 2006 at 3:04:05PM MST")
으로 정의해놓으면 월이 문자로 나오고, 그 다음 일, 년, 시간이 나온다.
따라서, 다음의 결과가 나온다.
December 3, 2016 at 12:00:00AM +0000
그러나 이는 기억하기 어렵고 사용하길 원할 때마다 찾아봐야 한다는 문제가 있다. 가장 일반적으로 사용되는 날짜와 서식 기간은 time
패키지 자체에 상수를 가지고 있다.
time.Duration
에서 부분을 추출하기 위한 메서드가 있는 것처럼, time.Time
에서도 동일한 일을 하는 메서드인 Day, Month, Year, Hour, Minute, Second, WeekDay, Clock
(분리된 정수값으로 시,분,초를 반환한다) 그리고 Date
(분리된 정수 값으로 년, 월, 일을 반환한다)가 있다.
func TimeParse() error {
t, err := time.Parse("2006-01-02 15:04:05 -0700", "2016-12-03 00:00:00 +0000")
if err != nil {
return err
}
fmt.Println(t.Date()) // 2016 December 3
fmt.Println(t.Year()) // 2016
fmt.Println(t.Month()) // December
fmt.Println(t.Second()) // 0
fmt.Println(t.Format("January 2, 2006 at 3:04:05PM MST"))
return nil
}
또한, 하나의 time.Time
인스턴스는 다른 인스턴스와 After, Before, Equal
메서드로 비교할 수 있다.
Sub
메서드는 두 개의 time.Time
인스턴스 사이에 경과 시간을 나타내는 time.Duration
을 반환하고, Add
메서드는 time.Duration
만큼 뒤의 time.Time
을 반환한다. 그리고 AddDate
는 년, 월, 일만큼 증가된 새로운 time.Time
인스턴스를 반환한다. time.Duration
처럼 Truncate
와 Round
메서드도 정의되어 있다. 모든 이런 메서드는 값 리시버로 정의되어 time.Time
인스턴스 자체를 수정할 일은 없다.
대부분의 운영 체제는 두 가지 시간을 운영하는데, 하나는 현재 시간에 대응되는 'wall clock'이 있고, 다른 하나는 컴퓨터가 부팅된 시점부터 단순히 증가하는 'monotonic clock'이 있다. 이렇게 다른 두 시계를 운영하는 이유는 'wall clock'은 균일하게 증가히자 않기 때문이다.
이런 잠재적 문제를 해결하기 위해 go는 타이머가 설정되거나 time.Time
인스턴스가 time.Now
로 생성될 때마다 경과 시간을 추적하기 위해 monotonic clock
을 사용한다. 해당 자원은 보이지 않고 타이머가 자동으로 사용한다. Sub
메서드는 time.Time
인스턴스 둘 다 설정되어 있다면 time.Duration
을 계산하기 위해 monotonic clock
을 사용한다. 만약 그렇지 않으면 두 인스턴스 중 하나가 time.Now
로 생성되지 않았기 때문에 Sub
메서드는 time.Duration
을 계산하기 위해 인스턴스에 지정된 시간을 사용할 것이다. 즉, monotonic clock
을 사용하여 정확한 시간 계산을 한다는 것이다.
time
패키지는 지정된 시간 후에 값을 출력하는 채널을 반환하는 함수를 포함한다.
time.After
함수는 한 번 출력하는 채널을 반환하는 반면에 time.Tick
에서 반환된 채널은 지정된 time.Duration
이 지날 때마다 새로운 값을 반환한다. 타임아웃이나 반복 작업을 활성화하기 위해 go의 동시성 지원과 함께 사용된다.
만약, time.After
로도 반복된 시간의 작업을 하고싶다면 다음과 같이하면 된다.
for {
ticker := time.After(time.Second * 1)
value := <-ticker
fmt.Println(value)
}
또한, 단일 함수를 time.AfterFunc
함수를 사용하여 지정된 time.Duration
이 지난 후에 수행할 수 있도록 할 수 있다. 기본 time.Ticker
는 중단할 수 없기 때문에(가비지 컬렉터로 회수되지 않는다) 사소한 프로그램의 외부에 time.Tick
을 사용하지 말도록 하자. 대신에 채널을 기다릴 뿐만 아니라 ticker
를 리셋하거나 중지할 수 있는 메서드를 가지는 *time.Ticker
를 반환하는 time.NewTicker
를 사용하도록 하자.
ticker := time.NewTicker(time.Second * 1)
defer ticker.Stop()
for value := range ticker.C {
fmt.Println(value)
}
NewTicker
로 만들어진 ticker
는 주기적으로 시간마다 현재 시간을 value
로 보내준다. 또한, defer ticker.Stop()
을 통해 자원을 회수할 수 있다.
go 표준라이브러는 go 데이터 타입에서 json으로 json에서 go 데이터 타입으로 변환을 위한 자원을 포함한다. mashaling
이라는 단어는 go데이터 타입을 인코딩으로 변환하는 것을 의미하고, unmarhsaling
은 go 데이터 타입으로 변환하는 것을 의미한다.
다음과 같은 json을 일고 쓰기를 한다고 하자.
{
"id":"12345",
"date_ordered":"2020-05-01T13:01:02Z",
"customer_id":"3",
"items":[{"id":"xyz123","name":"Thing 1"},{"id":"abc789","name":"Thing 2"}]
}
다음과 같이 json 데아터를 맵핑하기 위한 구조체를 만들 수 있다.
type Order struct {
ID string `json:"id"`
DateOrdered time.Time `json:"date_ordered"`
CustomerID string `json:"customer_id"`
Items []Item `json:"items"`
}
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
구조체 항목 뒤에 쓰여진 문자열인 구조체 태그로 json을 처리하기 위한 규칙을 지정한다. 구조체 태그는 backtick(백틱)으로 표시된 문자열이다. 구조체 태그는 하나 이상의 태그/값의 쌍으로 구성되어 tag:value
으로 쓰고 공백으로 구분한다.
이것도 단순한 문자열이기 때문에 컴파일러는 해당 포맷이 정상적인지 검증할 수 없지만, go vet
은 할 수 있다. 또한 이러한 모든 항목은 외부로 노출이 된다는 것이다. 모든 다른 패키지처럼 encoding/json
패키지에 있는 코드는 다른 패키지의 구조체 내에 노출되지 않은 항목은 접근할 수 없다. 즉, 구조체 태그로 구조체에 접근하려면 구조체의 첫 글자가 대문자여야 한다.
json 처리를 위한 구조체 항목과 연관되어 있는 json항목의 이름을 지정하기 위한 태그이름으로 json
을 사용한다. 무런 json
태그가 제공되지 않는다면, 기본 동작으로 구조체 항목의 이름과 일치하는 json 객체 항목의 이름으로 가정한다. 이런 기본 동작에도 불구하고, 항목 이름이 같더라도 명시적으로 항목의 이름을 구조체 태그로 지정해주는 것이 가장 좋다.
json태그가 없는 구조체 항목으로 언마샬링을 하는 경우, 이름은 대소문자를 구분하지 않고 일치시킨다. json태그가 없는 구조체 항목에서 다시 json으로 언마샬링하는 경우
json
의 항목은 외부로 노출되기 때문에 첫 글자는 항상 대문자가 된다.
package main
import (
"encoding/json"
"fmt"
)
type Temp struct {
Name string
Age int
address string
}
func main() {
temp := Temp{
Name: "name",
Age: 13,
address: "seoul",
}
data, _ := json.Marshal(&temp)
fmt.Println(string(data)) // {"Name":"name","Age":13}
var temp2 Temp
_ = json.Unmarshal(data, &temp2)
fmt.Println(temp2) // {name 13 }
}
다음과 같이 go 구조체를 마샬링할 때, 특정 구조체에 json태그가 없으면 대문자인 구조체 항목은 그 이름으로 json
객체의 항목의 이름이 된다. 소문자인 구조체 항목은 json
객체의 항목에 반영되지 않는다.
반대로 json
객체를 go의 구조체로 언마샬링할 때 go 구조체의 json태그가 없다면 대문자 이름으로 언마샬된다. 만약 소문자이면 무시된다.
그러나, 되도록이면 구조체에 json태그를 넣어서 마샬링하고 언마샬링하는 것이 좋다. 만약 무시되어야 하는 경우는, json 태그의 이름에 -
를 사용하도록 하자. 만약, 항목이 비어있을 때만 출력을 제외하고 싶다면 json tag에서 value
부분 뒤에 omitempty
를 쓰면 된다.
가령 다음과 같다.
package main
import (
"encoding/json"
"fmt"
)
type Temp struct {
Name string `json:"name"`
Age int `json:"-"`
Address string `json:"address,omitempty"`
}
func main() {
temp := Temp{
Name: "name",
Age: 13,
}
data, _ := json.Marshal(&temp)
fmt.Println(string(data)) // {"name":"name"}
var temp2 Temp
_ = json.Unmarshal(data, &temp2)
fmt.Println(temp2) // {name 0 }
}
Age
의 json tag는 -
이기 때문에 json marhsal에 반영되지 않는다.
Address
는 omitempty
이기 때문에 값이 없다면 json marhsal에 반영되지 않는다. 만약, 값을 넣어주면 마샬링에 반영이 될 것이다.
한 가지 조심해야할 것은 언마샬 시에 제로값과 구분되지 못하는 경우가 있다. 가령, 위의 예제에서 Age
가 의도적으로 0인 것과 제로값인 0과 구분이 안된다는 것이다. 이런 경우를 때문에 일부 개발자는 구조체 포인터를 쓰거나, 슬라이스, 맵 등을 사용하는데, 이들의 경우는 언마샬 시 없으면 nil
이 나오기 때문이다.
구조체 태그는 프로그램의 행동 방식을 제어하는 메타데이터를 사용할 수 있도록 한다. 다른 언어, 특히 자바는 다양한 프로그램 요소가 명시적으로 무엇이 처리될 것인지 지정하지 않고 어떻게 처리되는 지를 기술하기 위해 어노테이션(annotation)을 쓰도록 개발자들에게 권장한다.
그러나 어노테이션은 코드를 어렵게 만든다는 단점이 있다. go는 짧은 코드보다는 명확한 코드를 선호한다. 구조체 태그는 자동으로 평가되지 않고 구조체 인스턴스가 함수로 전달될 때 처리된다. 이 부분에서 차이가 있는 것이다.
encoding/json
패키지에 Unmarshal
함수는 바이트 슬라이스를 구조체로 변환하기 위해 사용된다. data
라는 json 문자열을 가진다면 이것 data
을 통해 go 구조체로 변환할 수 있다.
var o Order
err := json.Unmarshal([]byte(data), &o)
if err != nil {
return err
}
json.Unmarshal
함수는 io.Reader
인터페이스의 구현과 같이 데이터를 입력 파라미터로 채운다. 이것에는 두 가지 이유가 있따.
첫번째는 io.Reader
구현과 마찬가지로 동일한 구조체를 반복해서 효율적으로 재사용할 수 있어서 메모리 사용을 제어할 수 있따. 두 번째는 이것을 수행하는데 다른 방법이 없다. go
는 1.19버전 이전까지만해도 제네릭을 지원하지않아 읽은 바이트를 저장하기 위해 어떤 타입을 인스턴스화 해야 하는 지 지정할 방법이 없다.
위의 Order
인스턴스에서 다시 바이트 슬라이스에 저장되는 json으로 쓰기를 하기 위해 encoding/json
패키지에 있는 Marshal
함수를 사용할 수 있다.
out, err := json.Marhsal(o)
그렇다면 구조체 태그로 어떻게 평가되는 지 궁금할 수 있다. 또한, json.Marhsal
과 json.Unmarshal
이 모든 타입의 구조체를 일곡 쓰는 방법도 궁금하다. 이는 사실 리플렉션과 관련이 있기 때문에 이후에 알아보도록 하자.
json.Marshal
과 json.Unmarshal
함수는 바이트 슬라이스로 동작한다. 대부분의 go 데이터 소스와 싱크는 io.Reader
와 io.Writer
인터페이스로 구현된다.
ioutil.ReadAll
을 사용하여 io.Reader
의 전체 내용을 바이트 슬라이스로 복사하고 해당 내용을 json.Unmarshal
로 읽을 수 있지만, 그것은 비효율적이다. 비슷하게 json.Marshal
을 사용하여 인메모리 바이트 슬라이스 버퍼에 쓴 다음 바이트 슬라이스를 네트워크나 디스크에 쓸 수 있지만, io.Writer
로 직접 쓸 수 있다면 더 좋을 것이다.
encoding/json
패키지는 이런 상황을 처리하기 위해 허용하는 두 가지 타입을 포함한다. json.Decoder
와 json.Encoder
타입은 io.Reader
와 io.Writer
인터페이스를 충족하는 모든 것에서 읽거나 해당 디렉터리, 파일로 쓰기가 가능하다. 어떻게 동작하는지 살펴보도록 하자.
다음의 간단한 예제를 보도록 하자.
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
toFile := Person {
Name: "Fred",
Age: 40,
}
위의 Person
구조체를 가지는 toFile
인스턴스를 파일에 저장하기로하자. 이전과 같으면 json.Marshal
을 한다음 바이트 슬라이스 째로 파일에 저장했어야 했는데, 이는 io.Writer
를 사용하는 이점이 떨어진다.
os.File
타입은 io.Reader
와 io.Writer
인터페이스를 구현해서 json.Decoder
와 json.Encoder
를 시연하기 위해 사용할 수 있다. 먼저 임시 파일에 인코딩된 데이터를 쓰기위해서, json.Encoder
를 반환하는 json.NewEncoder
를 만들어, 임시 파일에 toFile
을 쓰도록 해보자.
tmpFile, err := ioutil.TempFile(os.TempDir(), "sample-")
if err != nil {
panic(err)
}
defer os.Remove(tmpFile.Name())
err = json.NewEncoder(tmpFile).Encode(toFile)
if err != nil {
panic(err)
}
err = tmpFile.Close()
if err != nil {
panic(err)
}
tmpFile
을 열고 json.NewEncoder
에 넣어주면 tmpFile
에 파일을 쓸 준비가 완료된 것이다. json.NewEncoder
는 io.Writer
를 받아, 인코딩된 바이트를 해당 파일에 저장하도록 한다. 따라서, Encode
뒤에 toFile
을 써주면 해당 go구조체를 바이트로 변형하여 파일에 써주는 것이다.
일단 toFile
이 쓰여지면, 임시 파일에 대한 참조를 json.NewDecoder
로 전달한 다음 타입 Person
의 변수로 반환된 json.Decoder
의 Decode
메서드를 호출함으써 json을 다시 읽을 수 있다.
tmpFile2, err := os.Open(tmpFile.Name())
if err != nil {
panic(err)
}
var fromFile Person
err = json.NewDecoder(tmpFile2).Decode(&fromFile)
if err != nil {
panic(err)
}
err = tmpFile2.Close()
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", fromFile)
다음과 같이 tempFile2
는 tmpFile
에 대한 os.File
포인터이다. 이를 json.NewDecoder
에 넣어주면 io.Reader
로 해당 파일에서 데이터를 읽어 Decode
안에있는 fromFile
로 디코딩해준다.
여러 json구조체를 한 번에 읽거나 쓰기를 하려고 할 때 어떻게 해야할까? 이런 상황에서 json.Decoder
와 json.Encoder
를 사용하면 된다.
const data = `
{"name": "Fred", "age": 40}
{"name": "Mary", "age": 21}
{"name": "Pat", "age": 30}
`
위의 json 데이터 스트림이 한 번에 들어왔다고 하자. 이를 Person
구조체로 변경하기 위해서 Unmarshal
을 사용할 수가 없다. 따라서, NewDecoder
를 사용하여 이를 변경해보도록 하자.
dec := json.NewDecoder(strings.NewReader(data))
for dec.More() {
err := dec.Decode(&t)
if err != nil {
panic(err)
}
// process t
}
string 자체에는 io.Reader
인터페이스를 충족하지 않으니, strings.NewReader
로 io.Reader
를 만족하여 문자열을 읽도록 한다. json.NewDecoder
로 json stream data를 디코딩할 수 있도록 준비한다.
stream이기 때문에 for
의 dec.More()
로 순회하도록 한다. string stream 데이터들은 매 순회를 돌면서 dec.Decode(&t)
로 디코딩되어 t
에 데이터가 들어간다.
json.Encoder
를 사용하여 여러 값을 쓰는 것은 단일 값을 쓰는 데 사용하는 것과 동일하다. 해당 예제는 bytes.Buffer
에 쓰지만 io.Writer
인터페이스를 충족하는 모든 타입이 가능하다.
var b bytes.Buffer
enc := json.NewEncoder(&b)
for _, input := range allInputs {
t := process(input)
err = enc.Encode(t)
if err != nil {
panic(err)
}
}
out := b.String()
b
에 input
데이터를 차곡차곡 string stream을 쓰는 것이다.
배열에 wrapping되지 않은 데이터 스트림에서 여러 JSON 객체를 읽는 예제를 보았다. 뿐만 아니라, json.Decoder
를 사용하여 배열로 wrapping된 데이터를 읽을 수도 있는데, 이는 한번에 메모리로 전체 배열을 로딩하지 않고 배열로부터 단일 객체를 읽을 수 있어, 훌륭한 성능 향상을 만들고 메모리 사용량을 줄일 수 있다.
만약, 특정 구조체의 항목을 json으로 마샬하거나 언마샬할 때 특별한 로직을 해주어야하는 경우는 어떻게해야할까? 가령, time.Time
은 RFC 339
포맷 내에서 json 항목을 지원하지만 다른 시간 포맷을 처리해야할 수도 있다. 이는 json.Marshaler
와 json.Unmarshaler
두 인터페이스를 구현하여 새로운 타입을 생성하여 처리할 수 있다.
type RFC822ZTime struct {
time.Time
}
func (rt RFC822ZTime) MarshalJSON() ([]byte, error) {
out := rt.Time.Format(time.RFC822Z)
return []byte(`"` + out + `"`), nil
}
func (rt *RFC822ZTime) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
return nil
}
t, err := time.Parse(`"`+time.RFC822Z+`"`, string(b))
if err != nil {
return err
}
*rt = RFC822ZTime{t}
return nil
}
위의 예제는 time.Time
인스턴스를 RFC8227Time
이라는 새로운 구조체에 임베딩하여 time.Time
의 다른 메서드에도 접근할 수 있도록 하였다. 또한, RFC8227Time
구조체에 메서드로 MarshalJSON
과 UnmarhsalJSON
을 구현하도록 하여, json.Marshaler
와 json.Unmarshaler
두 인터페이스를 만족시키도록 하였다.
이제 위의 RFC8227Time
을 사용해보자.
type Order struct {
ID string `json:"id"`
DateOrdered RFC822ZTime `json:"date_ordered"`
CustomerID string `json:"customer_id"`
Items []Item `json:"items"`
}
json marhsal, unmarsal을 해보면 문제없이 동작하는 것을 볼 수 있을 것이다.
만약, 입력으로 들어오는 json
의 구조를 모른다면 어떻게해야할까? 가령, 입력으로 들어오는 json
이 무엇인지는 모르겠지만 뭐든 받아서 바이패스해야하는 상황도 있다. 이런 경우 map[string]interface{}
를 사용하여 marshal, unmarshal에 전달할 수 있다.
package main
import (
"encoding/json"
"fmt"
)
type DataJSON struct {
Data map[string]interface{}
}
func main() {
data := []byte(`{"name":"gyu", "age": 12, "info": {
"address": "seoul",
"gender": "man"
}}`)
var dataJson DataJSON
json.Unmarshal(data, &dataJson.Data)
fmt.Println(dataJson.Data) // map[age:12 info:map[address:seoul gender:man] name:gyu]
fmt.Println(dataJson.Data["name"]) // gyu
fmt.Println(dataJson.Data["age"]) // 12
info := dataJson.Data["info"].(map[string]interface{}) // interface type -> map[string]interface{}
fmt.Println(info["address"]) // seoul
fmt.Println(info["gender"]) // man
}
위와 같이 data
에 대한 정보를 잘 모를 때, DataJSON
구조체에서 Data
라는 map[string]interface{}
타입을 만들어 모든 json
을 받도록 하는 것이다. 단, 이는 interface{}
로 값을 받기 때문에 반드시 구체 타입으로 변경해주는 타입 단언(assertion)을 해주어야 한다. 또한, 타입 세이프하지도 않기 때문에 되도록이면 문자열로 받거나, json 구체 타입을 협약해서 하는 것이 좋다.
json말고도 csv, xml, base64 등과 같은 다른 인코더들도 제공한다. 만약, 인코딩하고 싶은 데이터 포맷이 있찌만 표준 라이브러리 및 서드-파티 모듈에서 지원하지 않는다면 직접 개발할 수 있다. 가령, 3GPP와 같은 4G,5G 협약 단체에서 사용하는 데이터 타입은 매우 마이너하고 회사에 specific할 수 있다. 이러한 것들은 따로 인코더와 디코더를 만드는 것이 좋다.