はじめに

Go で access-log-parser というモジュールを作りました。非常に小さいモジュールですが、開発の過程で Go を書く上での慣習、 CI 手法、リリース手法などが学べたので、枯れたネタではありますが紹介します。

概要

主に以下の機能を提供します。

  • 複数の正規表現を順番にマッチさせることができる (S3 など世代を持つログフォーマットを扱える)
  • 正規表現の名前付きキャプチャグループからフィールド名を判断する
  • デフォルトで NDJSON 形式を返すが、その動作を上書きして独自の形式を返すことができる
  • Metadata (簡単な集計情報とアンマッチデータ) も返す
  • GZIP を直接解析できる
  • ZIP アーカイブを一括で直接解析できる
  • ZIP の場合はエントリを glob パターンでフィルタできる

注意点

以下のように注意点もあります。

  • 正規表現にキャプチャグループがひとつもない場合はエラーを返す
  • キャプチャグループはあるが名前がついていない場合もエラーを返す

インストール

go get "github.com/nekrassov01/access-log-parser@latest"

実装例

こんな感じで使います。

package main

import (
    "fmt"
    "log"
    "regexp"
    "strings"

    "github.com/nekrassov01/access-log-parser"
)

var (
    patterns = []*regexp.Regexp{
        regexp.MustCompile("(?P<field1>[!-~]+) (?P<field2>[!-~]+) (?P<field3>[!-~]+)"),
    }
    sample = `aaa bbb ccc
xxx yyy zzz
111 222 333`
)

func main() {
    p := parser.NewParser()
    if err := p.AddPatterns(patterns); err != nil {
        log.Fatal(err)
    }
    out, err := p.Parse(strings.NewReader(sample), nil)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(strings.Join(out.Data, "\n"))
    fmt.Println(out.Metadata)
}
# 出力
{"index":1,"field1":"aaa","field2":"bbb","field3":"ccc"}
{"index":2,"field1":"xxx","field2":"yyy","field3":"zzz"}
{"index":3,"field1":"111","field2":"222","field3":"333"}
{"total":3,"matched":3,"unmatched":0,"skipped":0,"source":"","errors":null}

別記事で紹介しますが、このモジュールを使って AWS サービスのログを解析する CLI アプリケーションも作りました。具体的な実装例が気になる場合はこちらを見ていただいたほうがよいかと思います。

使い方

S3 のアクセスログを例に取ります。

まずは Parser をインスタンス化します。オプションを渡すことでハンドラの動作を上書きできますが、これはのちほど説明します。

p := parser.NewParser()

正規表現パターンのコンパイル

S3 は歴史的経緯でログのフィールドが何度も拡張されています。このケースに対応しようとすると、正規表現を世代ごとに用意して新しいほうから順番にマッチさせていく必要があります。このため正規表現はスライスで渡す設計にしています。

以下は正規表現パターンのスライスを作る一例です。フィールド名を判断するため、必ず名前付きキャプチャグループを定義する必要があります (append の回数を減らすため、少し冗長になっています)

    sep := " "
    basePattern := []string{
        `^(?P<bucket_owner>[!-~]+)`,
        `(?P<bucket>[!-~]+)`,
        `(?P<time>\[[ -~]+ [0-9+]+\])`,
        `(?P<remote_ip>[!-~]+)`,
        `(?P<requester>[!-~]+)`,
        `(?P<request_id>[!-~]+)`,
        `(?P<operation>[!-~]+)`,
        `(?P<key>[!-~]+)`,
        `"(?P<request_uri>[ -~]+)"`,
        `(?P<http_status>\d{1,3})`,
        `(?P<error_code>[!-~]+)`,
        `(?P<bytes_sent>[\d\-.]+)`,
        `(?P<object_size>[\d\-.]+)`,
        `(?P<total_time>[\d\-.]+)`,
        `(?P<turn_around_time>[\d\-.]+)`,
        `"(?P<referer>[ -~]+)"`,
        `"(?P<user_agent>[ -~]+)"`,
        `(?P<version_id>[!-~]+)`,
    }

    additions := [][]string{
        {
            `(?P<host_id>[!-~]+)`,
            `(?P<signature_version>[!-~]+)`,
            `(?P<cipher_suite>[!-~]+)`,
            `(?P<authentication_type>[!-~]+)`,
            `(?P<host_header>[!-~]+)`,
            `(?P<tls_version>[!-~]+)`,
            `(?P<access_point_arn>[!-~]+)`,
            `(?P<acl_required>[!-~]+)`,
        },
        {
            `(?P<host_id>[!-~]+)`,
            `(?P<signature_version>[!-~]+)`,
            `(?P<cipher_suite>[!-~]+)`,
            `(?P<authentication_type>[!-~]+)`,
            `(?P<host_header>[!-~]+)`,
            `(?P<tls_version>[!-~]+)`,
            `(?P<access_point_arn>[!-~]+)`,
        },
        {
            `(?P<host_id>[!-~]+)`,
            `(?P<signature_version>[!-~]+)`,
            `(?P<cipher_suite>[!-~]+)`,
            `(?P<authentication_type>[!-~]+)`,
            `(?P<host_header>[!-~]+)`,
            `(?P<tls_version>[!-~]+)`,
        },
        {
            `(?P<host_id>[!-~]+)`,
            `(?P<signature_version>[!-~]+)`,
            `(?P<cipher_suite>[!-~]+)`,
            `(?P<authentication_type>[!-~]+)`,
            `(?P<host_header>[!-~]+)`,
        },
        {},
    }

    patterns = make([]*regexp.Regexp, len(additions))
    for i, addition := range additions {
        patterns[i] = regexp.MustCompile(strings.Join(append(basePattern, addition...), sep))
    }

Parser に正規表現パターンをセット

上記で作成した正規表現パターンを AddPatterns メソッドに渡します。この時点でパターンが検査され、たとえば名前のないキャプチャグループが検出された場合などはエラーになります。

if err := p.AddPatterns(patterns); err != nil {
    log.Fatal(err)
}

メソッド

インスタンス化した Parser には以下のメソッドが生えています。

# シグネチャ
func (p *Parser) Parse(input io.Reader, skipLines []int) (*Result, error)
func (p *Parser) ParseFile(input string, skipLines []int) (*Result, error)
func (p *Parser) ParseGzip(input string, skipLines []int) (*Result, error)
func (p *Parser) ParseString(input string, skipLines []int) (*Result, error)
func (p *Parser) ParseZipEntries(input string, skipLines []int, globPattern string) ([]*Result, error)

Go では様々な入力が io.Reader インターフェースを実装しているので、入力を io.Reader にしておけば型をユーザーに委ねることができます。Parse メソッドはこの用途で汎用的に使用できます。

res, err := p.Parse(reader, nil)

一方で圧縮ファイルのハンドリングなどもモジュール側の機能として担いたかったので、具体的な input を受け取るメソッドも用意しています。文字列を直接渡す場合は ParseString を使います。

log := `dummy string`
res, err := p.ParseString(log, nil)

ファイルパスを渡して読み込む場合は ParseFile を使います。なお ParseXXX 系のメソッドでは、第 2 引数でスキップする行番号を渡すことができます。ヘッダー行を読み飛ばしたいケースなどで有効です。

res, err := p.ParseFile("path/to/logfile.log", []int{1, 2})

GZIP ファイルを直接読む場合は ParseGzip を使います。

res, err := p.ParseGzip("path/to/logfile.log.gz", []int{1, 2})

ZIP アーカイブを直接読む場合は ParseZipEntries を使います。デフォルトでは ZIP エントリをすべて読み込んでそれぞれの結果を返しますが、第 3 引数で globPattern を渡すことで読み込みたいエントリをフィルタリングできます。

res, err := p.ParseZipEntries("path/to/logfile.log.zip", nil, "*.log")

戻り値

これらのメソッドは、シリアライズされた結果データとメタデータを含んだ Result 構造体を返します。

type Result struct {
    Data     []string `json:"data"`
    Metadata string   `json:"metadata"`
}

Metadata には簡単な集計情報とエラーレコードの内容が含まれます。

type Metadata struct {
    Total     int           `json:"total"`
    Matched   int           `json:"matched"`
    Unmatched int           `json:"unmatched"`
    Skipped   int           `json:"skipped"`
    Source    string        `json:"source"`
    Errors    []ErrorRecord `json:"errors"`
}

ErrorRecord はマッチしなかったレコードの行番号と内容を保持します。

type ErrorRecord struct {
    Index  int    `json:"index"`
    Record string `json:"record"`
}

カスタマイズ

Result 構造体のフィールドである DataMetadata はそれぞれ、デフォルトでは NDJSON (newline-delimited JSON) 形式にシリアライズされます。マッチした行に対するハンドラ関数とメタデータに対するハンドラ関数があり、それらはデフォルトのハンドラ関数としてエクスポートされます。

func DefaultLineHandler(matches []string, fields []string, index int) (string, error) {
    var builder strings.Builder
    _, err := builder.WriteString(fmt.Sprintf("{\"index\":%d", index))
    if err != nil {
        return "", fmt.Errorf("cannot use builder to write strings: %w", err)
    }
    for i, match := range matches {
        if i < len(fields) {
            b, err := json.Marshal(match)
            if err != nil {
                return "", fmt.Errorf("cannot marshal matched string \"%s\" as json: %w", match, err)
            }
            _, err = builder.WriteString(fmt.Sprintf(",\"%s\":%s", fields[i], b))
            if err != nil {
                return "", fmt.Errorf("cannot use builder to write strings: %w", err)
            }
        }
    }
    builder.WriteString("}")
    return builder.String(), nil
}

func DefaultMetadataHandler(m *Metadata) (string, error) {
    b, err := json.Marshal(m)
    if err != nil {
        return "", fmt.Errorf("cannot marshal result as json: %w", err)
    }
    return string(b), nil
}

これらのハンドラの動作は、Parser をインスタンス化する際に上書きできます。つまりログを独自の形式にフォーマットできます。以下の例は単純に JSON をインデント付きで返したい場合です。

まず pretty-print 用のヘルパー関数を用意します。

func prettyJSON(s string) (string, error) {
    var buf bytes.Buffer
    if err := json.Indent(&buf, []byte(s), "", "  "); err != nil {
        return "", fmt.Errorf("cannot format string as json: %w", err)
    }
    return buf.String(), nil
}

デフォルトのハンドラ関数をラップします。

func prettyJSONLineHandler(matches []string, fields []string, index int) (string, error) {
    s, err := parser.DefaultLineHandler(matches, fields, index)
    if err != nil {
        return "", err
    }
    return prettyJSON(s)
}

func prettyJSONMetadataHandler(m *parser.Metadata) (string, error) {
    s, err := parser.DefaultMetadataHandler(m)
    if err != nil {
        return "", err
    }
    return prettyJSON(s)
}

これらの関数を NewParser の引数で渡します。Go はオプション引数を意図的に採用していないので、今回は関数オプションパターンを使って以下のように渡す設計にしています。

    p = parser.NewParser(
        parser.WithLineHandler(prettyJSONLineHandler),
        parser.WithMetadataHandler(prettyJSONMetadataHandler),
    )

この機能はいろいろなアクセスログを特定のフォーマットに統一するようなケースで有用です。
たとえば Amazon Kinesis Data Firehose の Lambda 関数でデータ変換を行い、Amazon Redshift に送り込むようなケースです (これも検証してブログに書きたい)

おわりに

Go で自作したログパーサーについて紹介しました。まだ破壊的変更が入る可能性はありますが、よければ使ってください。
Go 初心者ながら、実際に形にしてみることで知見が広がりました。次回はこのモジュールを題材に CI とリリースの手法を紹介します。