Go Reader Pattern 設計與實作
最近在寫小專案所遇到的設計問題,其中一個目標是從檔案中讀取英文單字 (word),並且將其轉換成英文小寫,因此記錄如何在 Golang 設計此機制,並且不斷改進寫了三個版本,並撰寫額外的測試程式碼 (testing code) 進行驗證。
首先先定義 Token
類型來記錄該些英文單字,並仿造其他 io.Reader
樣式,宣告 TokenReader
:
import "io"
type Token []byte
type TokenReader interface {
Read() (Token, error)
}
初始實作
第一次的實作中,假設輸入都只會是 ASCII characters,一次性載入所有資料。並且建立 processByte
函數,處理英文字元以及處理大小寫轉換。
import "io"
func NewTokenReader(r io.Reader) TokenReader {
return &tokenReader{r: r}
}
type tokenReader struct {
r io.Reader
buf []byte
}
func (r *tokenReader) Read() (Token, error) {
if r.buf == nil {
b, err := ioutil.ReadAll(r.r)
if err != nil {
return nil, err
}
r.buf = b
}
temp := make([]byte, 0)
for len(r.buf) > 0 {
b := r.processByte(r.buf[0])
r.buf = r.buf[1:]
if b == 0 && len(temp) > 0 {
return Token(temp), nil
} else if b != 0 {
temp = append(temp, b)
}
}
if len(temp) > 0 {
return Token(temp), nil
}
return nil, io.EOF
}
func (r tokenReader) processByte(b byte) byte {
// ASCII only
if b >= 'a' && b <= 'z' {
return b
} else if b >= 'A' && b <= 'Z' {
return b + 32
}
return 0
}
準備測試
測試程式碼也是不可少,因此使用 bytes.NewReader
來建立 io.Reader
,並輸入一長串包含換行符號的測試字串。撰寫 assertReadToken
函數處理測試判斷,程式碼顯得比較優雅。
import (
"bytes"
"io"
"testing"
)
func TestReader(t *testing.T) {
d := bytes.NewReader(([]byte)(`aWfv42Ghjt 134aet
ecvrR45 RES as
U\*uuu____`))
r := NewTokenReader(d)
assertReadToken(t, r, nil, "awfv")
assertReadToken(t, r, nil, "ghjt")
assertReadToken(t, r, nil, "aet")
assertReadToken(t, r, nil, "ecvrr")
assertReadToken(t, r, nil, "res")
assertReadToken(t, r, nil, "as")
assertReadToken(t, r, nil, "u")
assertReadToken(t, r, nil, "uuu")
assertReadToken(t, r, io.EOF, "")
assertReadToken(t, r, io.EOF, "")
}
func assertReadToken(t *testing.T, reader TokenReader, expectedErr error, expectedStr string) {
t.Helper()
if token, err := reader.Read(); err != expectedErr {
t.Errorf("Got error: %v, expected: %v", err, expectedErr)
} else if expectedErr == nil {
if s := token.String(); s != expectedStr {
t.Errorf("Got token: %s, expected: %s", s, expectedStr)
}
}
}
考慮 Rune
在第一的版本的實作中,並沒有考慮其他 UTF-8 字元的存在,在某些測試文字中可能會發生錯誤,因此修改原先實作,使用 utf8.DecodeRune
來取得 rune
(符文,可想像是單一字元符號)。
此外也修改原先的 processByte
實作,改處理 rune
。至於 rune
如何轉換成 []byte
,則是使用 Golang 內部機制,先轉換成 string
,然後再轉 []byte
。
import "io"
func NewTokenReader(r io.Reader) TokenReader {
return &tokenReader{r: r}
}
type tokenReader struct {
r io.Reader
buf []byte
}
func (r *tokenReader) Read() (Token, error) {
if r.buf == nil {
b, err := ioutil.ReadAll(r.r)
if err != nil {
return nil, err
}
r.buf = b
}
temp := make([]byte, 0)
for len(r.buf) > 0 {
rv, size := utf8.DecodeRune(r.buf)
rv = r.processRune(rv)
r.buf = r.buf[size:]
if rv == 0 && len(temp) > 0 {
return Token(temp), nil
} else if rv != 0 {
temp = append(temp, []byte(string(rv))...)
}
}
if len(temp) > 0 {
return Token(temp), nil
}
return nil, io.EOF
}
func (r tokenReader) processRune(rv rune) rune {
if rv >= 'a' && rv <= 'z' {
return b
} else if rv >= 'A' && rv <= 'Z' {
return rv + 32
}
return 0
}
考慮 Streaming 以及 Rune
最後考慮實作 Streaming read 的機制,畢竟記憶體可能無法一次載入大檔案資料,分成多次讀取與批次處理。
幸好 bufio.NewReader
已實作緩衝機制 (buffered),而且還提供 ReadRune
的實作,改寫起來相當輕鬆寫意。
import (
"bufio"
"io"
)
func NewTokenReader(r io.Reader) TokenReader {
return &tokenReader{bufio.NewReader(r)}
}
type tokenReader struct {
r *bufio.Reader
}
func (r tokenReader) Read() (Token, error) {
temp := make([]byte, 0)
for {
rv, _, err := r.r.ReadRune()
if err != nil {
if err == io.EOF && len(temp) > 0 {
return Token(temp), nil
}
return nil, err
}
rv = r.processRune(rv)
if rv == 0 && len(temp) > 0 {
return Token(temp), nil
} else if rv != 0 {
temp = append(temp, []byte(string(rv))...)
}
}
}
func (r tokenReader) processRune(rv rune) rune {
if rv >= 'a' && rv <= 'z' {
return rv
} else if rv >= 'A' && rv <= 'Z' {
return rv + 32
}
return 0
}
下一步呢?
開啟檔案,使用 TokenReader
的範例:
package main
import (
"fmt"
"io"
"os"
)
func main() {
file, err := os.Open("mobydick.txt")
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot open file, %s\n", err.Error())
os.Exit(1)
}
defer file.Close()
reader := NewTokenReader(file)
for {
token, err := reader.Read()
if err == io.EOF {
break
}
fmt.Printf("%s\n", token)
}
}
或是修改其 interface,以因應未來處理讀取不同的資料,例如以下改成採用 GPS 座標:
type GPSPoint struct {
Latitude float64
Longitude float64
Timestamp time.Time
}
type TokenReader interface {
Read() (GPSPoint, error)
}
沒有留言: