Help us understand the problem. What is going on with this article?

fmt.Scannerを使う

本記事はGo3 Advent Calendar 2019 14日目の記事です。前回の記事は@tennashiさんの最近よく書く HTTP サーバ基礎部分でした。

はじめに

fmtパッケージではスキャニング用の関数やインターフェイスが提供されています。これらを使用することで、パースなどの入力から受け取ったデータを意味ある何らかの値へと変換するような操作をより簡単に書くことができます。本記事ではそれらの紹介を行います。

使ってみる

紹介がてらにHTTP/1.0リクエストの最初の行であるRequest Lineをパースする例を考えてみたいと思います。Request Lineは以下のようにフォーマットが定められています 1

Method Request-URI HTTP/major.minor CRLF

もちろんstrings.Splitなどを使ってパースすることもできます。あえて書いてみると以下のようになると思います。

func Example() {
    src := "GET /index.html HTTP/1.0\n"

    src = strings.TrimRight(src, "\n")
    splited := strings.Split(src, " ")
    if len(splited) != 3 {
        return
    }

    method, uri, version := splited[0], splited[1], strings.TrimPrefix(splited[2], "HTTP/")
    splited = strings.Split(version, ".")
    if len(splited) != 2 {
        return
    }
    major, err := strconv.Atoi(splited[0])
    if err != nil {
        return
    }
    minor, err := strconv.Atoi(splited[1])
    if err != nil {
        return
    }

    fmt.Printf("%s %s %d %d", method, uri, major, minor)
    // Output:
    // GET /index.html 1 0
}

fmtパッケージのスキャン用の関数を使うと以下のように書くことができます。ここではstringを入力にできるfmt.Sscanfを使っています。

func Example() {
    src := "GET /index.html HTTP/1.0\n"

    var (
        method, uri  string
        major, minor int
    )
    if _, err := fmt.Sscanf(src, "%s %s HTTP/%d.%d\n", &method, &uri, &major, &minor); err != nil {
        return
    }

    fmt.Printf("%s %s %d %d", method, uri, major, minor)
    // Output:
    // GET /index.html 1 0
}

前者の例では必要であったトリムや型の変換などの操作が、後者の例では登場しません。その分後者のコードの方がスッキリしているように思えます。実際それらの操作はコードの読み手に前提としているフォーマットを意識させるため、可読性を低下させる要因になっていると思います。

私の経験上の話ではありますが、パースなどのフォーマットにしたがって入力から意味ある値を引き抜きたい際に、このようにfmt.Scanを使うと簡単にそれができる場合がありました。以下ではfmt.Scanの基本的な紹介をします。

Fscan, Scan, Sscan

fmtパーケージで提供されているスキャニング用関数には、io.ReaderからスキャンするFscan系、io.StdinからスキャンするScan系、stringからスキャンするSscan系があります。それぞれにデフォルト(後述)のフォーマットでスキャンする関数(FscanScanSscan)、指定したフォーマットからスキャンする関数(FscanfScanfSscanf)と、最初の改行までをスキャンする関数(FscanlnScanlnSscanln)が用意されています(1)。

デフォルトのフォーマットはスペース、あるいは改行区切りというものです。このフォーマットは、フォーマットを指定しないFscanScanSscanFscanlnScanlnSscanlnで使用されます。ただFscanlnScanlnSscanlnは最初の改行でスキャンを止めるので、これらは実際はスペース区切りでスキャンします。他方FscanfScanfSscanfに対して指定するフォーマットではスペースと改行は区別されます。そのため以下のコードは一見すると同じように見えますが期待するフォーマットが違います。

var a, b, c string

fmt.Scan(&a, &b, &c)

fmt.Scanf("%s %s %s", &a, &b, &c)

後者は指定した通りの%s %s %sを期待するものですが、前者の場合は例えばa\nb\ncのような入力でもエラーを返さずスキャンします。

ちなみにFscan系に渡したio.Readerは1 byteずつ読み込むのに使用されます(2)。そのため*os.FileなどのReadを呼ぶコストが高い型は、bufio.Readerなどへラップしておいた方が良いと思います。下のベンチマークはファイル({'a'*2047}'\b'{'a'*2047})を読み込む際に、*os.Fileをそのまま渡した場合(BenchmarkFile)とbufio.Readerへとラップした場合(BenchmarkBuffer)との比較です。

go test -bench .
goos: darwin
goarch: amd64
BenchmarkFile-8         1000000000               0.00775 ns/op
BenchmarkBuffer-8       1000000000               0.000097 ns/op

ScanState

スペースや改行区切りのような単純な場合はすでに用意されているFscanf等を使用すればスキャンできます。しかし現実には、扱う対象がもっと複雑な場合も多いでしょう。例えばHTTP/1.0では、ヘッダーに,区切りで値が続くフィールドがいくつかありますが、何度値が続くかはもちろんわからないため、可変引数を渡すということもできません。

Allow: HEAD, GET, ...

fmt.Scanner(3)を用意することで、独自な実装でスキャンを行うことができます。fmt.Scannerのシグネチャーは以下の通りです。

type Scanner interface {
    Scan(state ScanState, verb rune) error
}

fmt.Printなどでのfmt.Stringerfmt.Formatterなどと同じように、
独自のScannerを用意してFprintなどに渡せば、独自実装が使われるようになります(4)。

// If the parameter has its own Scan method, use that.
if v, ok := arg.(fmt.Scanner); ok {
    err = v.Scan(s, verb)
    if err != nil {
        if err == io.EOF {
            err = io.ErrUnexpectedEOF
        }
        s.error(err)
    }
    return
}

Scanner.Scanの第1引数にはScanState(5)という以下のインターフェイスが、第2引数にはフォーマットで指定した%sのsなどの動詞(verb)が渡されます。

type ScanState interface {
    ReadRune() (r rune, size int, err error)
    UnreadRune() error
    SkipSpace()
    Token(skipSpace bool, f func(rune) bool) (token []byte, err error)
    Width() (wid int, ok bool)
    Read(buf []byte) (n int, err error)
}

独自実装では、このScanStateを使って、すでに渡してあるio.Readerから読み込んでいくことになります。ここに渡されるScanStateの実体はfmtパッケージのunexportedな型になっています(6)。上記の通りScanStateio.Readerio.RuneScannarでもあります。しかしドキュメントに書かれている通り(7)、ScanStateはすでにio.RuneScannarを満たしているので、ScanState.Readは使われるべきではないでしょう。fmtパッケージから渡されるScanState.Readの実装はその旨のエラーを返すようになっています(8)。

ただio.Readerであることには変わりないので、独自実装側でもFscan系の第一引数に渡すことができます。

func (f *foo) Scan(state fmt.ScanState, _ rune) error {
    // ...
    if _, err := fmt.Fprint(state, &f.bar); err != nil {
        return err
    }
}

Fprint系は渡されたio.Readerio.RuneScannerでもあった場合は、そちらを使うようになっています(9)。

if rs, ok := r.(io.RuneScanner); ok {
    s.rs = rs
} else {
    s.rs = &readRune{reader: r, peekRune: -1}
}

これらを踏まえると、上であげたHTTP/1.0ヘッダーのフィールドは以下のようにスキャンすることができます。

func Example() {
    src := "Allow: HEAD, GET"

    var field headerField
    if _, err := fmt.Sscan(src, &field); err != nil {
        return
    }

    fmt.Print(field.key, " ", field.values)
    // Output:
    // Allow [HEAD GET]
}

type headerField struct {
    key    headerFieldKey
    values headerFieldValues
}

func (h *headerField) Scan(state fmt.ScanState, _ rune) error {
    if _, err := fmt.Fscanf(state, "%s:%s\n", &h.key, &h.values); err != nil {
        return err
    }

    return nil
}

type headerFieldKey string

func (k *headerFieldKey) Scan(state fmt.ScanState, _ rune) error {
    read, err := state.Token(true, func(char rune) bool {
        return char != ':'
    })
    if err != nil {
        return err
    }

    *k = headerFieldKey(read)

    return nil
}

type headerFieldValues []string

func (vs *headerFieldValues) Scan(state fmt.ScanState, _ rune) error {
    state.SkipSpace()

    read, err := state.Token(true, func(char rune) bool {
        return char != '\n'
    })
    if err != nil {
        return err
    }

    splited := strings.Split(string(read), ",")
    *vs = make(headerFieldValues, len(splited))
    for i, v := range splited {
        (*vs)[i] = strings.TrimLeft(v, " ")
    }

    return nil
}

この例で登場するScanState.Tokenは第2引数に渡した関数がtrueを返す限り読み続けます。上述の通りfmtパッケージが渡すScanStateの実装はReadでエラーを返すため、ioutil.ReadAllなどの純粋なio.Readerが求められる場合には使用できません。このTokenを使えば、独自実装内でも例えばEOFまで読むということができます。

read, err := state.Token(true, func(char rune) bool {
    return true
})
if err != nil && err != io.EOF {
    // ...
}

あるいはGoでの関数リテラルがクロージャーであること(10)を利用して、Content-Length分だけ読み込むということもできます。

type body struct {
    len    int
    content string
}

func (b *body) Scan(state fmt.ScanState, _ rune) error {
    var n int
    read, err := state.Token(false, func(char rune) bool {
        defer func() { n++ }()
        return n < b.len
    })
    if err != nil && err != io.EOF {
        return err
    }

    (*b).content = string(read)

    return nil
}

fmtパッケージから渡されるTokenの実装は、直前に読み込んだものを[]byteでバッファリングし、それを戻り値として返します(11)。そのためドキュメントに示されている通り(12)、Tokenを連続で読んだりすると、それぞれの戻り値が最後に読み込んだものに上書きされてしまいます。Tokenを使う際には受け取った値はすぐにcopyしておくと良いと思います。

またScanStateにはUnreadRuneがありますが、Fprint系に独自のio.RuneScannerを渡さない限り、すでにUnreadRuneしていた場合はエラーになり(13)、fmtパッケージのScanStateの実装はそのエラーを返しません(14) 2。そのためUnreadRuneを呼ぶ場合は、常にReadRuneの直後の1回だけになると思います。

func (r *readRune) UnreadRune() error {
    if r.peekRune >= 0 {
        return errors.New("fmt: scanning called UnreadRune with no rune available")
    }
    // Reverse bit flip of previously read rune to obtain valid >=0 state.
    r.peekRune = ^r.peekRune
    return nil
}
func (s *ss) UnreadRune() error {
    s.rs.UnreadRune()
    s.atEOF = false
    s.count--
    return nil
}

ScanStateには他にも、%5sでの5のような幅を返すWidthや、Scannerにはverb(動詞)も渡されるので、これらも活用できると思います。

おわりに

本記事ではfmt.Scannerについて紹介しました。スペースやコンマ区切りの単純なケースではデフォルトのScannerにそのまま任せることができます。スキャンの間により複雑な処理が必要な場合は、独自実装なScannerに移譲することもできます。どちらの場合もFprintなどのAPIを使用することができます。特に'スペース'という言葉にスペースも改行も含まれる等のフォーマットの部分が複雑ですが、この記事がその解決の糸口になれば幸いです。

ちなみにnet.Connio.Readerを満たしているので上記の例を組み合わせると、HTTP/1.0系のリクエストのパースは

fmt.Fscan(conn, &req)

という具合に書けたりします。

参考文献

  1. https://golang.org/pkg/fmt/#hdr-Scanning
  2. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L321
  3. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L55
  4. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L385
  5. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L21
  6. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L157
  7. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L45
  8. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L179
  9. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L385
  10. https://golang.org/ref/spec#Function_literals
  11. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L446
  12. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L38
  13. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L370
  14. https://github.com/golang/go/blob/7d30af8e17d62932f8a458ad96f483b9afec6171/src/fmt/scan.go#L233

  1. RFC1945をもとに簡略化しています。 

  2. ただScanStateの内部実装であるssのcountはどんどん引かれたりしているので、もしかしたら修正される必要があるのかもしれません。 

tomocy
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした