Quantcast
Browsing Latest Articles All 25 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

ベイズを全くわからない人がベイジアンフィルタを利用して、投稿データから投稿者を推定してみる

この記事は Go2 AdventCalendar の1日目の記事です。

ガチガチのGoネタは Go AdventCalendar の方が書いてくれると思いますので、
今回は弊社が利用している(作っている?) Unipos というサービスのデータをGoから利用して遊んでみようと思います。

Uniposとはご存知の方もいると思いますが、感謝の言葉とポイントを送り合うサービスで、感謝のコメントが社員から沢山投稿されています。
弊社の場合には比較的長めの投稿も多いため、今回は 投稿した内容(文章)から投稿者をベイズフィルタを利用して当てることができるだろうか?というのを試してみます。

ちなみに、記事の中にはGoのコードは殆ど出てきません・・・
あと、ベイズ方面は全く詳しくありません。調べながら書いてるので内容的にアレな部分は、 なめらかなマサカリ(コメント) でいただけるとありがたいです。

事前準備

  • goの実行環境
  • Uniposの投稿データ(画面をコツコツスクレイピングする or ユーザスクリプトなどで集めます)
    • Fringe81の直近の投稿データ
    • 学習用に8090件、確認用に1857件に分ける

Goでベイジアンフィルタをどう実装するか

ベイズの部分は実装できそうな雰囲気もあったのですが、実装を誤っていてもこの分野に明るくないので良い/悪いの判断ができそうにないので、 https://github.com/jbrukh/bayesian を利用しました。

こんな感じに実装することで実際に試せます

const (
    Good Class = "Good"
    Bad  Class = "Bad"
)

func Test_SimpleClassification(t *testing.T) {

    classifier := NewClassifier(Good, Bad)

    goodStuff := []string{"tall", "rich", "handsome"}
    badStuff := []string{"poor", "smelly", "ugly"}

    classifier.Learn(goodStuff, Good)
    classifier.Learn(badStuff, Bad)

    scores, likely, _ := classifier.LogScores([]string{"tall", "girl"})

    t.Log(scores) // [-27.12019549216256 -51.350019226428955]
    t.Log(likely) // 0

    probs, likely, _ := classifier.ProbScores([]string{"tall", "girl"})

    t.Log(probs) // [0.99999999997 2.99999999991e-11]
    t.Log(likely) // [0.99999999997 2.99999999991e-11]
}

上の実装例を見てわかるように、Learn(学習)させる場合には、トークン(文章を単語単位に分けたもの)で渡す必要があります。
英語の場合には基本的にスペースで区切られれているので単語分割が容易なのですが、日本語は簡単にはできません。

Goで日本語の分かち書きをする

日本語の文章の場合、先程も書いたように文章の中から単語を切り出すのは簡単ではなく、
形態素解析などを利用して分割するのが一般的なようです。

形態素解析(けいたいそかいせき、Morphological Analysis)とは、文法的な情報の注記の無い自然言語のテキストデータ(文)から、対象言語の文法や、辞書と呼ばれる単語の品詞等の情報にもとづき、形態素(Morpheme, おおまかにいえば、言語で意味を持つ最小単位)の列に分割し、それぞれの形態素の品詞等を判別する作業である。

MeCabなどが有名ですが、Goではikawahaさんの https://github.com/ikawaha/kagome という実装がありますので、それを利用します。

コード的には以下のような形で利用でき、かなり高速です。(私感

func Test_日本語分かち書き(t *testing.T) {

    to := tokenizer.New()
    tokens := to.Tokenize("最高の寿司体験")

    for _, token := range tokens {
        if token.Class == tokenizer.DUMMY {
            fmt.Printf("%s\n", token.Surface)
            continue
        }
        features := strings.Join(token.Features(), ",")
        fmt.Printf("%s\t%v\n", token.Surface, features)

    // BOS
    // 最高   名詞,一般,*,*,*,*,最高,サイコウ,サイコー
    // の  助詞,連体化,*,*,*,*,の,ノ,ノ
    // 寿司   名詞,一般,*,*,*,*,寿司,スシ,スシ
    // 体験   名詞,サ変接続,*,*,*,*,体験,タイケン,タイケン
    // EOS
    }
}

実際に動かしてみる

実装コードなどは省いてしまいましたが、完成したコードを動かしてみます。

前提にも書きましたが、学習用に8090件、確認用に1857件のデータを利用します。

チューニングなし(ベース)

正解 : 446件
不正解 : 1411件
正解率 : 24.0172%

学習データの文章が著しく短いものを省く

ただ単にカンですが、学習用のデータが一言ぐらいしかない場合のデータは良くないかな?ということで短めのものを省きます。
これもカンですが、twitter程度の長さ以下のものは省いてみます。

条件 : 140文字以下の学習データを除外する

正解 : 473件
不正解 : 1384件
正解率 : 25.4712%

ちょっと正解率が上がりました。
調子に乗ってもう少し除外条件を長くしてみます

条件 : 200文字以下の学習データを除外する

正解 : 481件
不正解 : 1376件
正解率 : 25.9020%

さらに正解率が上がりました。
もう少し長いほうが良いのでしょうか?長くしてみます。

条件 : 400文字以下の学習データを除外する

正解 : 423件
不正解 : 1434件
正解率 : 22.7787%

下がってしまいました・・・
おそらく学習対象データがトータルで 3186 件まで減ってしまったというのも問題かもしれません。

ちなみに、300件も試しましたが、 24.6634% ぐらいになってしまうので200文字付近が適切そうです。

学習データの一部の品詞を除外してみる

一般的にベイジアンフィルタをやる場合には 名詞 を抽出するとどこかで見かけました[要出典]ので、
品詞を抽出して試してみます

条件 : 名詞のみ学習データに含む

学習データは200文字以下除外

正解 : 320件
不正解 : 1537件
正解率 : 17.2321%

残念下がってしまいました・・・
とはいえめげずに別のパラメータを試してみます。

条件 : 助詞を省く

なんか 助詞 とかいらないっしょ、みたいなカジュアルなカンです。
やってみます。

学習データは200文字以下除外

正解 : 376件
不正解 : 1481件
正解率 : 20.2477%

だめ、全然だめ。

条件 : 記号を省く

ほぼさっきのノリです。記号いらないっしょ

学習データは200文字以下除外

正解 : 447件
不正解 : 1410件
正解率 : 24.0711%

最終的には

雑にパラメータをいろいろいじってみましたが、最終的には

条件 : 学習データは180文字以下を省く

という条件で

正解 : 487件
不正解 : 1370件
正解率 : 26.2251%

という感じが一番良さそうです。
ちなみにユニークなユーザ数(クラス数)は167です。

つまり

素人がわからないながらに適当に作っても四分の一(26%)ぐらいの確率で、
文章から誰が投稿したかと言うのを当てることができるようです。

この値が良いのか悪いのかはちょっとわかりませんが、
今日は子供の誕生日なのに準備も手伝わずに記事を書いていたら家庭が変な空気になりつつあるのでまとめますw

まとめ

  • わからない分野なのでほんと良くわからない。悔しい、、もう少し勉強しよう。
  • データ量が結構あってもgoは速い。優秀
  • 家庭は大事にしよう

GoでCGIしてみる

というわけでCGIしてみます。

一般的なGoのWebアプリケーションからの置き換え

普通、GoでWebアプリケーションを作るときは、net/httpを用いてHTTPをしゃべるサーバを立てるかと思います。もしくは、各種フレームワークが同じようなことをやるでしょう。

import "net/http"

func main() {
    http.ListenAndServe(
        ":8080",
        http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
            // do something
        }),
    )
}

CGIの場合は、Apache等のサーバがHTTPをしゃべるサーバとなり、CGIプログラムをサーバが実行します。なので、上記方法はそのまま使えません。

そこでGoにはnet/http/cgiというものがあります。これは上記net/httpListenAndServeを置き換えればいいだけです。

import (
    "net/http"
    "net/http/cgi"
)

func main() {
    cgi.Serve(
        http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) {
            // do something
        }),
    )
}

ローカルでの確認方法

現代においてはApacheを立てるというのも相対的に難儀な作業になってきました。そこでCGIアプリケーションを確認するための簡易的な方法を紹介します。

HTTPサーバミドルウェアのh2oを用います。以下の設定でCGIアプリケーションの確認ができます。

listen:
  port: 8081
user: username
hosts:
  "localhost":
    paths:
      /:
        file.dir: ./
        file.index: ["index.cgi"]
        file.custom-handler:
          extension: .cgi
          fastcgi.spawn:
            command: "exec $H2O_ROOT/share/h2o/fastcgi-cgi"

access-log: log/access-log
error-log: log/error-log
pid-file: log/pid-file

以上をh2o.ymlで保存し、

$ h2o -c h2o.yml

で起動します。GoのCGIアプリケーションはindex.cgiで保存しておきます。もちろんパーミッションは実行可能にしておきます。これで簡単にローカルでCGIアプリケーションの確認ができます。

実際のCGIアプリケーションを作ってみる

https://gist.github.com/mackee/2247f081ad860ed36c7b27bb6886b862

というわけで作ってみました。

image.png

頑張ってる点としては

  • カウンタがファイルサイズで記録して追記で済ませるタイプ
  • コメント欄はファイル記録
    • 当時は考えていなかったCSRFだとかを考えないといけない
    • XSSはhtml/templateがある程度防御してくれている
  • ログはファイルに書いておいてレンタルサーバでデバッグしやすくする

という感じです。

まとめ

  • 簡単にですがGoでCGIについて説明しました
  • コンテナだとかサーバレスだとかはCGIっぽいアーキテクチャかと思います。CGIで済むのであればCGIでプライベートサーバレス環境を作ってみると面白いかと思います

Go製WebToolKit Buffalo[概要編]

はじめに

Golang UK Conference 2017で紹介されていたGo製WebToolKit。日本ではまだまだ事例がなさそうなのと気になっていたのでREADME公式ドキュメントを読んで概要をまとめた。ここではBuffaloとは?を理解できるよう書いていく

概要

BuffaloはGoのWebエコシステムに沿ってWebアプリケーション開発が出来るソフトウェア。Goを使って楽に素早くWeb開発できるように設計されている。
ここでエコシステムと言ってるのはBuffaloは大半の機能を独自で実装しているわけではなく、既存のpackageを利用して成り立っているからである。

Buffaloはフロントエンド(JavaScript、SCSSなど)からバックエンド(データベース、ルーティングなど)をWebプロジェクトに含めた状態で生成し起動する。生成したプロジェクトでは簡単にWebアプリケーション用APIの開発ができる。

Buffaloは単なるWebフレームワークではなく、高速にサービスを開発するための総合的な開発環境とプロジェクト構造を提供する。

ちょっと英語の翻訳が上手くできてないが、一言でまとめると「RailsやLaravelのようにCLIでプロジェクトを作成したり便利なライブラリを含んだWebフレームワークみたいなモノ」と理解してよいと思う。

開発環境要件

buffaloを動作させるには以下の環境構築が必要となるがフロントエンドやデータベースを使用しない場合は設定はオプション扱いとなる。

事前インストール

  • Goの開発環境
  • $PATH$GOPATH/binを通している
  • Goversion 1.9.7以上

フロントエンドの環境(オプション)

  • Node.js ver8以上
  • yarnかnpmでwebpackをインストールしている

データベースの環境(オプション)

データベース(sqlite)を使用しない場合は設定不要
- SQLite3を使用する場合、mattn/go-sqlite3を使用するためGCCなどのCコンパイラが必要

install

$ go get -u github.com/gobuffalo/buffalo/buffalo

依存してるGo package

buffaloは車輪の再発明をせず、すでにGoコミュニティに存在する便利なpackageを利用してプロジェクトが成り立っている。
依存しているGo packageは巨人の肩に乗るという事でSHOULDERS.mdとしてリストアップされているので目を通すと良い

主な特徴

  • URLルーティング
    • route, session, cookieなどはWebToolBoxであるgorriaを使用
  • HTML Template
    • plushというテンプレートを使用。html/templateを使用する事も可能
  • 便利なTOOLBOX(bufffaloコマンド)
  • Test Suite
  • ホットリロード
  • フロントエンドパイプライン(option)
    • webpackを使用
  • Models/ORM(option)
  • タスクランナー(option)

buffaloコマンド

プロジェクトの作成

$ buffalo new <name>

[開発時]Webアプリケーションの実行

開発時はホットリロードがサポートされている。buffalo devコマンドで.go.htmlを監視しファイル更新時にはホットリロードが実行される。リロードと表現しているが内部的にはgo buildwebpack watchを実行している。

$ buffalo dev

[本番環境]Webアプリケーションの実行

本番環境ではgoビルドしたバイナリを実行する

ユニットテスト

buffalo testコマンドを実行すると、./vendorディレクトリをスキップしてtestコードを実行できる。depやglideを使用してpackage管理している場合はvendorディレクトリのテストは自動でスキップされる。

$ buffalo test

依存しているパッケージ達

READMEではShoulders of Giants(巨人の肩に乗る)と表現され、車輪の再発明はせずにすでに存在するpackageに依存している。

所感

RevelのようなフルスタックなWebフレームワークと思ったが内容を追っているとpackageを上手に組み合わせてプロジェクトを構築しているという印象。始めから大量のpackageを内包せずにオプションサポートしてるのでフルスタックという感じでもない。

gopher的にはミニマムな構成でのpackage群で頑張るカルチャーがあると思うが、例えばこれからWeb開発にGoを導入してみたいが既にあるpackageを使い楽をしたいというチームにはbuffaloは良いかも知れない。

Rails, Laravelとか使用していた人がGoでWeb開発する時に、取っつきやすさがあると思うので候補とするのはありだと思う。

実際にサンプルWebアプリケーションを作った記事は別のポストに書く予定です。

gocuiのコンポーネントライブラリを作った話

Go2 Advent Calendar 2018 4日目の記事です。

こんにちわ

最近GoでCUI・CLIツールを作るのにハマっています。
CUIツールを作るときにい使用しているライブラリでgocuiというのを使っています。

今日はgocuiのコンポーネントライブラリっぽいやつを作ったので、その話をすこしします。
ソースはこちらになります。

本記事を読む前に、gocuiの知識はあったほうが良いので、
こちらの記事を軽く読んでおく事をめちゃくちゃオススメします。

どういうやつ?

ターミナル上でhtmlのformっぽい入力インターフェイスを簡単に作ることができます。
ボタンやチェックボックスなどを用意してあります。

demo.gif

作った背景

以前gocuiを使用してDockerのCUIクライアントツールdocuiを作りましたが、
コンテナ作成などで必要な情報を入力するインターフェイスを自前で用意する必要がありました。

それがかなりめんどくさかったのと、
他のCUIツールを作るときに使用したいかもしれないし、調べた限りgocuiのcomponentライブラリがない、
というのもあってコンポーネントとして切り出したほうが良さそうというのがきっかけでした。

ちなみに、docuiの移植前後はこんな感じです。
左が旧バージョン、右が適用後のバージョンになります。
今更ながら、旧バージョンのUIずれているし味気ないし酷いな…

image.png

使い方

_demosにあるselectのサンプルをもとに説明していきます。

select.gif

func main() {
    gui, err := gocui.NewGui(gocui.Output256)

    if err != nil {
        panic(err)
    }
    defer gui.Close()

    if err := gui.SetKeybinding("", gocui.KeyCtrlC, gocui.ModNone, quit); err != nil {
        panic(err)
    }

    component.NewSelect(gui, "Programming Language:", 0, 0, 21, 10).
        AddOptions("Go", "Java", "PHP", "Python", "Ruby", "C", "C++", "C#").
        Draw()

    if err := gui.MainLoop(); err != nil && err != gocui.ErrQuit {
        panic(err)
    }
}
  • gocui.NewGui(gocui.Output256)でgocuiのインスタンスを生成しておく。
  • component.NewSelectでselect componentのインスタンスを生成する。
  • AddOptionsでselect一覧で選択したいオプションを追加していく。
  • Drawでgocuiインスタンスに諸々の設定を追加していく。
  • gui.MainLoop()を呼び出して、gocuiインスタンスに追加された設定をもとに画面描写やキーバインド処理などを実行する。

基本の流れは上記の様に、gocuiインスタンスを作成して、
それをcomponentに渡した後に、methodで各設定をしていき、Drawで渡されたgocuiインスタンスに対して設定を追加していくという流れになっています。

gocui自体はgocuiインスタンスにviewの設定を追加した後で、MainLoopで一気に処理する動きになっていて、
今回作成したcomponentライブラリはviewの細かい設定をより簡単にできるようにラップしたものになります。

他にもサンプルコードを_demosにおいてあるので、使ってみたい方は覗いてみてください。

内部処理

簡単な使い方を説明したところで、
上記のSelectcomponentの内部でどんな処理をしているかを見ていきます。

Selectの構造体は以下の様になっています。

type Select struct {
    *InputField
    options      []string // 選択するオプション一覧を保持する
    currentOpt   int // 現在選択しているオプションの配列インデックス
    isExpanded   bool // オプション一覧が開いているかどうかの判定フラグ
    ctype        ComponentType // componentのタイプ Formでcomponentの判定に使用する
    listColor    *Attributes // オプション一覧の色の定義
    listHandlers Handlers // オプション一覧を開いたときの操作の定義
}

Select自体はInputFieldcomponentを埋め込んでいて、それを拡張したcomponentになります。

以下がselect componentをnewするときの処理になります。

// NewSelect new select
func NewSelect(gui *gocui.Gui, label string, x, y, labelWidth, fieldWidth int) *Select {

    s := &Select{
        InputField:   NewInputField(gui, label, x, y, labelWidth, fieldWidth), // InputFieldのインスタンスを生成
        listHandlers: make(Handlers),
        ctype:        TypeSelect,
    }

    // Enterでオプション一覧を開くように、InputFieldのAddHandlerを使用して定義
    s.AddHandler(gocui.KeyEnter, s.expandOpt) 

    // オプション一覧の色を定義
    s.AddAttribute(gocui.ColorBlack, gocui.ColorWhite, gocui.ColorBlack, gocui.ColorGreen).
        // AddListHandlerを利用してオプション一覧を開いたとき、jk↑↓で移動、Enterで選択できるように定義
        AddListHandler('j', s.nextOpt).
        AddListHandler('k', s.preOpt).
        AddListHandler(gocui.KeyArrowDown, s.nextOpt).
        AddListHandler(gocui.KeyArrowUp, s.preOpt).
        AddListHandler(gocui.KeyEnter, s.selectOpt).
        // `InputField`を埋め込んでいるので、入力できないようにする必要がある
        SetEditable(false)

    return s
}

Selectで苦労したのはオプション一覧をどのように表示・選択できるようにするかという点です。
gocuiの仕様上、viewを作成してviewの領域内で文字を描写する必要があります。
つまり、オプション一覧を表示する時はオプションの数だけviewを定義する必要があります。
また、どのオプションを選択しているかをわかるように、フォーカス処理や選択後の閉じる処理も必要になります。

オプション一覧を表示する処理はexpandOptで行っているので、その処理を見ていきます。

func (s *Select) expandOpt(g *gocui.Gui, vi *gocui.View) error {
    if s.hasOpts() {
        s.isExpanded = true
        g.Cursor = false

        x := s.field.X
        w := s.field.W

        y := s.field.Y
        h := y + 2

        for _, opt := range s.options {
            // オプション一覧は下方向に展開していくので、yとh座標をインクリメントする
            y++
            h++

            // オプションごとviewを定義していく
            if v, err := g.SetView(opt, x, y, w, h); err != nil {
                if err != gocui.ErrUnknownView {
                    panic(err)
                }

                v.Frame = false
                v.SelFgColor = s.listColor.textColor
                v.SelBgColor = s.listColor.textBgColor
                v.FgColor = s.listColor.hilightColor
                v.BgColor = s.listColor.hilightBgColor

                // 設定したキーバインドをオプションごとに追加する
                for key, handler := range s.listHandlers {
                    if err := g.SetKeybinding(v.Name(), key, gocui.ModNone, handler); err != nil {
                        panic(err)
                    }
                }

                fmt.Fprint(v, opt)
            }

        }

        // 一覧を開いたときに選択したのオプションにフォーカスを当てる
        v, _ := g.SetCurrentView(s.options[s.currentOpt])
        v.Highlight = true
    }

    return nil
}

func (s *Select) selectOpt(g *gocui.Gui, v *gocui.View) error {
    // オプション一覧が開いていれば閉じる、開いていなければ展開する
    if !s.isExpanded {
        s.expandOpt(g, v)
    } else {
        s.closeOpt(g, v)
    }

    return nil
}

func (s *Select) nextOpt(g *gocui.Gui, v *gocui.View) error {
    maxOpt := len(s.options)
    if maxOpt == 0 {
        return nil
    }

    // 前のオプションのフォーカスを外す
    v.Highlight = false

    next := s.currentOpt + 1
    if next >= maxOpt {
        next = s.currentOpt
    }

    // 次のオプションにフォーカスを当てる
    s.currentOpt = next
    v, _ = g.SetCurrentView(s.options[next])

    v.Highlight = true

    return nil
}

func (s *Select) closeOpt(g *gocui.Gui, v *gocui.View) error {
    s.isExpanded = false
    g.Cursor = true

    // オプションリストのviewとキーバインドを削除する
    for _, opt := range s.options {
        g.DeleteView(opt)
        g.DeleteKeybindings(opt)
    }

    v, _ = g.SetCurrentView(s.GetLabel())

    v.Clear()

    // 選択したオプションを反映する
    fmt.Fprint(v, s.GetSelected())

    return nil
}

SelectでEnterを押下すると上記の処理が走り、オプションごとのview座標をインクリメントしながら描写します。
やり方自体はシンプルですが、コード量と処理量が多いのが難点ですね。

ざっくりまとめると、

  • オプションリストをEnterで動的に生成するにはオプションごとviewを作成し、移動と選択のキーバインドを追加する処理が必要
  • 移動は前のviewのフォーカスを外し、次のviewにフォーカスを当てる処理が必要
  • オプション一覧でEnterを押下すると選択したオプションを反映して、一覧を閉じる処理が必要

こういった事を考え実装する必要があります。
けっこう大変です。
いっそ違うライブラリを使ったほうが楽ではないか?と思います。

作る上で苦労したこと

主に苦労したのは

  1. componentのインターフェイスをどうするか
  2. formに各component(ボタンなど)を組み込むときの共通化の部分をどうするか

の2つです。

1.はどういうメソッドがあれば良いのか、どこまで設定値を使用者側で設定できるようにするか悩みました。
サクッと使いたい人もいれば、細かく設定(色など)したいもいるだろうけど、
ひとまずここはできるようにしておこう、あとは需要に応じてissueやプルリクで対応していけばよいかなというところで線引しています。

ではどのように線引して行ったかというと、既存のライブラリを参考しただけです。
せっかく世の中に素晴らしいライブラリがあるのに、そのUIを参考にしないのはもったいないし、
今の自分の経験値からでは出てこないようなアイディアが詰まっていることもあります。

今回componentライブラリを作成するにあたって、参考にしたのはVueのUIライブラリElementUItviewです。
特にtviewは標準でformなどが使えるので、それを参考にgocui版を作ったようなもんです。

2.はformは各componentを内包していて、それらをDraw()でまとめて処理しています。
まとめて処理するにはGoのインターフェイスを使います。

    for _, cp := range f.components {
        p := cp.GetPosition()
        if p.W > f.W {
            f.W = p.W
        }
        if p.H > f.H {
            f.H = p.H
        }
        cp.AddHandlerOnly(gocui.KeyTab, f.NextItem)
        cp.AddHandlerOnly(gocui.KeyArrowDown, f.NextItem)
        cp.AddHandlerOnly(gocui.KeyArrowUp, f.PreItem)
        cp.Draw()
    }

ここで苦労したのは、インターフェイスに定義をどうするかということです。
どんなmethodがformで必要なのかをcomponentを作りながら定義していきました。

ここが一番難しかったです。
設計できるひと、尊敬です…

作って学んだこと

先日、初めて外国の方からプルリクをいただきました。
感動して涙で目の前が見えませんでした。

自分では大したことがないモノを作ったと思っても、
世界中で誰かが見てくれて使ってくれているかもしれないから、
今まで通り、恐れずガンガン公開していこうと改めて思いました。

それも含めて、作っていて学んだことは

  • 仕様で悩むときは既存のライブラリを参考にしたほうが良い、自分にはないアイディアがそこに詰まっているから。
  • 共通処理はinterfaceを定義して使うと良い、よりソースがスマートになるから。
  • 質を気にせずに作ったものをどんどん公開したほうが良い、モチベの維持と勉強になるから。

です。

余談(gocuiの今後とそのかわりになるもの)

gocuiの今後ですが、作者自身があまり活動していないようで、
プルリクがマージされる気配もなさそうなので、新機能が追加されることがあまり望めないかなと思っています。

gocuiの代わりになるものをいくつかピックアップした中で一番良さそうなのがtviewでした。
gocuiと比べてtviewはまだ生まれて1年くらいのようで、
開発がそれなりに活発でformやtableなどのcomponentは標準搭載してあるのでリッチなCUIライブラリです。

tviewはhtmlの思想をいくつか取り込んでいるんだなというのがすこし使ってみた感想です。
なので、htmlをある程度しっている方であればそれほど使い方で悩むことはないんじゃないかなと思います。

興味ある方はtviewを覗いてみてください。
demosでサンプルを見れるので学習にも役立つと思います。

最後に

「作ったものは質を気にせずにどんどん公開していこう、不幸になる人なんていないから」
というのが実はこの記事で一番言いたいことだったりします。

大したやつじゃないと自分が思っても、どこかで誰かの役に立ったりするのが個人的にすごく嬉しいです。
この記事を読んで、自分も公開してみようかなって方いましたらぜひ公開していきましょ。

ちょっと使いやすくしたcutコマンド作成中

External article

AWS Lambda + Amazon SES + aws-sdk-go で添付ファイル付きメール送信

External article

fmt.Printfなんかこわくない

はじめに

Goのfmtパッケージのprintf系の関数

  • Fprintf
  • Printf
  • Sprintf

のフォーマットの指定方法についてまとめました。

Goでは書式指定子 %... のことを verb と表記しています。

すべての型に使えるverb

%v

値のデフォルトのフォーマットでの表現を出力する。

基本型の場合

verb
論理値(bool) %t
符号付き整数(int, int8など) %d
符号なし整数(uint, uint8など) %d
浮動小数点数(float64など) %g
複素数(complex128など) %g
文字列(string) %s
チャネル(chan) %p
ポインタ(pointer) %p
package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%v\n", true)
    fmt.Printf("%v\n", 42)
    fmt.Printf("%v\n", uint(42))
    fmt.Printf("%v\n", 12.345)
    fmt.Printf("%v\n", 1-2i)
    fmt.Printf("%v\n", "寿司🍣Beer🍺")
    fmt.Printf("%v\n", make(chan bool))
    fmt.Printf("%v\n", new(int))
}
true
42
42
12.345
(1-2i)
寿司🍣Beer🍺
0x434080
0x416028

https://goplay.space/#z9gPcDYzgkV

コンポジット型の場合

以下のようになり、さらに各要素に対して再帰的に %v でのフォーマットをしたものが結果として出力される。

フォーマット
構造体(struct) {フィールド1 フィールド2 ...}
構造体のポインタ &{フィールド1 フィールド2 ...}
配列・スライス(array, slice) [要素1 要素2 ...]
配列・スライスのポインタ &[要素1 要素2 ...]
マップ(map) map[キー1:値1 キー2:値2 ...]
マップのポインタ &map[キー1:値1 キー2:値2 ...]
package main

import (
    "fmt"
    "net/http"
)

func main() {
    fmt.Printf("%v\n", http.Client{})
    fmt.Printf("%v\n", &http.Client{})
    fmt.Printf("%v\n", [...]int{1, 2, 3})
    fmt.Printf("%v\n", &[...]int{1, 2, 3})
    fmt.Printf("%v\n", []int{1, 2, 3})
    fmt.Printf("%v\n", &[]int{1, 2, 3})
    fmt.Printf("%v\n", map[string]int{"寿司": 1000, "ビール": 500})
    fmt.Printf("%v\n", &map[string]int{"寿司": 1000, "ビール": 500})
}
{<nil> <nil> <nil> 0s}
&{<nil> <nil> <nil> 0s}
[1 2 3]
&[1 2 3]
[1 2 3]
&[1 2 3]
map[寿司:1000 ビール:500]
&map[寿司:1000 ビール:500]

https://goplay.space/#aoO0hoy_p6a

%+v

%vと同じだが、構造体の場合にフィールド名を出力する。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    fmt.Printf("%v\n", http.Client{})
    fmt.Printf("%+v\n", http.Client{})
}
{<nil> <nil> <nil> 0s}
{Transport:<nil> CheckRedirect:<nil> Jar:<nil> Timeout:0s}

https://goplay.space/#lWvspeqs7ua

%#v

値のGoの文法での表現を出力する。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    fmt.Printf("%#v\n", true)
    fmt.Printf("%#v\n", 42)
    fmt.Printf("%#v\n", uint(42))
    fmt.Printf("%#v\n", 12.345)
    fmt.Printf("%#v\n", 1-2i)
    fmt.Printf("%#v\n", "寿司🍣Beer🍺")
    fmt.Printf("%#v\n", make(chan bool))
    fmt.Printf("%#v\n", new(int))
    fmt.Printf("\n")
    fmt.Printf("%#v\n", http.Client{})
    fmt.Printf("%#v\n", &http.Client{})
    fmt.Printf("%#v\n", [...]int{1, 2, 3})
    fmt.Printf("%#v\n", &[...]int{1, 2, 3})
    fmt.Printf("%#v\n", []int{1, 2, 3})
    fmt.Printf("%#v\n", &[]int{1, 2, 3})
    fmt.Printf("%#v\n", map[string]int{"寿司": 1000, "ビール": 500})
    fmt.Printf("%#v\n", &map[string]int{"寿司": 1000, "ビール": 500})
}
true
42
0x2a
12.345
(1-2i)
"寿司🍣Beer🍺"
(chan bool)(0x834100)
(*int)(0x816260)

http.Client{Transport:http.RoundTripper(nil), CheckRedirect:(func(*http.Request, []*http.Request) error)(nil), Jar:http.CookieJar(nil), Timeout:0}
&http.Client{Transport:http.RoundTripper(nil), CheckRedirect:(func(*http.Request, []*http.Request) error)(nil), Jar:http.CookieJar(nil), Timeout:0}
[3]int{1, 2, 3}
&[3]int{1, 2, 3}
[]int{1, 2, 3}
&[]int{1, 2, 3}
map[string]int{"寿司":1000, "ビール":500}
&map[string]int{"寿司":1000, "ビール":500}

https://goplay.space/#fxNuoQ2vBUj

%T

値の型のGoの文法での表現を出力する。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    fmt.Printf("%T\n", true)
    fmt.Printf("%T\n", 42)
    fmt.Printf("%T\n", uint(42))
    fmt.Printf("%T\n", 12.345)
    fmt.Printf("%T\n", 1-2i)
    fmt.Printf("%T\n", "寿司🍣Beer🍺")
    fmt.Printf("%T\n", make(chan bool))
    fmt.Printf("%T\n", new(int))
    fmt.Printf("\n")
    fmt.Printf("%T\n", http.Client{})
    fmt.Printf("%T\n", &http.Client{})
    fmt.Printf("%T\n", [...]int{1, 2, 3})
    fmt.Printf("%T\n", &[...]int{1, 2, 3})
    fmt.Printf("%T\n", []int{1, 2, 3})
    fmt.Printf("%T\n", &[]int{1, 2, 3})
    fmt.Printf("%T\n", map[string]int{"寿司": 1000, "ビール": 500})
    fmt.Printf("%T\n", &map[string]int{"寿司": 1000, "ビール": 500})
}
bool
int
uint
float64
complex128
string
chan bool
*int

http.Client
*http.Client
[3]int
*[3]int
[]int
*[]int
map[string]int
*map[string]int

https://goplay.space/#mpPV8cOoc0n

%%

%そのものを出力したい場合に使う。

論理値に使えるverb

%t

truefalse

package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%t\n", true)
    fmt.Printf("%t\n", false)
}
true
false

https://goplay.space/#dPg7ImEakE3

整数に使えるverb

%d

10進数での表現

%b

2進数での表現

%o

8進数での表現

%x

16進数での表現(a-fは小文字)

%X

16進数での表現(A-Fは大文字)

%c

Unicodeコードポイントに対応する文字

%q

対応する文字をシングルクォート'で囲んだ文字列

package main

import (
    "fmt"
)

func main() {
    answer := 42
    fmt.Printf("%b\n", answer)
    fmt.Printf("%c\n", answer)
    fmt.Printf("%d\n", answer)
    fmt.Printf("%o\n", answer)
    fmt.Printf("%q\n", answer)
    fmt.Printf("%x\n", answer)
    fmt.Printf("%X\n", answer)
    fmt.Printf("%U\n", answer)
}
101010
*
42
52
'*'
2a
2A
U+002A

https://goplay.space/#uOjc6CIP2Cc

浮動小数点数・複素数に使えるverb

%b

小数点なしの指数表記 指数は2の累乗

%e

指数表記

%E

%eeEで表記される

%f, %F

指数表記なし

%g

指数が大きい場合は%eそうでなければ%f

%G

指数が大きい場合は%Eそうでなければ%F

package main

import (
    "fmt"
)

func main() {
    f := 12.345
    fmt.Printf("%b\n", f)
    fmt.Printf("%e\n", f)
    fmt.Printf("%E\n", f)
    fmt.Printf("%f\n", f)
    fmt.Printf("%F\n", f)
    fmt.Printf("%g\n", f)
    fmt.Printf("%G\n", f)
    fmt.Printf("%g\n", 12345678.9)
    fmt.Printf("%G\n", 12345678.9)
}
6949617174986097p-49
1.234500e+01
1.234500E+01
12.345000
12.345000
12.345
12.345
1.23456789e+07
1.23456789E+07

https://goplay.space/#x97S-f6fLcn

文字列([]byteも同じ)に使えるverb

%s

そのままの出力

%q

Goの文法上のエスケープをした文字列

%x

1バイトにつき2文字の16進数での表現(a-fは小文字)

%X

%xと同じだが、A-Fが大文字

package main

import (
    "fmt"
)

func main() {
    s := "寿司🍣Beer🍺"
    fmt.Printf("%s\n", s)
    fmt.Printf("%q\n", s)
    fmt.Printf("%x\n", s)
    fmt.Printf("%X\n", s)
}
寿司🍣Beer🍺
"寿司🍣Beer🍺"
e5afbfe58fb8f09f8da342656572f09f8dba
E5AFBFE58FB8F09F8DA342656572F09F8DBA

https://goplay.space/#4z7ki7m7BjD

width, precision

verbの直前に整数でwidthを指定することができる。
widthはrune単位で数えられる出力する文字列の長さ。
指定しない場合、値を表現するのに必要な長さになる。

precisionはwidthの直後に.を付け、その後に整数で指定できる。
.がない場合、デフォルトのprecisionになる。
.があるが数値の指定がない場合、precisionは0になる。

width, precisionには整数の代わりに*を指定することもでき、その場合次の引数の値を指定したことになる。
その場合、その引数の値は整数である必要がある。

  • 文字列([]byteでも同じ)の場合: precisionはフォーマット対象にする文字列の長さの上限。文字列が長すぎる場合は途中で切られる。長さはrune単位だが、%x%Xでフォーマットされる場合はバイト単位。
  • 浮動小数点数の場合:

    • width: 文字列で表現される際の最小の文字数
    • precision:
      • %e, %f, 小数点以下の桁数
      • %g, %G有効桁数の最大値
      • デフォルト:
        • %e, %f, %#g: 6
        • %g: 数値を表すのに必要な桁数
  • 複素数の場合: widthとprecisionの値は実数部と虚数部にそれぞれ適用され、結果は()で囲われる。

package main

import (
    "fmt"
)

func main() {
    f := 12.345
    fmt.Printf("%f\n", f)
    fmt.Printf("%12f\n", f)
    fmt.Printf("%12.2f\n", f)
    fmt.Printf("%.2f\n", f)
    fmt.Printf("%12.f\n", f)
    fmt.Printf("%e\n", f)
    fmt.Printf("%#g\n", f)
    fmt.Printf("%g\n", f)

    fmt.Printf("%f", 1-2i)
}
12.345000
   12.345000
       12.35
12.35
          12
1.234500e+01
12.3450
12.345
(1.000000-2.000000i)

https://goplay.space/#qbrm8OIcZ0V

flag

width, precisionの他にもverbの直前に置くことでフォーマットを変えられるflagがあります。

+

  • 数値の場合: 正でも符号(+)を出力する
  • %qの場合: ASCII文字だけで出力する
package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%d\n", 42)
    fmt.Printf("%+d\n", 42)
    fmt.Printf("%q\n", 945)
    fmt.Printf("%+q\n", 945)
    fmt.Printf("%q\n", "寿司🍣Beer🍺")
    fmt.Printf("%+q\n", "寿司🍣Beer🍺")
}
42
+42
'α'
'\u03b1'
"寿司🍣Beer🍺"
"\u5bff\u53f8\U0001f363Beer\U0001f37a"

https://goplay.space/#Mqe5nGa0Af_E

-

左詰めにする

package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%5d\n", 42)
    fmt.Printf("%-5d\n", 42)
    fmt.Printf("%10s\n", "寿司🍣Beer🍺")
    fmt.Printf("%-10s\n", "寿司🍣Beer🍺")
}
   42
42   
  寿司🍣Beer🍺
寿司🍣Beer🍺

https://goplay.space/#0eC0HaeMk-B

#

デフォルトとは異なるフォーマットにする

  • 8進数の場合(%#o): 先頭に0を付ける
  • 16進数の場合(%#x): 先頭に0xを付ける
  • 16進数(大文字)の場合(%#X): 先頭に0Xを付ける
  • ポインタの場合(%#p): 先頭の0xを付けない
  • %qの場合: strconv.CanBackquoteがtrueを返すならraw文字列を出力する
  • %e, %E, %f, %F, %g, %Gの場合: 必ず小数点を付ける
  • %g, %Gの場合: 末尾の0を省略しない
  • %Uの場合: U+0078 'x'の形式で出力する
package main

import (
    "fmt"
)

func main() {
    answer := 42
    fmt.Printf("%o\n", answer)
    fmt.Printf("%#o\n", answer)
    fmt.Printf("%x\n", answer)
    fmt.Printf("%#x\n", answer)
    fmt.Printf("%X\n", answer)
    fmt.Printf("%#X\n", answer)
    fmt.Printf("%p\n", &answer)
    fmt.Printf("%#p\n", &answer)
    fmt.Printf("%q\n", "go")
    fmt.Printf("%#q\n", "go")
    fmt.Printf("%q\n", "`go`")
    fmt.Printf("%#q\n", "`go`")
    fmt.Printf("%.f\n", 12.345)
    fmt.Printf("%#.f\n", 12.345)
    fmt.Printf("%g\n", 12.345)
    fmt.Printf("%#g\n", 12.345)
    fmt.Printf("%U\n", answer)
    fmt.Printf("%#U\n", answer)
}
52
052
2a
0x2a
2A
0X2A
0x416020
416020
"go"
`go`
"`go`"
"`go`"
12
12.
12.345
12.3450
U+002A
U+002A '*'

https://goplay.space/#2YTrvJY308B

(スペース)

  • 数値の場合: 符号のためのスペースを空ける
  • 文字列をバイト単位で表現する場合: それぞれのバイトの間にスペースを空ける
package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%d\n", 42)
    fmt.Printf("% d\n", 42)
    fmt.Printf("%x\n", "寿司🍣Beer🍺")
    fmt.Printf("% x\n", "寿司🍣Beer🍺")
    fmt.Printf("%X\n", "寿司🍣Beer🍺")
    fmt.Printf("% X\n", "寿司🍣Beer🍺")
}
42
 42
e5afbfe58fb8f09f8da342656572f09f8dba
e5 af bf e5 8f b8 f0 9f 8d a3 42 65 65 72 f0 9f 8d ba
E5AFBFE58FB8F09F8DA342656572F09F8DBA
E5 AF BF E5 8F B8 F0 9F 8D A3 42 65 65 72 F0 9F 8D BA

https://goplay.space/#_KM5ZDuGfM2

0

スペースではなく、0で埋める

数値の場合: 0埋めは符号のあと

package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%10s\n", "寿司🍣Beer🍺")
    fmt.Printf("%010s\n", "寿司🍣Beer🍺")
    fmt.Printf("%10.3f\n", -12.345)
    fmt.Printf("%010.3f\n", -12.345)
}
  寿司🍣Beer🍺
00寿司🍣Beer🍺
   -12.345
-00012.345

https://goplay.space/#tGs_5FUYnVy

[n]引数のインデックス指定

  • verbの直前に[n]の表記でインデックスを指定することで、フォーマットする引数を指定できる
  • *に対しても使える
  • [n]の指定をした以降はn+1番目, n+2番目, ... となる
package main

import (
    "fmt"
)

func main() {
    fmt.Printf("%[2]s %[1]s\n", "寿司🍣", "Beer🍺")
    fmt.Printf("%[3]*.[2]*[1]f\n", 12.345, 2, 8)
    fmt.Printf("%*.*f\n", 8, 2, 12.345)
    fmt.Printf("%s %s %[1]q %q", "寿司🍣", "Beer🍺")
}
Beer🍺 寿司🍣
   12.35
   12.35
寿司🍣 Beer🍺 "寿司🍣" "Beer🍺"

https://goplay.space/#dztaNduIqv0

参考

fmt - The Go Programming Language

Go で Enum を定義するときのちょっとした気遣い

@mosaxiv さんの代打で、Go で Enum を定義するときに気になっていたこととその解決策についてお話します。

Go にはデフォルトで Enum を定義する仕組みがないため、一般的に const 宣言内で iota を使って次のように定義することが多いと思います。

type Type int

const (
    a Type = iota
    b
    c
)

しかし、Go には Zero-Value といって変数を初期化する際に明示的に値を代入しない場合、デフォルトで割り当てられる値が決まっています。例えば int 型の変数の場合「0」です。

すると iota は「0」から始まる連続する整数を生成する識別子であるため、上で宣言した a がデフォルト値でもない限り、変数を初期化する際に明示的に値を指定していないにも関わらずデフォルト値でもない a で勝手に初期化されてしまっている状態が発生します。

文章では少し伝わりにくいと思うのでコードで示すと

type S struct {
    T Type
}

func main() {
    s := S{}
    switch s.T {
    case a:
        fmt.Println("a") // <- 実際にはここを通る
    case b:
        fmt.Println("b")
    case c:
        fmt.Println("b")
    default:
        fmt.Println("default") // <- s.T に何も設定していなかったらここを通ってほしい
    }
}

という具合です。

この問題は iota を「1」から始めることで解決できます。このちょっとしたおせっかい気遣いによって次のように意図した処理が行われます。

const (
    a Type = iota + 1 // <- 1から始める
    b
    c
)

func main() {
    s := S{}
    switch s.T {
    case a:
        fmt.Println("a")
    case b:
        fmt.Println("b")
    case c:
        fmt.Println("b")
    default:
        fmt.Println("default") // <- s.T に何も設定していないのでここを通る!
    }
}

ただこの解決策がベストかどうか分からないので、もう少しいい案があるよ!という方はコメント欄までお願いします:bow:

ssssをgolangに移植してみた

External article

GoのWebアプリケーションのSQLロガーとパフォーマンス分析ツールを作ってみた

External article

165行で実装するProtocol Buffersデコーダ(ミニマム版)

External article

golang で 2 Way SQL

空いていたので穴埋め。

はじめに

データベースを扱うプロジェクトでは、オンコードで SQL を書く事が割と多いのですが、そういったソースコードに埋め込まれた SQL はプレースホルダを使って値を取るので直接実行する事は出来ません。

select * from foo where id = :id and bar = :bar

RDBMS によっては :変数名 ではなく ?$1 といった表記をする物もあります。こういった SQL は直接実行できない為、どうしても結果がイメージし辛くなるのですが、2 Way SQL という方法を使う事で解決できる事があります。

2 Way SQL とは

2 Way SQL は SQL のコメントに IF や ELSE、END といった制御構文を埋め込む事で、直接実行する事も出来るし、プレースホルダを使ったオンコード用の SQL としても使う事ができるといった物です。筆者が知る限り明確な仕様は見当たらないのですが、昔は Doma/S2Dao といったデータベースの抽象化レイヤと合わせて利用されて来ました。

select * from foo where id = /*id*/5 /* IF enabled */and bar = /*bar*/'foo' /*END*/

この SQL を実行するとコメントは無視される為、実際は以下が実行されます。

select * from foo where id = 5 and bar = 'foo'

このコメントに記述された IF/ELSE/END と、値の直前に書かれたコメントが 2 Way SQL です。

2 Way SQL のルール

select * from foo where id = /*id*/5 /* IF enabled */and bar = /*bar*/'foo' /*END*/

この SQL に制御パラメータ enabled を true 渡すと IF 文が有効になるため、id と bar による抽出が有効になります。また false で渡すと IF 文が無効となるため id のみの抽出となります。

select * from foo where id = :id

この様に /*bar*/'foo' はプレースホルダ bar に置き換えられます。

2 Way SQL の実装

随分前ですが golang で実装しました。

https://github.com/mattn/ways2go

以下の様に使います。

env := make(map[string]interface{})
env["enabled"] = true

sql2way := `
select
  *
from
  foo
where
  id = /*id*/5
/* IF enabled */
  and bar = /*bar*/3
/*END*/
`

sql, err := Eval(sql2way, env, ways2go.Question)

正常に評価されると以下の SQL を得る事が出来ます。

select
  *
from
  foo
where
  id = ?
  and bar = ?

データベースによってはプレースホルダの形式が異なります。ways2go では以下の形式をサポートしています。

識別 プレースホルダ
Question ?
Dollar $1
Colon :foo

これを使えばサンプルとして実行する事もでき、プログラムから実行する事もできる SQL を同じファイルで管理する事が出来ます。

まとめ

大規模なプロジェクトで SQL が散乱する様なコードベースでは SQL を外部ファイルに切り出すことでソースコードがスッキリするかもしれません。ただし実は筆者も昔に作ったまま時間が経っており、テストは書いてあるのですが実務で使った事がありません。皆さんからの PR や機能追加をお待ちしております。

Goのnet/httpとSlackのEventAPIでHTTPベースの本棚管理Botを作ってみた

Goのnet/httpとSlackのEventAPIで本棚管理Botを作ってみた

POSTが遅くなってしまい申し訳ありません。Go2アドベントカレンダー13日目の記事です。
今回はアドベントカレンダーに向けて、Goのnet/httpパッケージとSlackのEventAPIを使って、書籍の検索を行ってくれる本棚管理ボットを作ってみました。今回はHTTPサーバーベースのBotです。
今回の記事ではその概要と実装について説明していきたいと思います。
実際のコードはGitHubにあります。

本棚管理Botとは

普段生活していて、いろんな場面で本を購入することは多いと思います。
しかし、本を買ってみたものの、家に帰って本棚を確認してみたら、両親や兄弟が同じ本を買っていて、同じ本が2冊になってしまったという経験はないでしょうか。家だけではなく、会社や大学の研究室など様々な場所で似たようなことが起きるのではないでしょうか。
同じ本を2冊買うのを防ぐために、購入前にSlackで今本棚にある本の検索ができたら便利だと思い、今回本棚管理Bot(bookshlfという名のBot)を作りました。
できたものは以下です。
スクリーンショット 2018-12-13 22.01.34.png
写真のようにBotにメンションして、search:<文字列>という形式で検索ワードを送ると
スクリーンショット 2018-12-13 22.01.48.png

このように検索結果を返してくれるというものです。後述しますが、この検索結果は本の一覧が書かれたCSVファイルを読み込み、参照した結果を返しています。
今回このBotをGoのnet/httpパッケージとSlackのEventAPIを使ってHTTPサーバーベースのBotを作成しました。

Slack,EventAPI

まずEventAPIはメンションなどの特定のイベントが発生すると自分が指定したURLにリクエストを投げてくれます。
今回はBotユーザーへのメンションが発生した時に自分のHTTPサーバーのURLにリクエストを送信するように設定します。(設定方法は他の記事参照)

GoのHTTPサーバー

main.goは以下のようになっています。

main.go
func main() {
    http.HandleFunc("/", handler.Handle)
    http.ListenAndServe(":8080", nil)
}

/にリクエストがきたらhandler.Handle関数が呼ばれるようにしています。

handler.Handle関数

関数はの概要は以下です。長くてすいません。

handler/handler.go
func Handle(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    byteBody, err := ioutil.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    var jsonMap map[string]interface{}
    if err := json.Unmarshal(byteBody, &jsonMap); err != nil {
        fmt.Println(err)
        w.WriteHeader(http.StatusBadRequest)
        return
    }
    fmt.Println(jsonMap)

    token := jsonMap["token"].(string)
    if token != os.Getenv("SLACK_TOKEN") {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    eventType := jsonMap["type"].(string)
    switch eventType {
    case "url_verification":
        challenge := jsonMap["challenge"].(string)
        w.WriteHeader(200)
        w.Write([]byte(challenge))
        return
    case "event_callback":
        event := jsonMap["event"].(map[string]interface{})
        eventTypeString := event["type"].(string)
        if eventTypeString == "app_mention" {
            eventText := event["text"].(string)
            stringReader := strings.NewReader(eventText)
            scanner := bufio.NewScanner(stringReader)
            scanner.Scan()
            scanner.Scan()
            text := scanner.Text()
            splitSlice := strings.Split(text, ":")
            if len(splitSlice) < 1 {
                w.WriteHeader(http.StatusBadRequest)
                return
            }
            switch splitSlice[0] {
            case "search":
                w.WriteHeader(200)
                if err != nil {
                    fmt.Fprint(os.Stderr, err)
                    return
                }
                service, err := service.NewService()
                if err != nil {
                    fmt.Fprint(os.Stderr, err)
                    return
                }
                channelName := event["channel"].(string)
                go service.SendAnswer(splitSlice[1], channelName)
                return
            }
        }
    default:
        w.WriteHeader(http.StatusBadRequest)
        return
    }
}

jsonのリクエストを受け取り、それをjson.Unmatshal関数を使ってmap[string]interface{}型にしてそこから型アサーションを使って、type.event.type.textを取り出します。これがユーザーから送られてきたメッセージになります。メッセージは

@bookshlf
saerch:HTTP

という風になるのでscanner.Scan()を2回使って、2行目のsaerch:HTTPを取り出します。その後strings.Split(text, ":")を使って:で区切りスライスに入れます。今回でいうとスライスの1番目の要素がHTTPという検索ワードになります。
この検索ワードとchannel名をgoroutineに渡して、goroutine内でCSVを使った検索を行い、Slackのchat.postMessageのAPIにPOSTリクエストを送信しています。なぜ、1回のリクエストでメッセージを返さなかったかというと、EventAPIのページに以下のことが書いてあったからです。

Respond to events with a HTTP 200 OK as soon as you can.
Avoid actually processing and reacting to events within
the same process. Implement a queue to handle inbound events after they are received.

これをみて真っ先にgoroutineだと思いつき、goroutineを使ってみました。
(goroutineの使い方はあっているかはわからない)

goroutineの中身

goroutineとして呼び出されているservice.SendAnswer関数の中身はどうなっているかというと検索ワードに応じて、CSV内の検索を行い、検索結果を文字列にしてchat.postMessageのAPIにPOSTリクエストを送信しています。service.SendAnswer関数自体の実装はあとで提示します。

CSV内文字列の検索

以下はCSVの検索の関数です。

finder/finder.go
type Finder interface {
    Find(searchWord string) ([]domain.Book, error)
    Close()
}

type CSV struct {
    reader io.ReadCloser
}

func (c *CSV) Find(searchWord string) ([]domain.Book, error) {
    bookSlice := make([]domain.Book, 0, 10)
    csvReader := csv.NewReader(c.reader)
    record, err := csvReader.ReadAll()
    if err != nil {
        fmt.Println(err)
        return nil, err
    }
    for _, row := range record {
        if strings.Contains(row[2], searchWord) && row[2] != "" && searchWord != "" {
            newBook := domain.Book{ISBN: row[0], Title: row[2], Author: row[3], Publisher: row[4]}
            bookSlice = append(bookSlice, newBook)
        }
    }
    return bookSlice, nil
}

//Close関数は省略

CSVという構造体にreaderというio.ReadCloserを持たせていますが、これはos.File型のcsvファイルが入ります。ちなみにCSV構造体はFinderインターフェースを実装しています。csv.NewReader(c.reader)関数とcsvReader.ReadAll()関数でcsvの読み取りを行なっています。
CSVファイルのフォーマットは

csv
ISBN,found,title,author,publisher,volume,series,cover

とい風になっており、今回はtitle(row[2])が検索ワードを含んでいたら、BooK構造体にセットして、スライスに入れていきます。検索ワードを含んでいるかはstrings.Contains(row[2], searchWord)で確認しています。

Book構造体

Book構造体は以下のように定義しました。

type Book struct {
    ISBN      string
    Title     string
    Author    string
    Publisher string
}

今回はCSVのISBN,title,author,publisherの項目だけを用います。
CSV検索関数ではBook構造体のスライスをservice.SendAnswer関数に返します。

service.SendAnswer関数

service.SendAnswer関数は以下のようになっています。
finder.FinderはCSV構造体が実装している、インターフェースです。実態はCSV構造体が入ります。

service/book_service.go
type BookService struct {
    finder finder.Finder
}

func (b *BookService) SendAnswer(query string, channelName string) {
    bookSlice, err := b.finder.Find(query)
    if err != nil {
        fmt.Println(err)
    }

    var sendMessage strings.Builder
    length := strconv.Itoa(len(bookSlice))
    fmt.Println(length)
    if len(bookSlice) > 0 {
        sendMessage.WriteString("本あったよ:sunglasses:\n")
        sendMessage.WriteString("検索結果/" + length + "件\n")
    } else {
        sendMessage.WriteString("残念だ...\n")
    }

    for _, book := range bookSlice {
        sendMessage.WriteString("```")
        sendMessage.WriteString(book.ToString())
        sendMessage.WriteString("```")
        sendMessage.WriteString("\n")
    }
    message.SendMessage(channelName, sendMessage.String())
    b.finder.Close()
}

b.finder.Find(query)の部分がCSVの検索の関数です。
帰ってきた、Book構造体のスライスをもとにユーザーに送信するメッセージを組み立てています。メッセージの組み立てではstrings.Builderを使っています。
book.ToString()関数はBook構造体の情報を読みやすい形に整形した文字列を返す関数です。
メッセージの組み立てが終わったら、message.SendMessage(channelName, sendMessage.String())関数を呼び出します。この関数でchat.postMessageのAPIにPOSTリクエストを送信しています。

message.SendMessage関数

message/send_message.go
func SendMessage(channelName string, message string) {
    values := url.Values{}
    token := os.Getenv("SLACK_OAUTH_TOKEN")
    values.Add("token", token)
    values.Add("channel", channelName)
    values.Add("text", message)
    values.Add("mrkdwn", "true")
    resp, err := http.PostForm("https://slack.com/api/chat.postMessage", values)
    if err != nil {
        fmt.Println(err)
    }
    defer resp.Body.Close()

    byteString, _ := ioutil.ReadAll(resp.Body)
    fmt.Println(string(byteString))
}

今回はhttp.PostFormでPOSTリクエストを送信しています。chat.PostMessageのAPIはcontent typesapplication/x-www-form-urlencoded形式をサポートしています。application/json形式もサポートしていますが、Bodyの組み立てが簡単そうなapplication/x-www-form-urlencodedを使いました。
url.Values{}を使ってリクエストのBodyを組み立てていきます。
メッセージを送るためのtokenと送信するchannelとメッセージであるtextとメッセージのマークダウン形式を認めるmrkdwnオプションを追加します。
その後http.PostForm("https://slack.com/api/chat.postMessage", values)でメッセージが送信されます。

まとめ

今回はGoのnet/httpとSlackのEventAPIを使って、HTTPベースのBotを作成しました。HTTPベースでBotが作れるのはだいぶ大きかったです。皆さんも是非HTTPベースでお好みのBotを作成してみてください。
なお、ここが間違っている、このアーキテクチャはよくないなどのご意見ありましたら、ぜひ、ご指摘よろしくお願いいたします。
スクリーンショット 2018-12-13 23.18.38.png

Goのカスタムエラーとその自動生成について

External article

ボイラプレート編 - #golang で CLI 作るときにいつもつかうやつ

技術選択編 が軽バズりして嬉しかったので続編.

TL;DR

  • 便利ライブラリ & CLI つくったよ
  • 開発用ツールの依存は gex で管理してるよ

logging

zap

Blazing fast, structured, leveled logging のとおり,はやくて構造化データを吐けてログレベルも設定できるロガー.これは知ってる人も多いハズ.

自分が使うときはデバッグフラグを定義しておき,cobra.OnInitialize で logger を初期化して global logger にセットしている.

cobra.OnInitialize(func() {
    zap.ReplaceGlobals(verboseLogger)
})

ReplaceGlobals でセットした Logger は zap.L() で取り出せるので,あとはコード中の任意の場所で zap.L().Error("failed to open file", zap.Error(err), zap.String("path", path)) とかできる.

ちなみに,ReplaceGlobals を呼ばないと Nop logger が利用されるので,テストとかに影響を及ぼすことはない.便利.

verbose logger / debug logger

自分はだいたい --verbose--debug の2種類のフラグを用意しておいて,それによって logger を使い分けている.

-v もしくは --verboseINFO level までのログを出す Logger を使う.これは一般ユーザでも使う想定で,みやすいログ形式でそこそこの情報を出すようにしている.

cfg := zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
cfg.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
    enc.AppendString(t.Local().Format("2006-01-02 15:04:05 MST"))
}

一方で --debug は主に自分が使うものなので,すべてのログ(DEBUG level)を出力している.ProductionConfig を利用すると JSON でログが出るようになるので,それをおもむろに jq に食わせてデバッグしたりする.

cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
cfg.DisableStacktrace = true

ery では各モジュールのインスタンスに named logger をもたせておいて,それを呼ぶようにしている.これでより jq しやすくなる.

// https://github.com/srvc/ery/blob/v0.0.1/pkg/app/proxy/manager.go#L20-L25
return &serverManager{
    mappingRepo:     mappingRepo,
    factory:         factory,
    cancellerByPort: new(sync.Map),
    log:             zap.L().Named("proxy"),
}

DEBUG level のログは「ユーザは普段見ない」「JSON で出てくるので多少量が多くても目的のログを検索しやすい」ので,開発中の print debug 代わりに使って消さずに残しておくくらいでいいのかなと考えている.

フラグハンドリング

いつも↓みたいな感じのヘルパを定義して,フラグを定義し,アプリケーション起動タイミングで logger も初期化している.

func AddLoggingFlags(cmd *cobra.Command) {
    var (
        debugEnabled, verboseEnabled bool
    )

    cmd.PersistentFlags().BoolVar(
        &debugEnabled,
        "debug",
        false,
        "Debug level output",
    )
    cmd.PersistentFlags().BoolVarP(
        &verboseEnabled,
        "verbose",
        "v",
        false,
        "Verbose level output",
    )

    cobra.OnInitialize(func() {
        switch {
        case debugEnabled:
            enableDebugLogger()
        case verboseEnabled:
            enableVerboseLogger()
        }
    })
}

ref: pkg/cli/logging.go at master · izumin5210/clig

stdio

たとえば kubectl の実装を読むと,かなり最初の方でコンストラクタから標準入出力を注入している.

// NewDefaultKubectlCommand creates the `kubectl` command with default arguments
func NewDefaultKubectlCommand() *cobra.Command {
  return NewDefaultKubectlCommandWithArgs(&defaultPluginHandler{}, os.Args, os.Stdin, os.Stdout, os.Stderr)
}

// NewDefaultKubectlCommandWithArgs creates the `kubectl` command with arguments
func NewDefaultKubectlCommandWithArgs(pluginHandler PluginHandler, args []string, in io.Reader, out, errout io.Writer) *cobra.Command {
  cmd := NewKubectlCommand(in, out, errout)

pkg/kubectl/cmd/cmd.go#L295-L303 at v1.13.1 · kubernetes/kubernetes

これをやっておくだけで,テスト時は os.Std* の代わりに new(bytes.Buffer) でモックできるようになる.

自分も CLI 実装時はこのやり方を踏襲していたが,最近はもう一段ラッパーを噛ませている.

type IO interface {
    In() io.Reader
    Out() io.Writer
    Err() io.Writer
}

io.Writerio.Reader だとあまりに広すぎるので,もうちょっと意味をもたせる形で interface で包んでいる.この副作用として,ちゃんと型が付くので wire などの型をみるタイプの DI ツールが使いやすくなる.

また,デフォルト値を返す Stdio() という関数を用意しておいて,main() からはそれを利用するようにしている.@mattn さんに grapi の Windows 対応してもらったときの経験を生かして,mattn/go-colorable を入れている.

func Stdio() IO {
    io := &IOContainer{
        InR:  os.Stdin,
        OutW: os.Stdout,
        ErrW: os.Stderr,
    }
    if runtime.GOOS == "windows" {
        io.OutW = colorable.NewColorableStdout()
        io.ErrW = colorable.NewColorableStderr()
    }
    return io
}

ref: pkg/cli/io.go at master · izumin5210/clig

あとは,テスト用に *bytes.Buffer が詰まった fake 実装とかを作ったりしている.

Makefile / CI

Go でボイラプレートといえば Makefile と CI ですね(?)

build

cmd/<CLI_NAME> 以下に main を置くようにしているので,それをいい感じにビルドするタスクを生成している.
たとえば cmd/foobar/main.go なら

  • make foobar
    • => go build -o ./bin/foobar ./cmd/foobar
  • make package-foobar
    • => gox ... -output="dist/foobar_{{.OS}}_{{.Arch}}" ./cmd/foobar

みたいな感じになる.こういうのも Makefile でよくわかんないことせずに Go でツールを作ってあげるといいのかもしれない….

SRC_FILES := $(shell go list -f '{{range .GoFiles}}{{printf "%s/%s\n" $$.Dir .}}{{end}}' ./...)
BIN_DIR := ./bin
OUT_DIR := ./dist
GENERATED_BINS :=
PACKAGES :=

XC_ARCH := 386 amd64
XC_OS := darwin linux windows

define cmd-tmpl

$(eval NAME := $(notdir $(1)))
$(eval OUT := $(addprefix $(BIN_DIR)/,$(NAME)))

$(OUT): $(SRC_FILES)
    go build $(GO_BUILD_FLAGS) $(LDFLAGS) -o $(OUT) $(1)

.PHONY: $(NAME)
$(NAME): $(OUT)

.PHONY: $(NAME)-package
$(NAME)-package: $(NAME)
    gox \
        $(LDFLAGS) \
        -os="$(XC_OS)" \
        -arch="$(XC_ARCH)" \
        -output="$(OUT_DIR)/$(NAME)_{{.OS}}_{{.Arch}}" \
        $(1)

$(eval GENERATED_BINS += $(OUT))
$(eval PACKAGES += $(NAME)-package)

endef

$(foreach src,$(wildcard ./cmd/*),$(eval $(call cmd-tmpl,$(src))))

.DEFAULT_GOAL := all

.PHONY: all
all: $(GENERATED_BINS)

.PHONY: packages
packages: $(PACKAGES)

ldflags

たとえば Skaffold はバージョン情報とかも ldflags から読み込んでいるが,自分はバグレポート受け取るときに便利な最小限のみ ldflags 経由で注入して,バージョン情報などはコード中にハードコードするようにしている.これは go が「go get だけでツールをビルド & インストールできる」文化で,ユーザがちゃんと brew や GitHub Release からアプリを落としてくれるとは限らないため.

REVISION ?= $(shell git describe --always)
BUILD_DATE ?= $(shell date +'%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -ldflags "-X main.revision=$(REVISION) -X main.buildDate=$(BUILD_DATE)"

Release

さっきしれっと出てきたけど,gox でクロスコンパイルしている.この生成物を CI から GitHub Release に投げ込む.

# .travis.yml
# 自分は Travis CI を使うことが多いけど,いまどきの CI as service なら何でもいいと思う

language: go

go: '1.11'

env:
  global:
  - FILE_TO_DEPLOY="dist/*"

  # GITHUB_TOKEN
  - secure: "..."

jobs:
  include:
  # snip.

  - stage: deploy
    install: make setup
    script: make packages -j4
    deploy:
    - provider: releases
      skip_cleanup: true
      api_key: $GITHUB_TOKEN
      file_glob: true
      file: $FILE_TO_DEPLOY
      on:
        tags: true
    if: type != 'pull_request'

lint / reviewdog

最低限のコードの品質保証とコードレビューの負担軽減用に,いくつかの linter を reviewdog を噛ませて使っている.たとえば grapi の .reviewdog.yml では golint, govet, errcheck, wraperr, megacheck, unparam を有効にしている.

あとは Makefile に lint 用のタスクを追加して,CI (pull-req)でチェックしている.

.PHONY: lint
lint:
ifdef CI
    gex reviewdog -reporter=github-pr-review
else
    gex reviewdog -diff="git diff master"
endif
# snip.

env:
  global:
  # snip.

  - REVIEWDOG_GITHUB_API_TOKEN=$GITHUB_TOKEN

jobs:
  include:
  - name: lint
    install: make setup
    script: make lint
    if: type = 'pull_request'

  # snip.

↓こんな感じになる.

pull-request review by reviewdog

reviewdog を使うことで CI を fail させずに lint の指摘を残せる.なので, golint のコメント関係や errcheck の絶対問題ない系のエラーハンドリングなどをスルーできるようになる.

また,lint ツールやコード生成ツールなどは gex という tool 管理ツールで管理している.開発者や CI 環境によって利用するツールのバージョンに差異が発生するのを防ぐためである.gex については以前「gex で Go プロジェクトの開発用ツールの依存を管理する - Qiita」という記事で紹介したので,そちらも参考にしてほしい.

clone 直後や CI で便利なように,setup task を Makefile に用意している.

.PHONY: setup
setup:
ifdef CI
    curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
endif
    dep ensure -v -vendor-only
    @go get github.com/izumin5210/gex/cmd/gex
    gex --build --verbose

boilerplate generator & utility package

と,ここまでで「いつも書いてる boilerplate」を紹介した.紹介してないものもいくつかあるので,実際にプロジェクト新規作成時にはもっとたくさん書いている(プロジェクトの性質によって取捨選択はするが).

流石に自分でもこれを毎回書いてるのはアホらしくなってたのでプロッジェクトジェネレータとライブラリを作った.

https://github.com/izumin5210/clig

こんな感じでプロジェクトが生成されたり

$ clig init your-app-name

$ cd your-app-name
$ tree -I 'bin|vendor'
.
├── Gopkg.lock
├── Gopkg.toml
├── Makefile
├── cmd
│   └── awesomecli
│       └── main.go
├── pkg
│   └── awesomecli
│       ├── cmd
│       │   └── cmd.go
│       ├── config.go
│       └── context.go
└── tools.go

よく書くコードがまとめられたパッケージがあったりする.
また,clig 自身も clig が吐くものとほぼ同じ構成をとっている.

新しく CLI ツールを作ることがあれば参考にしてもらえるといいかもしれない.

goパッケージを使って複数ファイルを1つにまとめる gma を作った

これは Go2 advent calendar 16日目の記事です。

モチベーション

最近競技プログラミングをやり始めたが、web上のエディタで書いたコードをそのままsubmitする形式が多いように思う。つまりシングルファイルにまとめる必要が出てくるが、いくつか問題を解いていると似たような処理が多くなりutilファイルが欲しくなってきたので作った。

作ったもの

go パッケージを使って複数のファイルを1つにまとめる gma というツールを作った。

何ができるか

単機能なのでREADMEに書いている以上のことはないが、現状以下のように複数のファイルがあったとき、良い感じにシングルファイルにまとめてくれる。

元ファイルたち:

$ tree example/
example/
├── main.go
├── util
│   └── util.go
└── util.go
example/main.go
package main

import (
        "fmt"

        "github.com/takashabe/gma/example/util"
)

func main() {
        fmt.Println(util.Foo())
        Foo()
}
example/util.go
package main

func Foo() {}
example/util/util.go
package util

import "fmt"

func Foo() string {
        return fmt.Sprintf("util")
}

結合する:

$ gma -main example/main.go -depends example/util.go -depends example/util/util.go
package main

import (
        "fmt"
)

func main() {
        fmt.Println(_util_Foo())
        Foo()
}
func Foo() {
}
func _util_Foo() string {
        return fmt.Sprintf("util")
}

"github.com/takashabe/gma/example/util" への依存が無くなり、単体で実行可能になっていることが分かると思う。

実装について

今回のように他パッケージのファイルを扱おうとするとシングルファイルにしたときimportの問題が出てくるので、ASTをゴニョゴニョするのが良さそうというのがわかる。
goでASTを扱うことについては日本語でも素晴らしい資料があるのでそれらを参照するのが良いと思う。特に参考にさせてもらったのは以下。
- https://qiita.com/tenntenn/items/a312d2c5381e36cf4cd3
- https://motemen.github.io/go-for-go-book/

ここでは gma を実装する上で特にハマった複数ファイルの結合と、外部パッケージの外部関数呼び出しをローカル呼び出しに変換する部分について、実際のコードから抜き出して紹介したい。

複数ファイルの結合

インポートや変数、型、関数定義といった宣言は全て ast.Decl インタフェースを実装している。そのためファイルごとのASTを得たら、それらから ast.Decl を抽出して新しい *ast.File とすれば良い。

以下ではimportを別に解決したかったのでそれだけ個別に除外しているが、雰囲気は伝わると思う。

func mergeFiles(files []*ast.File) (*ast.File, error) {
...
  decls := []ast.Decl{}
  for _, file := range files {
    for _, d := range file.Decls {
    g, ok := d.(*ast.GenDecl)
    if ok && g.Tok == token.IMPORT {
      continue
    }
    decls = append(decls, d)
    }
  }

  file := &ast.File{
    Package: files[0].Package,
    Name:    files[0].Name,
    Decls:   decls,
  }
...

上記コードでは触れていないが、複数パッケージを対象にしたときに同名の宣言があるとコンフリクトするので、実際には予めパッケージごとにユニークになるように名前を変換している。

外部関数呼び出しの変換

関数呼び出しをしているのは *ast.CallExpr で、更にその中で外部パッケージの関数呼び出しをしているのは callExpr.Fun*ast.SelectorExpr のものになる。

以下では条件に合致するnodeを探索している。ASTをいじるときは大体こんな感じで必要なnodeをwalkなりして探してゴニョゴニョというコードが多くなると思う。

func(c *astutil.Cursor) bool {
  n := c.Node()

  callExpr, ok := n.(*ast.CallExpr)
  if !ok {
    return true
  }
  selector, ok := callExpr.Fun.(*ast.SelectorExpr)
  if !ok {
    return true
  }
  x, ok := selector.X.(*ast.Ident)
  if !ok {
    return true
  }
...

外部関数呼び出しを行っているnodeが特定できたら、あとは実際にそれが変換する必要があるかどうかを判定して、変換の必要があれば astutil パッケージの Cursor.Replace でnodeの変換を行っている。(この用途なら普通にast.Walkしてnodeのフィールドを上書きしても良かったかもしれない)

実際には事前に結合したファイル側の関数を全て抜き出して、変換対象リストを作ってmainファイル側で呼び出される関数ごとに突き合わせを行っている。泥臭い感じのコードになっているがもし興味があればリポジトリを見てもらえればと思う。

  cn := &ast.CallExpr{
    Fun:  repNode.Name,
    Args: callExpr.Args,
  }
  c.Replace(cn)
  return true
}

astutil パッケージは golang.org/x/tools 配下にある準公式っぽいやつで、使い方はテストを見ると何となく分かると思う。
https://github.com/golang/tools/blob/master/go/ast/astutil/rewrite_test.go

まとめ

go パッケージを使って複数のファイルを1つにまとめる gma というツールを作った。またその過程で得たASTの扱いなどについて紹介した。

まともにASTを触ったのは初めてだったが、goではASTにアクセスするためのインタフェースが揃っているのでとても楽だった。
今回のようにAST経由で変換して何かしたいといった場合、要素ごとにそれを表すASTノードが何であるかを把握出来るとあとは整合性を保って変換していくだけなので、各ノードの関係性が分かるとそれなりに動くものが作れそうな気がする。

またgoでは公式ツール内で go パッケージを使っているものも多く、特に cmd/gofmtgolang/x/tools/cmd は非常に参考にさせてもらった。

高速コレクション操作ライブラリ「Koazee」

この記事はGo2 Advent Calendar 2018の17日目の記事です。

コレクション操作ライブラリ Koazee

12月はじめのこと、go-nutsに「速いコレクション操作ライブラリを作ったぜ」という投稿がありました。

彼のベンチマークによると類似のライブラリに比べてかなり速いという結果が示されています。ここでは彼が作ったコレクション操作ライブラリ Koazee を紹介しつつ、高速化の一端に触れてみます。

Lazy like a koala, smart like a chimpanzee (原文ママ)

Koazeeの特徴は

  • イミュータブル
  • ストリームスタイル
  • 遅延ローディング
  • ジェネリックス
  • 速い!

とのことでここでは「ジェネリックス」と「速い!」という点を見ていきます。

ジェネリックス

Koazee を使うとストリームの操作をより自然な形で記述することができます。

まず、Koazee の使い方を見る前に、類似ライブラリの go-linq のサンプルを見てみます。

import . "github.com/ahmetb/go-linq"

type Car struct {
    year int
    owner, model string
}

...


var owners []string

From(cars).Where(func(c interface{}) bool {
    return c.(Car).year >= 2015
}).Select(func(c interface{}) interface{} {
    return c.(Car).owner
}).ToSlice(&owners)

WhereSelectに渡される関数リテラルの中で、型アサーションしています。なんだか面倒くさい。go-linqにはWhereTSelectTといった、関数リテラル自体をinterface{}で受けるものも用意されていますが、「オーバーヘッドがあるよ」と注意書きがされています。

一方、Koazeeのサンプルを見てみると

package main

import (
    "fmt"
    "github.com/wesovilabs/koazee"
)

var numbers = []int{1, 5, 4, 3, 2, 7, 1, 8, 2, 3}

func main() {
    fmt.Printf("input: %v\n", numbers)
    stream := koazee.StreamOf(numbers)
    fmt.Print("stream.Reduce(sum): ")
    fmt.Println(stream.Reduce(func(acc, val int) int {
        return acc + val
    }).Int())
}

/**
go run main.go

input: [1 5 4 3 2 7 1 8 2 3]
stream.Reduce(sum): 36
*/

型アサーションをしない形で書かれています。実際にReduceは関数リテラルをinterface{}で受け取っています。

go-linqでは遅くなるとされていた方式で、なぜKoazeeは速いのか?

速い!

本人の解説によると、「キャッシュをうまく使っている」とのこと。例として、Filterの実装にあるバリデーションを見てみると、

func (op *Filter) validate() (*filterInfo, *errors.Error) {
   item := &filterInfo{}
   fnType := reflect.TypeOf(op.Func)
   if val := cache.get(op.ItemsType, fnType); val != nil {
      return val, nil
   //...... Validations for input
   item.fnInputType = fnIn.Type()
   cache.add(op.ItemsType, fnType, item)
   return item, nil
}

cacheにバリデーション結果を保存しているのがわかります。cacheの実態はただの二次元mapです。

また、他の点でもパフォーマンスに気を使っているとのことで、Reverseではちょっと工夫して

func reverseInt16Ptr(itemsValue reflect.Value) interface{}{
   input := itemsValue.Interface().([]*int16)
   len := len(input)
   output := make([]*int16, len)
   for index := 0; index < (len/2)+1; index++ {
      output[index], output[len-1-index] = input[len-1-index], input[index]
   }
   return output
}

ループ処理が半分で済むように実装されていたりします。

まとめ

年末に彗星のごとく現れたKoazee、使いやすく、それでいて高速!

キャッシュまわりはgoroutineと併用されたときにどうなるのーとか気になりますが、今後に注目です。

Goにまつわるとっても真面目なBenchmarkクイズ!「goの正規表現(regexp)は速いのか?」編

今回はクソ真面目にクイズを書いていきます。
なぜ真面目を強調しているかといえば、昔書いた記事が不真面目だったからです。
鬱陶しいくらい「いいね」するよう勧めてくるgoroutineクイズ

それではクイズに行きましょう。
最初の3問は小手調べ、Goの基本事項のおさらいです。
その後、第四問から正規表現の速さ比べに入ります。

実行環境

こんなの

Distro: Ubuntu 18.04.1
Kernel: 4.15.0-42-generic
CPU:    Intel(R) Core(TM) i5-7600 CPU @ 3.50GHz
Mem:    16GB
Go:     go version go1.11.3 linux/amd64

書いている間にgo1.11.4がリリースされましたが、今回は1.11.3です。

形式

f0とf1の実行時間を比較してください。

Benchmarkはだいたい

main_test.go
func BenchmarkF0(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f0()
    }
}

func BenchmarkF1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f1()
    }
}

最初の3問はこんなノリです。f0とf1のどちらが速いか、あるいはだいたい同じくらいかを当ててください。

第一問:文字列のフォーマット、結合

割と基本的な文字列の結合です。

main.go
var (
    a = 1
    b = "Fizz"
    c = false
)

func f0() string {
    return fmt.Sprintf("%d%s%v", a, b, c)
}

func f1() string {
    return strconv.Itoa(a) + b + strconv.FormatBool(c)
}

解答

解答(折りたたみ)

f1の方が速い。
printf系の関数は遅くなることが多いです。

BenchmarkF0-4       10000000           155 ns/op          40 B/op          3 allocs/op
BenchmarkF1-4       30000000            44.8 ns/op        16 B/op          1 allocs/op

第二問:スライスの定義

初心者がよくハマるあれです。

main.go
func f0() []int {
    slice := []int{}
    for i := 0; i < 1024; i++ {
        slice = append(slice, i)
    }
    return slice
}

func f1() []int {
    slice := make([]int, 0, 1024)
    for i := 0; i < 1024; i++ {
        slice = append(slice, i)
    }
    return slice
}

解答

解答(折りたたみ)
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/slice0
BenchmarkF0-4         500000          2522 ns/op       16376 B/op         11 allocs/op
BenchmarkF1-4        1000000          1365 ns/op        8192 B/op          1 allocs/op

最初にcapを確保するかどうかで大分変わります。
メモリの再確保は時間がかかるのです。

第三問:再帰関数

よくある再帰関数です。

main.go
func f0(n int) int {
    if n > 0 {
        return n + f0(n-1)
    }
    return 0

}

func f1(n int) int {
    sum := 0
    for i := 0; i <= n; i++ {
        sum += i
    }
    return sum
}

解答

解答(折りたたみ)

f1が速い
まあ、再帰は遅いよね。

$ go test --bench . --benchmem
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/rec
BenchmarkF0-4            300       5626446 ns/op           0 B/op          0 allocs/op
BenchmarkF1-4           3000        520985 ns/op           0 B/op          0 allocs/op
PASS
ok      github.com/aimof/bench/rec  4.323s

第四問:正規表現その1

本題です。
遅いと勘違いされがちなgoの正規表現についてです。
ランダムな数字で構成された文字列、バイト列を生成して"123"を"999"に変換して出力します。
stringsパッケージとregexpパッケージの比較です。
仕様上f0が文字列、f1がバイト列をそれぞれ引数、返り値にしています。

main.go
func f0(s string) string {
    return strings.Replace(s, "123", "999", -1)
}

func f1(b []byte) []byte {
    reg, err := regexp.Compile("123")
    if err != nil {
        log.Fatalln(err)
    }
    return reg.ReplaceAll(b, []byte("999"))
}
main_test.go
var length = 1024
var target = makeBytes(length)
var str = string(target)

func makeBytes(n int) (b []byte) {
    rand.Seed(time.Now().UnixNano())
    for i := 0; i < n; i++ {
        num := rand.Intn(10)
        b = append(b, []byte(strconv.Itoa(num))...)
    }
    return b
}

func Test(t *testing.T) {
    n0 := f0(str)
    n1 := f1(target)
    if n0 != string(n1) {
        t.Error()
    }
}

func BenchmarkF0(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f0(str)
    }
}

func BenchmarkF1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f1(target)
    }
}

ここからはテストまで全部書きます。
クイズ始めましょう!

n = 1024(1Ki)のとき速いのはどっち?

解答

解答(折りたたみ)

f0(strings)の方が速い。やはり遅いですね、goの正規表現。

$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg
BenchmarkF0-4        1000000          3583 ns/op        2048 B/op          2 allocs/op
BenchmarkF1-4          50000         23791 ns/op       41720 B/op         33 allocs/op
PASS

第五問:正規表現その2

第四問でlength=1024*1024(1Mi)の場合は?

解答

解答(折りたたみ)

f1(regexp)の方が速い

$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg
BenchmarkF0-4           1000       4911563 ns/op     2097152 B/op          2 allocs/op
BenchmarkF1-4           1000       2735865 ns/op     5859064 B/op         58 allocs/op
PASS
ok      github.com/aimof/bench/reg  10.261s

第六問:正規表現その3

第四問、第五問でlength=1024*1024*1024(1Gi)の場合は?

解答

解答(折りたたみ)

だいたいf1の方が速いです。

$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg
BenchmarkF0-4              1    1939527170 ns/op    2147483744 B/op        3 allocs/op
BenchmarkF1-4              1    1693673809 ns/op    6168930776 B/op       84 allocs/op
PASS
ok      github.com/aimof/bench/reg  48.742s

ループ数1なので10回くらい試しましたが、おおよそ、f0: 19.5秒程度、f1: 17秒程度になります。

第七問:正規表現同士の比較1

解答

main.go
func f0(b []byte) []byte {
    reg, err := regexp.Compile("123")
    if err != nil {
        log.Fatalln(err)
    }
    return reg.ReplaceAll(b, []byte("999"))
}

func f1(b []byte) []byte {
    reg, err := regexp.Compile("1.3")
    if err != nil {
        log.Fatalln(err)
    }
    return reg.ReplaceAll(b, []byte("999"))
}
main_test.go
var length = 1024
var target = makeBytes(length)

func makeBytes(n int) (b []byte) {
    rand.Seed(time.Now().UnixNano())
    return bytes.Repeat([]byte("12345678"), n/8)
}

func Test(t *testing.T) {
    n0 := f0(target)
    n1 := f1(target)
    if !reflect.DeepEqual(n0, n1) {
        t.Error()
    }
}

func BenchmarkF0(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f0(target)
    }
}

func BenchmarkF1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f1(target)
    }
}

"123"と"1.3"の比較です。
文字列の生成方法を工夫したので"1.3"にマッチするのは"123"だけです。
どんな感じになるのでしょうか?
length=1024のときです。(この後増えます)

解答(折りたたみ)
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg1
BenchmarkF0-4          50000         65917 ns/op       40896 B/op         37 allocs/op
BenchmarkF1-4          30000         65174 ns/op       41072 B/op         40 allocs/op
PASS
ok      github.com/aimof/bench/reg1 6.078s

第八問:正規表現同士の比較2

length=1024*1024

解答

解答(折りたたみ)
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg1
BenchmarkF0-4             50      32877722 ns/op     5865417 B/op         66 allocs/op
BenchmarkF1-4             50      32408068 ns/op     5865597 B/op         69 allocs/op
PASS
ok      github.com/aimof/bench/reg1 3.464s

あんまり変わりませんね(つまらぬ)

第九問:正規表現同士の比較3

一応、length=1024*1024*1024(1Gi)のときもやってみましょう。

解答

解答(折りたたみ)
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg1
BenchmarkF0-4              1    32865004402 ns/op   6168960968 B/op       97 allocs/op
BenchmarkF1-4              1    31682328432 ns/op   6168961144 B/op      100 allocs/op
PASS
ok      github.com/aimof/bench/reg1 195.128s

やっぱりあまり変わりません。
もうちょっと複雑な正規表現を試してみましょう。

最終問題:正規表現同士の比較

"1......8"
"12*8"
この2つのパターンで、"12222228"この繰り返しにマッチさせます。

main.go
func f0(b []byte) []byte {
    reg, err := regexp.Compile("1......8")
    if err != nil {
        log.Fatalln(err)
    }
    return reg.ReplaceAll(b, []byte("19999998"))
}

func f1(b []byte) []byte {
    reg, err := regexp.Compile("12*8")
    if err != nil {
        log.Fatalln(err)
    }
    return reg.ReplaceAll(b, []byte("19999998"))
}
main_test.go
var length = 1024 * 1024 * 1024
var target = makeBytes(length)

func makeBytes(n int) (b []byte) {
    return bytes.Repeat([]byte("12222228"), n/8)
}

func Test(t *testing.T) {
    n0 := f0(target)
    n1 := f1(target)
    if !reflect.DeepEqual(n0, n1) {
        t.Error()
    }
}

func BenchmarkF0(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f0(target)
    }
}

func BenchmarkF1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = f1(target)
    }
}

1Ki, 1Mi, 1Giそれぞれの場合の速い方を答えてください!

解答

解答(折りたたみ)

1Ki: 同じくらい
1Mi: f0の方が速い
1Gi: f0の方が速い

桁数が指定されていないf1のほうが遅いみたいですね。(それでも結構速いですが)

1Ki.go
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg2
BenchmarkF0-4          50000         70360 ns/op       42592 B/op         48 allocs/op
BenchmarkF1-4          20000         68998 ns/op       41216 B/op         41 allocs/op
PASS
ok      github.com/aimof/bench/reg2 5.872s
1Mi.go
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg2
BenchmarkF0-4             30      54123874 ns/op     5867170 B/op         77 allocs/op
BenchmarkF1-4             20      71250299 ns/op     5865864 B/op         75 allocs/op
PASS
ok      github.com/aimof/bench/reg2 3.372s
1Gi.go
$ go test --bench . --benchmem 
goos: linux
goarch: amd64
pkg: github.com/aimof/bench/reg2
BenchmarkF0-4              1    55755956525 ns/op   6168962664 B/op      108 allocs/op
BenchmarkF1-4              1    72950733405 ns/op   6168961416 B/op      106 allocs/op
PASS
ok      github.com/aimof/bench/reg2 324.925s

まとめ

さて、ここまでやってきてお気づきの方もいらっしゃるかもしれませんが、Goの正規表現は実行時間がほぼ線形に増えます。
1Miと1Giの実行時間を比べてみると、ほぼ1024倍に近い差になっています。
1ki程度に短い場合には、大分長くなるようです。

というわけで、長い文字列を処理する場合には、regexpは優秀です。
MiB単位くらい長くないとあまり効果は発揮できないようですね。

以上、Goにまつわるとっても真面目なBenchmarkクイズ!「goの正規表現(regexp)は速いのか?」編でした。

私の読みたいものリストに「regexpのソースコード」が追加されました。

RxGoでリアクティブプログラミングに入門する

External article

Goのssa.htmlの変更点まとめ2018

Go2 Advent Calendar 2018 20日目の記事です。
空きを見つけたので代筆させていただきます。

はじめに

Goコンパイラの中間表現として採用されているSSA(Static Single Assignment)の小ネタです。

SSA(静的単一代入)についてはWikipediaが詳しいのでご覧ください。

GoコンパイラにはSSA形式が最適化パスを通してどのように変化してくのかをHTMLファイル(ssa.html)にダンプする機能があります。この記事ではssa.htmlが2018年にどのように進化していったかをコミットログから追っていきます。

ssa.htmlの作り方

$ env GOSSAFUNC=main go build

コンパイル時に環境変数GOSSAFUNCに関数名をセットすることで、ssa.htmlを生成できます。

後述しますが、2017年はGOSSAFUNCには単に関数名を指定するだけでしたが、2018年の変更で複雑な指定もできるようになりました。

変更点

では、以降でどのような変更があったかを見ていきます。変更は

  • 現在の最新安定版(Go11.4)までに入っているもの
  • まだmaster(go1.12beta1)にしか入っていないもの

に分けて見ていきます。

変更点は、ssa.htmlを生成しているcmd/compile/internal/ssa/html.goのコミットログから調べました。

今回解析対象としたコードは以下のサンプルのssa関数です。Wikipediaの例を拝借しました。

package main

import "fmt"

func ssa() {
        var w, x, y, z int

        x = 5
        x = x - 3
        if x < 3 {
                y = x * 2
                w = y
        } else {
                y = x - 3
        }
        w = x - y
        z = x + y

        fmt.Printf("%d, %d\n", w, z)
}

func main() {
        ssa()
}

1.9.2〜1.11.4の変更点

昨年末の最新版Go1.9.2と現在の最新版Go1.11.4ssa.htmlがどのくらい違うのか比べて見ましょう。

大きく違うのはいくつかの最適化パスが折りたたまれていることでしょうか。

では、順に見ていきます。

Jul 28, 2017 (1.10beta1-) Goコードの変数名が出力されるように

[dev.debug] cmd/compile: better DWARF with optimizations on

SSA形式の変数名だけでは元のコードのどの変数だったのかが分かりづらかったのですが、この変更によって対応する変数が表示されるようになりました。
この変更自体は、最適化によって失われていた変数の位置情報をコンパイラオプション-dwarflocationlistsによってデバッグ情報として付与できるようにした、
というものです。

-dwarflocationlistsオプションについては以下に少し書いてあります
https://tip.golang.org/doc/diagnostics.html#debugging

Aug 18, 2017 (1.10beta1-) (リファクタリングなのでスキップ)

cmd/compile: rename SSA Register.Name to Register.String

Oct 12, 2017 (1.10beta1-) 変数に対応する行数が表示されるように

cmd/compile: add line numbers to values & blocks in ssa.html

元のコードの行数がSSAの変数の横に括弧書きされるようになりました。

Apr 5, 2018 (go1.11beta1-) IsStmtによって行数の表示が変わるように

cmd/compile: add IsStmt breakpoint info to src.lico

Includes changes to html output for GOSSAFUNC to indicate
not-default is-a-statement with bold and not-a-statement
with strikethrough.

https://github.com/golang/go/commit/619679a3971fad8ff4aa231d7942c95579ceff23#diff-8cd6c5dd336a16009909e578d8b6fcb3R394

この変更時点ではIsStmtマークは使われていませんが、現在は例えば以下の演算で行番号がストライクアウトされます。
https://github.com/golang/go/blob/release-branch.go1.11/src/cmd/compile/internal/ssa/numberlines.go#L62

Jun 14, 2018 (go1.11beta1-) 最適化の各フェーズの列を折り畳めるように

cmd/compile: use expandable columns in ssa.html

デフォルトでは以下の名前で始まるパスが最初から広がっており(html.go#L297)、それ以外は折りたたまれるようになりました。パス名の辺りをクリックすることで畳んだり広げたりできます。

  • "start"
  • "deadcode"
  • "opt"
  • "lower"
  • "late deadcode"
  • "regalloc"
  • "genssa"

Jun 16, 2018 (go1.11beta1-) 選択した時の色の種類が増えました

cmd/compile: add more color choices to ssa.html

ブロックや各行を選択するたびに別の色でハイライトされますが、この色が5色ほど増えました。

1.12(予定)以降の変更点

まだmasterブランチにしか入ってませんが、かなり変更が入っていますので紹介しておきます。全て@ysmolskyさんによるものです。

Go1.11.4と最新リリースのGo1.12beta1でssa.htmlがどのくらい違うのか比べて見ましょう。

ソースコードやAST、そしてCFG(Control Flow Graph)が追加されています。

では、1つずつ見ていきます。

Aug 23, 2018 Goの元のソースコードがssa.htmlに含まれるように

cmd/compile: display Go code for a function in ssa.html

今まではSSA形式に変換されてからの変遷でしたが、元のソースコードと対応づけて表示できるようになりました。

Aug 23, 2018 ssa.htmlの出力パスが標準出力に表示されるように

cmd/compile: clean the output of GOSSAFUNC

この変更は、GOSSAFUNCをつけたときに標準出力に表示される大量のデバッグプリントを抑制するためのものです。従来のようにデバッグプリントが必要な時はGOSSAFUNC=Foo+のように関数名の後ろに+をつければよいです。ssa.htmlの内容に変化はありませんが、html.goの中ではssa.htmlの出力パスを表示するよう変更になりました。

Aug 23, 2018 ソースコードにインライン展開後のソースが追加

cmd/compile: add sources for inlined functions to ssa.html

https://github.com/golang/go/issues/25904

インライン展開された関数がソースコードの列に追加されるようになりました。

Aug 25, 2018 ソースコードとSSAの間にASTの列が追加

cmd/compile: display AST IR in ssa.html

https://github.com/golang/go/issues/26662

ソースコードとSSAの間にAST(Abstract Syntax Tree)の列が入りました。ソースコード⇔AST⇔SSA間も対応づけてハイライトされます。

Sep 19, 2018 [bug fix]

cmd/compile/internal/ssa: fix a == a to a == b

Goのソースコードの列の関数の順序の計算が間違ってました。

Oct 17, 2018 タブ幅を狭く

cmd/compile: make tabs narrow in src column of ssa.html

Goのソースコードのインデントのタブ幅を狭くしました。

Oct 25, 2018 ヘッダの高さを控えめに

cmd/compile: reduce the size of header in ssa.html

関数名とヘルプで上部領域が埋まってたので調整したようです。
毎日見ていると、この高さが気になるのでしょう。

Nov 21, 2018 各フェーズのCFGを表示できるように

cmd/compile: add control flow graphs to ssa.html

大きめの機能が入りました。各フェーズにCFG(Control Flow Graph)を表示できるようになりました。コミットログにも書かれていますが、関数名の後ろに次の指定をすることでCFGが生成されます。

:*            - dump CFG for every phase
:lower        - just the lower phase
:lower-layout - lower through layout
:w,x-y        - phases w and x through y

サンプルはこちらでも確認できます。

https://github.com/golang/go/issues/28177

実行には、graphviz(dotコマンド)が必要です。SVG出力したものがssa.htmlに埋め込まれています。SSAの各ブロックを選択するとそれ対応してCFGのノードがハイライトされます。反対にノードを選択すると対応するブロックが選択されます。

細かい話ですが、先ほど紹介した「Aug 23, 2018 ssa.htmlの出力パスが標準出力に表示されるように」で末尾に「+」を追加するとデバッグプリントが表示されるのは、この形式になっても有効です。

# 全フェーズでCFGを生成、デバッグプリントも表示
$ env GOSSAFUNC=ssa:*+ go build main.go

Nov 22, 2018 ↑の手直し

cmd/compile: fix TestFormats by using valid formats

Nov 24, 2018 ブロックが畳めるように

cmd/compile: make ssa blocks collapsable in ssa.html

小さいですが各ブロックの右上に-ボタンが追加されました。押すとブロックを畳めます。

まとめ

2018年に導入されたssa.htmlへの変更点をまとめました。主に次の変更点がありました。

  • 元のソースコードとの対応づけ強化
  • CFG表示
  • 最適化パスやブロックの折りたたみ
  • 色数増加

go1.12がリリースされた際にはお試しください。

それでは良いお年を。

Goを書く時に使えそうな便利ツールを調べてみた

External article

GoのOSSをクロスコンパイルして公開する

External article

Written A Compiler In Go を読んだ

External article

Go + gRPC でC言語プロジェクトのビルドを早くした話

この記事は Go2 Advent Calendar 2018 の 24 日目の記事です。

私は組込ソフトエンジニアで、職場にはレガシーな環境が多く残っています。
そして、ビルドツールが古かったりして 2MB にも満たないバイナリを作るのに数十分かかったりしています。
時間がかかる主な理由は、複数 CPU による分散コンパイルが実現されてない(場合が多い)から、です。

ということで、 Go 言語の goroutine を用いて CPU をなるべく使う形のタスクランナーを書くことが多いわけですが、最近は gRPC 経由で複数のマシンを活用する分散ビルド環境を作っているのでまとめました。

動くサンプルを紹介しつつ、徐々に分散ビルドになるように段階的に進めていきます。

分散処理のイメージ

実際に仕事で使っているプロジェクトの分散 build は以下の画像のようになります。
1 CPU で普通にビルドすると 1200 秒かかりますが、分散ビルド (合計 22 CPU) することで 90 秒まで短くすることができました。

pfcc2.png

横軸は時間 (sec) で、縦軸はリソース (ローカルリソースである localWorker (6CPU) と、リモートリソースである pcA (10 CPU) と pcB (6CPU)) を表します。

処理を始めるとすぐに localWorker が仕事を始めます。
それと同時に pcA および pcB にビルドに必要なファイルを転送 (約3000ファイル 100MB) します。
pcA と pcB は約10秒経過後にファイル受け取りが完了し処理に加わっています。
上の例では、 pcB がわずかに早くファイル受け取りが完了しています。

localWorker(4) が最後に処理しているファイル (右端) は、他のすべてが終わってからしか作成できないファイルです。
pcB(4) が受け持つファイルが完了次第、処理をしています。
このように、隙間が空いている場所は何らかの依存関係があって処理待ちしている状況となります。

また localWorker(0) が処理しているファイルは、1ファイルで90秒近い処理時間となっています。
このファイルが全体のビルド時間を決めてしまっています。

題材

仕事で使っている環境は持ち出せないので、今回はダミーのコンパイラ(dummycc) とリンカ (dummyld) を用意しました。
それぞれの仕様は以下の通り。

  • dummycc
    • 入力したファイルの1行目に記載された時間をかけて処理する
    • 2行目以降の文字列がある場合は、 warning として出力する
  • dummyld
    • 入力したファイルが存在しなければエラーとする
    • 1秒かけてゆっくり処理する

テストに使うCソースは、 aa.cff.c までの 36 ファイルですべてのコンパイルが終わってからリンクを行う想定です。
実際のファイルは こちら にあります。

想定する実行は以下の通り。

dummycc -o testdata/aa.o testdata/aa.c
dummycc -o testdata/ab.o testdata/ab.c
・・・
dummycc -o testdata/fe.o testdata/fe.c
dummycc -o testdata/ff.o testdata/ff.c
dummyld -o testdata/a.out testdata/aa.o testdata/ab.o  ・・・

上記をもとに []*exec.Cmd を作成、それをもとに処理を行っていきます。
具体的には下の関数で作成します。

func makeCmds() []*exec.Cmd {
    xxx := `dummycc -o testdata/aa.o testdata/aa.c
dummycc -o testdata/ab.o testdata/ab.c
dummycc -o testdata/ac.o testdata/ac.c
// 省略
dummycc -o testdata/fe.o testdata/fe.c
dummycc -o testdata/ff.o testdata/ff.c
dummyld -o testdata/a.out testdata/aa.o testdata/ab.o testdata/ac.o testdata/ad.o testdata/ae.o testdata/af.o testdata/ba.o testdata/bb.o testdata/bc.o testdata/bd.o testdata/be.o testdata/bf.o testdata/ca.o testdata/cb.o testdata/cc.o testdata/cd.o testdata/ce.o testdata/cf.o testdata/da.o testdata/db.o testdata/dc.o testdata/dd.o testdata/de.o testdata/df.o testdata/ea.o testdata/eb.o testdata/ec.o testdata/ed.o testdata/ee.o testdata/ef.o testdata/fa.o testdata/fb.o testdata/fc.o testdata/fd.o testdata/fe.o testdata/ff.o
`

    cmds := []*exec.Cmd{}
    scanner := bufio.NewScanner(strings.NewReader(xxx))
    for scanner.Scan() {
        fields := strings.Fields(scanner.Text())
        cmds = append(cmds, &exec.Cmd{
            Path: fields[0],
            Args: fields,
        })
    }

    return cmds
}

build01 : まずは分散せずに普通に build するパターン

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder01.go

シンプルなコードとしては以下の通り。

func build01(cmds []*exec.Cmd) {
    for _, cmd := range cmds {
        buf, _ := cmd.CombinedOutput()
        fmt.Print(string(buf))
    }
}

単純にループするだけなので特に難しい事はありません。
1 つの CPU だけを使って順番に処理するので、時間がかかります。

build02 : goroutine を湯水のように使ったパターン

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder02.go

CPU の core / threads 数を考えずに goroutine を湯水のように使ったパターンです。

build02.png

最初に 36 並列で実行が始まり、すべてのコンパイルが終わってからリンクが始まる、という流れです。

今回の dummycc は実際には一定時間 time.Sleep() しているだけなので 36 並列でも問題はないのですが、通常は処理負荷が問題になるでしょう。
リンクする前にはコンパイルが終わっている必要があるので、 wg.Wait() を使って直前までのコンパイルが全て終わるのを待ち合わせています。
(なお、この書き方だとコンパイルとリンクが交互に走るケース等で CPU を有効活用できませんがここでは無視します)

func build02(cmds []*exec.Cmd) {
    var wg sync.WaitGroup

    for _, cmd := range cmds {
        cmd := cmd

        if cmd.Path != dummyCc {
            // コンパイラではない時は、直前までのコンパイルが終わるのを待つ
            wg.Wait()
        }

        wg.Add(1)
        go func() {
            defer wg.Done()
            buf, _ := cmd.CombinedOutput()
            fmt.Print(string(buf))
        }()
    }
    wg.Wait()
}

build03 : 分散数を指定しつつ処理するパターン

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder03.go

build03_8.png

分散数を指定しつつ、の典型例としては cap 付の chan (↓の例では limit) を用意して制御するパターンがあります。
これはうまく動作しますが、処理時の出力 (warning 等) が混ざる問題があるのでもう少し工夫する必要があります。

func build03(cmds []*exec.Cmd) {
    var wg sync.WaitGroup

    // *threads 分だけ cap を作っておく事で分散数を制御する
    limit := make(chan struct{}, *threads)

    for _, cmd := range cmds {
        cmd := cmd

        if cmd.Path != dummyCc {
            // コンパイラではない時は、直前までのコンパイルが終わるのを待つ
            wg.Wait()
        }

        limit <- struct{}{}
        wg.Add(1)
        go func() {
            defer func() { <-limit }()
            defer wg.Done()
            buf, _ := cmd.CombinedOutput()
            fmt.Print(string(buf))
        }()
    }
    wg.Wait()
}

build04 : 分散数を指定しつつ出力をうまくやるパターン

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder04.go

出力を (混ざらず順序よく) うまくやるために、ここでは github.com/sago35/ochan を使いました。
ochan を使うと ch := oc.GetCh() を実行した順に出力する ch を作り出すことができるので、並列実行しても順番通りに出力されます。
また、 oc.Wait() すると oc.GetCh() して取り出した ch が全て閉じられるまで待つので、ほぼ sync.WaitGroup のように使えます。

ochan については、 umedago #3 の LT で話をしました。 → chan + 順序制御 = ochan

分散実行のイメージは、 build03 と同じです。

func build04(cmds []*exec.Cmd) {
    outCh := make(chan string, 10000)
    done := make(chan struct{})

    go func() {
        for ch := range outCh {
            fmt.Print(ch)
        }
        close(done)
    }()

    limit := make(chan struct{}, *threads)

    oc := ochan.NewOchan(outCh, 100)
    for _, cmd := range cmds {
        cmd := cmd

        if cmd.Path != dummyCc {
            // コンパイラではない時は、直前までのコンパイルが終わるのを待つ
            oc.Wait()
        }

        limit <- struct{}{}
        ch := oc.GetCh()
        go func() {
            defer func() { <-limit }()
            defer close(ch)

            buf, _ := cmd.CombinedOutput()
            if len(buf) > 0 {
                ch <- string(buf)
            }
        }()
    }
    oc.Wait()
    close(outCh)
    <-done
}

gRPC を用いた分散ビルド環境

基本的には上記で説明した goroutine を用いた処理を gRPC 上の処理にすれば良いわけですが、以下についての考慮が必要です。

  • gRPC server 側はソースコードを持っていない
  • gRPC server 側はコンパイル済み object (*.o) を持っていない
    • 複数の gRPC server で実行する場合はすべての object を持っているわけではない
  • gRPC の connection 内に複数の使用可能リソースがある
  • gRPC 上のリソースと local リソースを同じように扱いたい

順番に解決していきます。

gRPC server 側はソースコードを持っていない

使いたい時に都度依存ファイルを送る方法も考えましたが、C言語プロジェクトの場合は #include を解析する必要があるし、どのみちターゲットの C ソースよりも依存するファイルが新しいなら送りなおす等の処置も必要であまり効率的に書ける気がしませんでした。
なので、自分が選んだ方法は git ls-files の結果全て or 引数で指定したファイルリスト を分散ビルドの先頭で送るようにしました。

そうすると、今度はファイルを全て送るまではビルドが始まらないという問題があります。

この部分は、(ファイルを送らなくても実行開始できる) local リソースはすぐにビルドを始めつつ、並列で gRPC 経由でファイルを送信し準備ができ次第 gRPC リソースを使うようにしてビルド時間への影響を減らしました。

gRPC server 側はコンパイル済み object (*.o) を持っていない

コンパイル結果等は当然 local に送り返すようにするわけですが、その結果は local にはあるが全ての gRPC server で持っているわけではありません。
また、すべてのファイルの同期をとる意味もないので、 ↑ で最初に送ったファイル以外に依存がある場合は都度送るようにしました。

gRPC の connection 内に複数の使用可能リソースがある

↑ の build03 のようなパターンだと、すべてのローカルリソースは分け隔てなく同じものである、という前提で処理することができます。
が、 gRPC 経由の場合は、ある server に対しての connection は1つだがその中で複数の JOB を実行できる、というような状態になります。
もちろん、複数 connection を作成してもよいとは思いますがその場合でも、「分け隔てなく」という事はなく、それぞれ connection 先という情報を持つ必要があります。

gRPC server A からは 2 CPU 、 gRPC server B からは 3 CPU となると、合計2つの connection で合計5つの CPU を使える形で処理する必要があります。
これを実現する方法はいくつかあるかと思いますが、自分は github.com/sago35/limichan というライブラリを作成して実現しました。
以下のようなイメージで実装することができます。

func limichan_sample() {
    l, _ := limichan.New(context.Background())

    // gwA からは 2 CPU
    gwA := newGrpcWorker(addresInfoA)
    l.AddWorker(gwA)
    l.AddWorker(gwA)

    // gwB からは 3 CPU
    gwB := newGrpcWorker(addresInfoB)
    l.AddWorker(gwB)
    l.AddWorker(gwB)
    l.AddWorker(gwB)

    for _, job := range jobs {
        // worker がある限りは並列実行し、無ければブロックする
        // worker は gwA の場合もあれば gwB の場合もある
        l.Do(job)
    }

    // すべての l.Do(job) が完了するのを待つ
    err := l.Wait()
    if err != nil {
        log.Fatal(err)
    }
}

gRPC 上のリソースと local リソースを同じように扱いたい

上記の limichanl.AddWorker() は以下の interface を満たす worker は登録可能です。
なので、 gRPC 上のリソースと同じく local のリソースも interface を満たすように実装すれば良いです。

type Worker interface {
    Do(context.Context, Job) error
}

gRPC proto

ということで、次は gRPC サービスを作っていきます。
各関数の説明は後述。

ソース全般は以下のあたりにあります。

syntax = "proto3";

package grpcbuild;

service GrpcBuild {
    rpc Init(InitRequest) returns (InitResponse) {}
    rpc Send(SendRequest) returns (SendResponse) {}
    rpc Exec(ExecRequest) returns (ExecResponse) {}
}

message InitRequest {
    string Dir = 1;
}

message InitResponse {
}

message File {
    string Filename = 1;
    string Dir = 2;
    bytes Data = 3;
}

message SendRequest {
    repeated File Files = 1;
}

message SendResponse {
}

message Cmd {
    string Path = 1;
    repeated string Args = 2;
    repeated string Env = 3;
    string Dir = 4;
}

message ExecRequest {
    repeated Cmd Cmds = 1;
    repeated string Files = 2;
}

message ExecResponse {
    int32 ExitCode = 1;
    bytes Stdout = 2;
    bytes Stderr = 3;
    repeated File Files = 4;
}

Init()

service GrpcBuild {
    rpc Init(InitRequest) returns (InitResponse) {}
}

message InitRequest {
    string Dir = 1;
}

message InitResponse {
}

Init() は引数 Dir によりサンドボックス (の雰囲気の作業ディレクトリ) を作成します。
毎回 Init() する度にすべてのファイルを削除して処理をし直すイメージです。
(本当はうまくサンドボックス化したいのですが、できていません)

Send()

service GrpcBuild {
    rpc Send(SendRequest) returns (SendResponse) {}
}
message File {
    string Filename = 1;
    string Dir = 2;
    bytes Data = 3;
}

message SendRequest {
    repeated File Files = 1;
}

message SendResponse {
}

Send() は以下の2つの目的で使用します。

  • Init() 直後に gRPC server 側はソースコードを持っていない への対策としての送信
  • Exec() 直前に gRPC server 側はコンパイル済み object (*.o) を持っていない への対策としての送信

streaming RPC にしてもよいですが、メモリ使用量が大きくなりがち (複数の接続先に並列に実行するとすぐ数 GB 超になる) なのでうまく使う必要があります。

Exec()

service GrpcBuild {
    rpc Exec(ExecRequest) returns (ExecResponse) {}
}

message File {
    string Filename = 1;
    string Dir = 2;
    bytes Data = 3;
}

message Cmd {
    string Path = 1;
    repeated string Args = 2;
    repeated string Env = 3;
    string Dir = 4;
}

message ExecRequest {
    repeated Cmd Cmds = 1;
    repeated string Files = 2;
}

message ExecResponse {
    int32 ExitCode = 1;
    bytes Stdout = 2;
    bytes Stderr = 3;
    repeated File Files = 4;
}

そのまま os/exec.Cmd に渡して処理できるような形で作成していて、コンパイル/リンクで使用します。
使用できる実行体を制限する等の処置を行った方が無難ですが、ここでは何でも実行できる形で作成しています。
ExecResponse で処理結果のファイル (*.o や a.out 等) を返すように作っています。

処理の流れ

「Init()」 → 「Send()」 → 「Exec() を必要回数繰り返す」 が基本となります。
が、前述の通りリンカ実行前等は依存ファイルを送信する必要があるので 「Send() + Exec()」という形になります。
具体的なコードは、次項の build06.go を確認してください。

build06 : gRPC を用いた分散ビルドパターン (ただしリモートのみ)

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder06.go

ソースコードの全貌はリンク先を見てください。
以下のように、 newWorker() で gRPC server に接続し処理を行います。
github.com/sago35/limichan を使っている以外は、今までのコードとさほど変わりません。

func build06(cmds []*exec.Cmd) {
    outCh := make(chan string, 10000)
    done := make(chan struct{})

    go func() {
        for ch := range outCh {
            fmt.Print(ch)
        }
        close(done)
    }()

    l, _ := limichan.New(context.Background())
    w, _ := newWorker(`127.0.0.1`, 12345, *threads)
    for i := 0; i < *threads; i++ {
        l.AddWorker(w)
    }

    oc := ochan.NewOchan(outCh, 100)
    for _, cmd := range cmds {
        cmd := cmd

        if cmd.Path != dummyCc {
            // コンパイラではない時は、直前までのコンパイルが終わるのを待つ
            oc.Wait()
        }

        j := &job{
            cmd:     cmd,
            ch:      oc.GetCh(),
            outFile: cmd.Args[2:3],
            depFile: cmd.Args[3:],
        }

        l.Do(j)
    }
    oc.Wait()
    l.Wait()
    close(outCh)
    <-done
}

build07 : ローカルとリモート (gRPC) の両方を用いた分散ビルドパターン

https://github.com/sago35/grpcbuild/blob/master/cmd/builder/builder07.go

image.png

作ったサンプルだと分かりにくいですが協調して分散ビルドする例です。
先ほどの build06 との差分は、 newLocalWorker() でローカルリソースを登録した後、 goroutine で gRPC の worker を追加しているところです。
このやり方により、ローカルリソースはすぐに処理をはじめ、リモートは接続等の時間のかかる処理が終わり次第分散ビルドに参加します。
こういうのが簡単に書けるのが Go の良い所ですね。

ここでは、 gRPC 接続+ファイル転送 に時間がかかるイメージで1秒 wait させているので、 ↑ の画像において 127.0.0.1 側の開始が遅いです。
準備ができ次第、ビルドを開始できているのが分かります。

func build07(cmds []*exec.Cmd) {
    outCh := make(chan string, 10000)
    done := make(chan struct{})

    go func() {
        for ch := range outCh {
            fmt.Print(ch)
        }
        close(done)
    }()

    l, _ := limichan.New(context.Background())
    w, _ := newLocalWorker()
    for i := 0; i < *threads; i++ {
        l.AddWorker(w)
    }
    go func() {
        w, _ := newWorker(`127.0.0.1`, 12345, *threads)

        // gRPC 接続に時間がかかるのを模擬するため1秒待つ
        time.Sleep(1 * time.Second)

        for i := 0; i < *threads; i++ {
            l.AddWorker(w)
        }
    }()

    oc := ochan.NewOchan(outCh, 100)
    for _, cmd := range cmds {
        cmd := cmd

        if cmd.Path != dummyCc {
            // コンパイラではない時は、直前までのコンパイルが終わるのを待つ
            oc.Wait()
        }

        j := &job{
            cmd:     cmd,
            ch:      oc.GetCh(),
            outFile: cmd.Args[2:3],
            depFile: cmd.Args[3:],
        }

        l.Do(j)
    }
    oc.Wait()
    l.Wait()
    close(outCh)
    <-done
}

まとめ

駆け足で分散ビルドまで紹介しました。
言葉が足りない部分がたくさんあるかと思いますが、雰囲気は伝わるかと思います。

実際に作ってみて発見があったのは以下です。

  • 最初に全ファイル転送する形で実施しても、分散の恩恵は得られる
  • streaming RPC でファイル送信を行うと、かなりメモリ消費が大きい (GB 単位)
  • []*exec.Cmd のようなものさえ作れれば、後は goroutine で適当に回せるので Go は本当に楽

Go は本当に楽でいいです。
そして、分散ビルドはとても楽しいので是非試してみてください。

ソースコードは以下にあります。

https://github.com/sago35/grpcbuild

おまけ : 分散処理を行う OSS

今回の内容は OSS でほぼ同じことができるかと思います。
が、私の境遇としてはコンパイラ等が Windows 縛りなので色々諦めている状況です。
実は Windows でも動くよ等の良い情報があったら教えてほしいです。

おまけ2 : 分散処理の可視化のやり方

今回の画像は Google Charts の Timelines を用いて作成しました。

始まりと終わりの時間を指定しつつグルーピングを指定すると後は良い感じに見せてくれます。
今回の用途で使う場合は 59秒 → 60秒というタイミングで0秒に戻る感じで表示されるので注意が必要です。

image.png

上の画像を生成するための html ソースは以下になります。
https://gist.github.com/sago35/792f50b4773c2e8c8ba6aea72e92ef50

これからのテストの話をしよう〜Goにおけるテストノウハウ〜

この記事は Go2 Advent Calendar 2018 の 25日目の記事です。
昨日は、 @sago35 さんの Go + gRPC でC言語プロジェクトのビルドを早くした話でした。

TL;DR

  • テストには、どんな種類があるの?
    • E2E / integration / unit test
    • コスト意識をもって書くことが大切
  • テストの種別ごとに、Goだったらこう書くかなというノウハウをまとめました

※自分は普段、developerとして働いておりQAに関する知識が少なかったり、goの経験も浅かったりするので、ツッコミ大歓迎です。

Motivation :bulb:

  • AgileTestingDays2018に参加してテストに関するノウハウを学び、知識を整理したかった1

  • Goにおけるテストであればどうか、テストコードを書きたかった

Level of Test :pencil:

(改めて整理する必要はないかもですが)テストには、下記のレベルがあります。いろいろな整理方法があるかもしれませんが、本投稿では下記の通り整理しています。

  • E2E test: 実際の挙動を実機で確認する(EndtoEnd test)。たとえばwebサービスであればブラウザでの挙動確認すること。本投稿では、ここには触れません。
  • Integrate test: 複数システム間を結合したテスト。例えばマイクロサービスであれば、複数サービスを結合したときに問題がないか確認すること。本投稿では、外部ミドルウェア(データベースなど)もここに含めています。
  • Unit test:関数単体のテスト。本投稿では、複数関数にまたがるテストでも、アプリ(ここではGo)だけで検証できるテストをここに含めています。

上記のように整理すると、「何をテストすべきか」「テストを書くことのコスト」を考えやすいと思っています。
今年話題になった記事(スピード感重視なのでテストは書かない。テストはなぜ開発を遅くするか)やそれにまつわる投稿でも触れられていますが、自分が参加したAgileTestingDays2018でも繰り返し述べられていたのは、コスト意識です。

たとえば、eBay at Berlinのエンジニアの方は、seleniumなどを用いたE2Eテストはあまり書いていないと言っていました。上記で整理したレベルが上に行くほど、一般的にコストが大きくなると思います。

Introduction of code :raised_hands:

さっそくですが、goにおけるテストについて、コードベースで説明していきます。
ここのソースから、下記の手順で手元で確認できます。

# setup
go get github.com/matsu0228/gotest

# mysqlの設定
#  - database接続を確認するためには、docker/docker-composeが必要です
cd $GOPATH/src/github.com/matsu0228/gotest/infla
docker-compose up -d

# hostsに追記
sudo sh -c 'echo "\n127.0.0.1       docker-mysql" >> /etc/hosts'

# create dabatase/table
go run init/main.go

アプリ自体の動作確認。ここでは2種類のアプリがあります(最低限の要素を詰め込んだだけのアプリで、特に意味はありません)

cd $GOPATH/src/github.com/matsu0228/gotest

# 税抜額を算出するアプリ
go run tips/main.go 

# dbに保存・dbから値取得、取得した値を計算するアプリ
#  手元でmysqlが動いている必要があります(`docker ps`で確認できます)
go run integrate/main.go 

各テストは、下記でを実行できます

# table driven test や setup/teardown の例
go test -v github.com/matsu0228/gotest/tips 

# mockをもちいたunit test の例
go test -v github.com/matsu0228/gotest/integrate

# buildタグによる、"unit testing"と"integrate testing"切り替えの例
go test -v github.com/matsu0228/gotest/integrate/repository
go test -v github.com/matsu0228/gotest/integrate/repository -tags=integration 

Knowledge about unit test :blue_book:

table driven testを用いた境界値チェック

テストにおいて大切なことは、カバレジでしょうか? panicを起こさずに動作することだけをテストすれば十分でしょうか?テストコードの効果を高めるためには、それらでは不十分です。

大切な概念の1つとして、境界値チェック(入力値/出力値のパターンを網羅しておくこと)が大切です。今回のアプリでは、税込額から、税抜額(本体価格)を算出する関数calcurateTaxExcludeAmount()がありますが、消費税で割ったときに、「割り切れる/切り上げる/切り下げる」場合がすべて正しいこと(閾値前後のチェック)を確認していると、効果が高いテストになると思います。

Goでは、このような場合にtable driven testが有用です。テストデータを後から追加・変更がしやすくなり、何をテストしているかも見通しが良くなります。また、サブテストとして実行することでテストが並行に実施され、データ量が多くなってもテスト時間が減らせる・どのテストケースで失敗したかがわかりやすくなるなどのメリットがあります。

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/tips/main_test.go#L33

// testCase として、割り切れる場合・切り上げる場合・切り下げる場合を用意する
    testCase := []struct {
        testName string
        input    int
        want     int
    }{
        {
            testName: "divided",
            input:    2160,
            want:     2000,
        },
省略
    }

    for _, tc := range testCase {
        // 【サブテスト】として実行
        t.Run(tc.testName, func(t *testing.T) {
            got := testTax.calcurateTaxExcludeAmount(tc.input)
            if got != tc.want {
                t.Fatalf("want %v, but %v:", tc.want, got)
            }
省略

setup/teardownにおける共通化

テストコードにおけるコストの一つとして、プロダクトコードと同様にメンテナンスコストがあります。プロダクトコードが大きく変わったりすると、テストコードの修正も必要になります。したがって、見通しの良さ・共通化されていることは、テストコードにおいても大切です。

Goでは、func Test**(t *testing.T) {〜〜}でテスト内容を記述できますが、ここにはテスト内容だけを記述し、テストのための事前準備などは別に用意することで見通しが良くなります。たとえば、setup()は、テスト用データベースへの接続、テスト用APIに接続するための認証設定など、複数のテストで利用するオブジェクトを作ったり、複数のテストで利用するデータを作成するのに適しており、teardown()はテスト用データを削除したりとテスト用データをきれいにするのに適しています。

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/tips/main_test.go#L12
// testing Mainについて : https://golang.org/pkg/testing/#hdr-Main

// TestMain is called first
func TestMain(m *testing.M) {
    setup()
    exitCode := m.Run()
    // teardown()

    os.Exit(exitCode)
}

func setup() {
    fmt.Println("called setup()")
    testTax = newTax()
}

mockを用いたunit test技法

外部環境に依存しない関数単体のテストをするために、mockを用いたテストが有用です。例えば、データベースから取得した値に対する処理や、利用しているAPIが500エラーを返した時のエラーハンドリングの正しさを確認するための手段として、外部環境をmockする方法があります。

この例では、データベースから取得した値に対する処理として、取得できた値を演算する関数のテストを書きました。
Goでは、外部環境をinterfaceとして実装することで、このようにmockテストができるようになります。

テストの話からはそれますが、このようにinterfaceを利用することで、ビジネスロジック(とあるデータを保存し、取得したあと処理をする)と、 技術的・具体的な実装を分離しておくことでメンテナンスしやすく、後から具体的な手法を変えることもできたり(RDBから取得していた部分を、キャッシュ参照したあとキャッシュがなければRDBを見にいく、など)と柔軟性が増します。

テスト対象の関数 : DBから取得したデータを足し合わせる関数

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/main.go#L21

// calcurate of keys data
func calcurate(repo Repository, keys []string) (int, error) {

    sum := 0

    // gather data
    var values []repository.SomeData
    for _, key := range keys {
        data, err := repo.Get(key)
        if err != nil {
            return 0, err
        }
        values = append(values, data...)
    }

    // calcurate of data
    for _, v := range values {
        i, err := strconv.Atoi(v.Body)
        if err != nil {
            return 0, err
        }
        log.Printf("[INFO] sum = sum:%v + i:%v", sum, i)
        sum = sum + i
    }
    return sum, nil
}

interfaceとして外部環境を定義しておくことがポイントです

// Repository is interface of datastore
type Repository interface {
    Get(title string) ([]repository.SomeData, error)
    Save(title, body string) error
}

テストコード側で、interfaceを満たすmockを作成しておくことで、unitテストが書かけます

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/main_test.go#L25

// repoMock is struct of Repository
type repoMock struct{}

func (r repoMock) Get(title string) ([]repository.SomeData, error) {
    // testing local rule
    ary := strings.Split(title, "_")
    if len(ary) == 0 {
        return []repository.SomeData{}, nil
    }
    return []repository.SomeData{
        {Body: ary[1]},
    }, nil
}

func (r repoMock) Save(title, body string) error {
    return nil
}

Knowledge about integrate test :books:

ビルドタグを用いた「unit test」と「integrate test」の切り替え

すぐにCI上に乗せることはできませんが、特定環境下(APIが叩ける、DB接続できる)で、integrate testができるようにするための方法です。

直接外部環境とのintegrate testをすることでmockを用いたテストでは想定していなかったケースに気づけたりしますし、最初から全ての箇所でmockとして実装するのはそれなりにコストがかかりますため、CIに乗っていなくてもこのようなテストも有用だと思います。

初期データ作成箇所

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/main_test.go#L24

func setup() {
    fmt.Println("called setup()")

    if integrateFlag { // setup when execute integrat test
        var err error
        //【注意】 あくまでサンプルなのでパスワード直書きしてますが、環境変数を使って設定するなど、セキュアな実装にしてください
        repo, err = NewDatabase("root", "mysql", "127.0.0.1", "3306", "todo", "?parseTime=true&loc=Japan")
        if err != nil {
            log.Fatal(err)
        }
    }
}

上記のように、ビルドタグ-tags=integrationがあるときのみに、外部DBへ接続するコードを呼ぶようにするため、フラグの値を下記のとおり定義します

// build tagについてのドキュメント: https://golang.org/pkg/go/build/#hdr-Build_Constraints

// タグあり: at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/integration.go
var integrateFlag = true

// タグ無し : at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/integration_false.go

// integrateFlag :build +integrate時のみtrueとする

var integrateFlag = false

同様に、外部環境が必要なテストは、ビルドタグ-tags=integrationありのテストファイルに分けて記述しておきます。たとえば、データベースに接続した値が取得できるかどうかのテストは下記のとおりです。

// at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/integrate/repository/database_integration_test.go

    want := "body_" + timestamp
    key := "title_" + timestamp
    err := repo.Save(key, want)
    if err != nil {
        t.Fatalf("cannot Save():%v", err)
    }

    data, err := repo.Get(key)
    if err != nil {
        t.Error(err)
    }
    if len(data) == 0 {
        t.Fatalf("none data of key:%v", key)
    }

省略

        if d.Body == want {
            isExitst = true
        }
    }
    if !isExitst {
        t.Fatalf("want %v, but %v:", want, got)
    }

circle.ciを用いた自動化

circle.ciのドキュメントどおりですが、ポイントは下記です。

最低限の設定(抜粋)

# at https://github.com/matsu0228/gotest/blob/0f399f729c979b27e912dd915b7442b131b0328d/.circleci/config.yml

jobs:
  build:
    docker:
      # go言語の環境をdocker imageから指定する
      - image: circleci/golang:1.9

    # ディレクトリの指定
    working_directory: /go/src/github.com/matsu0228/gotest

    # Goの環境設定、コード取得
    steps:
      - run: echo 'export PATH=${GOPATH}/bin/:${PATH}' >> $BASH_ENV
      - checkout

      # テストコード実行
      - run: go test -v github.com/matsu0228/gotest/tips
      - run: go test -v github.com/matsu0228/gotest/integrate
      - run: go test -v github.com/matsu0228/gotest/integrate/repository


      # 静的解析
      - run: go get golang.org/x/lint/golint
      - run: go get github.com/haya14busa/goverage
      - run: golint ./...
      - run: go vet ./...

外部環境(ここではmysql)を使ったテスト(抜粋)

    docker:
      # mysql のdocker imageを取得&設定
      # 【注意】 あくまでサンプルなのでパスワード直書きしてますが、環境変数を使って設定するなど、セキュアな実装にしてください
      - image: circleci/mysql:5.7
        environment:
          MYSQL_ROOT_PASSWORD: mysql
          # MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE: todo
        command: [--character-set-server=utf8, --collation-server=utf8_general_ci]

      # mysqlのdockerが立ち上がるまで待つ
      # これがなくてテスト失敗になってハマりました。。
      - run:
          name: Waiting for Database to be ready
          command: |
            for i in `seq 1 20`;
            do
              nc -z 127.0.0.1 3306 && echo Success && exit 0
              echo -n .
              sleep 1
            done
            echo Failed waiting for mysql && exit 1

      # 必要なテーブルの作成
      # TODO:これはテストコード内でやるべきかも?
      - run:  go run infla/init/main.go

      # mysqlを利用したテストを実行
      - run: go test -v -tags=integration github.com/matsu0228/gotest/integrate/repository

Cost performance of test code :moneybag:

(補足:以下はテストコードにする(=テスト自動化するかどうか)の話で、リリース前にテストをしないという話ではありません)

プロダクトのフェーズや、開発チームの状況・機能の性質によって、どこまでテストコードを書くか変わると思います。たとえば、1ヶ月先には使わなくなる機能であれば、その機能のテストコードの優先度は低く、人手でテストすればよいかもしれません。

【本投稿のまとめ】効果が高いテストコードとは、「低コスト」「高効果」なものです。次の点を考慮し、今の状況に応じて、どこまでテストするかを考えるとよいでしょう。

  • 低コストにする

    • 一般的に、テストのレベルが高次元となるほど、そのテストコードのコストは高くなります。つまり、「Unit test多め、E2Eテストは少なめ」にした方がコストは低くできます。
    • setup/taerdownなどのテクニックを使って、テストコードをメンテナンスしやすく(テスト内容とテストデータ設定を分離・DRYの原則に従う)しておくことで、将来的な改修コストを減らせます。
    • 外部環境(DBなど)をテストコード用に用意するよりも、ビルドタグを使ったintegrate testをひとまず書いておくといった手法も低コストでできる手段のひとつだと思います。
  • 高効果にする

    • Table driven testのように値のチェック、エラーハンドリングのチェックまですると、バグを見つけやすくなりテストの効果が高まります。
    • CIに乗せることで、人手でテストコードを実行しなくてもリリースプロセスで必ずテストが実行されるようにするとよいでしょう。
    • 重要な機能(そのサービスにコンバージョンに関わるような)や、3回以上人手でテストをする(リグレッションテスト含む)ような機能に対してテストを書くとのがテストコードがある意義が高まります。
Browsing Latest Articles All 25 Live