關於 web service, unity, blogger 等軟體工程筆記

Go Reader Pattern 設計與實作

Edit icon 沒有留言
Go

最近在寫小專案所遇到的設計問題,其中一個目標是從檔案中讀取英文單字 (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)
}

延伸資料

沒有留言: