Goでトークナイズ処理してみる。

Goのtext/scannerを使うと、トークナイズ処理ができるらしい。

やりたいことは単純で

  • スペースに区切られたログをタブ区切りにしたい。
  • 一回の読み込みでスペース区切りしてみたい。(→なのでトークナイザを使う)

ただ少し難点があって

  • "aaa bbb ccc" [2018-04-10 10:32]といったダブルクオーテーションや[]で囲まれた場合はタブ区切りにしてほしくない。

ほんとやりたいことは単純だ。


これまでに正規表現を使ってみたり、単純にスペースでsplitして各itemを"で判断してまたコンカチ、でもいいんだけど、
Goのscannerを使ってみたかったのでやってみることにした。

ちなみにだけど正規表現処理はpythonでもGoでもめちゃ遅いしCPUをけっこう消費することが分かっている。

text/scanner では解決できなかった

結論から言うと、text/scannerでは解決できないことが分かった。
scanner - The Go Programming Language

※なのでタイトルみたくトークナイズ処理はできてないけど
似た処理はできた。


基本的にScan()関数は一文字ごとに判断している。

その一文字が数値、文字や記号であれば、その文字以降が数値、コメント、識別子・・・といった
意味のあるカテゴリーの文字の羅列になっているときに意味のある単語(トークン)のかたまりとして判断される。

なのでスペースで区切った文字列全部をトークンとして判断させようとしても、
下記のカテゴリに当てはまってなければ1文字づつ区切られてしまうのでダメだった。

src/text/scanner/scanner.go - The Go Programming Language

GoTokens  = ScanIdents | ScanFloats | ScanChars | ScanStrings | ScanRawStrings | ScanComments | SkipComments

思い通りのトークナイザはできないがサンプルコードを載せておく。
ここを参考にした。
https://socketloop.com/tutorials/golang-how-to-tokenize-source-code-with-text-scanner-package

package main

import (
        "fmt"
        "strings"
        "text/scanner"
)

func main() {
        text_log := `2018-04-10T10:32:00.123456Z hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"`
        codeReader := strings.NewReader(text_log)
        fmt.Println(text_log)
        fmt.Println("---------------------------")

        var scn scanner.Scanner
        scn.Init(codeReader)
        // * Whitespaceは区切り文字として削除する文字。デフォルトは下記
        //  GoWhitespace = 1<<'\t' | 1<<'\n' | 1<<'\r' | 1<<' '
        // 下記のようにして変更できる。
        //scn.Whitespace = 1<<' '
        
        // * Mode:トークンとして判断するカテゴリ。デフォルトは下記
        // Mode : ScanIdents | ScanFloats | ScanChars | ScanStrings | ScanRawStrings | ScanComments | SkipComments
        // 下記のようにして変更できる。
        //scn.Mode = scanner.ScanIdents | scanner.ScanComments
        tok := scn.Scan()
        fmt.Println(scn.TokenText())
        for tok != scanner.EOF {
                tok = scn.Scan()
                //fmt.Println(tok)
                fmt.Println(scn.TokenText())
        }
}

これを実行するとこんな結果になる。

2018-04-10T10:32:00.123456Z     hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"
---------------------------
2018     # <--- タイムスタンプがぶつ切りになってしまってる
-
04
-
10
T10
:
32
:
00.123456
Z
hoge1234hage
aaa
10.20     # <--- IPアドレスも数値と判断されてしまってる
.30
.40
:
80
0.000
0.003
200
401
"GET http://hoge.fuga.com/uaaa HTTP/1.1"

テックキャンプ

bufio の scanner で解決できた

そこでどうしようかとググってみたらよさげな
サンプルを見つけたので試してみたところ、うまくいった。
baubaubau.hatenablog.com
参考にさせていただきます!

package main

import (
        "bufio"
        "fmt"
        "strings"
)

func main() {
        fmt.Println("------Sample Text-------")
        text_log := `2018-04-10T10:32:00 [2018-04-10 10:32] hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"`
        codeReader := strings.NewReader(text_log)
        fmt.Println(text_log)
        fmt.Println("---------------------------")
        fmt.Println("Normal splitter")
        fmt.Println()

        scn := bufio.NewScanner(codeReader)
        scn.Split(bufio.ScanWords)
        for scn.Scan(){
                fmt.Println(scn.Text())
        }

        fmt.Println("---------------------------")
        fmt.Println("Customized splitter")
        fmt.Println()

        codeReader = strings.NewReader(text_log)
        scn_sp := bufio.NewScanner(codeReader)

        dbl_q_on := false
        splitSpace := func(data []byte, atEOF bool) (advance int, token []byte, err error){
                for i := 0 ; i < len(data) ; i++ {
                        if data[i] == '"' || data[i] == '[' || data[i] == ']' {
                                dbl_q_on = ! dbl_q_on
                        }
                        if data[i] == ' ' {
                                if ! dbl_q_on {
                                        return i + 1, data[:i], nil
                                }
                        }
                }
                return 0, data, bufio.ErrFinalToken
        }

        scn_sp.Split(splitSpace)
        for scn_sp.Scan(){
                fmt.Println(scn_sp.Text())
        }
}
------Sample Text-------
2018-04-10T10:32:00 [2018-04-10 10:32] hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"
---------------------------
Normal splitter

2018-04-10T10:32:00
[2018-04-10
10:32]            # <--- こういうのだめ
hoge1234hage
aaa
10.20.30.40:80
0.000
0.003
200
401
"GET
http://hoge.fuga.com/uaaa            # <--- こういうのだめ
HTTP/1.1"
---------------------------
Customized splitter

2018-04-10T10:32:00
[2018-04-10 10:32]            # <--- うまくいった
hoge1234hage
aaa
10.20.30.40:80
0.000
0.003
200
401
"GET http://hoge.fuga.com/uaaa HTTP/1.1"            # <--- うまくいった

さらにもうちょっと改善

上述のコードをもとにもうちょっと、一回のreadでパースできないかと
Scanを使わないで一文字づつの判定でパースするようにしてみた。
それとメモリ消費も抑えるようにもやってみた。

package main

import (
        "fmt"
        "unsafe"
)

func parseLog(data *[]byte) *string {
        dbl_q_on := false
        output := ""
        cur_pos := 0
        for i := 0 ; i < len(*data) ; i++ {
                if (*data)[i] == '"' || (*data)[i] == '[' || (*data)[i] == ']' {
                        dbl_q_on = ! dbl_q_on
                }
                if (*data)[i] == ' ' {
                        if ! dbl_q_on {
                                fmt.Println(string((*data)[cur_pos:i]))
                                output += string((*data)[cur_pos:i]) + "\t"
                                cur_pos = i + 1
                        }
                }
        }
        output += string((*data)[cur_pos:])
        return &output
}


func main() {
        text_log := `2018-04-10T10:32:00 [2018-04-10 10:32] hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"`
        fmt.Println(text_log)
        fmt.Println("-------------------------")

        text_log_b := *(*[]byte)(unsafe.Pointer(&(text_log)))
        //fmt.Println(text_log_b)

        output_str := parseLog(&text_log_b)


        fmt.Println("-------------------------")
        fmt.Println(*output_str)
}

unsafeはどこでも言われてるが使う時に注意が必要なので、むやみやたらとunsafeを使うことはおすすめしない。
unsafeはログを読み込んで1行をbyteに変換することも想定にいれてて
メモリ消費を減らせないかと考えたので使ってる。

2018-04-10T10:32:00 [2018-04-10 10:32] hoge1234hage aaa 10.20.30.40:80 0.000 0.003 200 401 "GET http://hoge.fuga.com/uaaa HTTP/1.1"
-------------------------
2018-04-10T10:32:00
[2018-04-10 10:32]
hoge1234hage
aaa
10.20.30.40:80
0.000
0.003
200
401
-------------------------
2018-04-10T10:32:00     [2018-04-10 10:32]      hoge1234hage    aaa     10.20.30.40:80  0.000   0.003   200     401     "GET http://hoge.fuga.com/uaaa HTTP/1.1"

CPUの負荷も下がった

ログをぶったぎるのにトークナイザを使おうとしたのは
ログをreadする処理を減らしたかったからだ。

スペースでsplitして各itemの先頭をさらに判断してパースする場合
推測なんだけど、スペースを探す処理でログを一回readして、
さらに各itemをforループするときにもう一回readしてる。
2回のループ。

でも先頭から一文字づつ読み込んで判定すれば、
適切な場所に来たら1トークンとなるので、readは一回で済む(と思う)。

そのせいか大量ログで試した場合、splitでやるよりも
readを減らした方がCPU負荷がかなり下がった

勉強になりました。


フリーランスコース

Goならわかるシステムプログラミング

Goならわかるシステムプログラミング