Go
golang
Go3Day 21

A Tour of Goの次にISUCONを書いてみた話

Intro

goのチュートリアルとしてはA Tour of Goが鉄板だが、その次に何をするかというのは悩ましい問題である。
この記事ではgo学習のためにA Tour of Goの次の一手としてISUCONを実装して得た学びなどについて紹介する。

モチベーション

まず学習のためにある程度の規模のサービスを書きたかったというのがある。元々作りたいものがあればそれを作っても良いが、今回はどちらかと言うと技術的関心の比重が大きかったので何を作るか考えるプロセスを省きたかった。そこで以前社内ISUCONを開催したことがあったのでこれを移植してみた。

ISUCONを題材に選ぶメリットとして以下がある。

  • 仕様が固まっている
    • 細かい点も参考実装があるので迷うことが少ない
  • Webapp, Benchmarker, Portalまで書くとそこそこの規模になる
  • ISUCONの性質上、ある程度の負荷に耐えられる作りが求められる

ここで以前同じようなものを書いた経験があるというのは大きい気もするが、そうでなくとも過去開催されたISUCONをベースに移植なりしていけば仕様については悩まずに進められると思う。

実装したもの

Webapp, Benchmarker, Portalを一通り実装した。リポジトリはこちら。またおまけでpubsubサーバも実装してベンチマークキューとして使っている。

全体の構成は以下の感じ

architecture

工夫点

工夫というよりは当たり前のテクニックな気もするが、実際にこういう場面で使うと便利だなという体験があったのでいくつか紹介してみる。

interfaceまわり

ベンチマークタスクの抽象化

ベンチマークの種類として以下のものがあるとする。

  • 初期化タスク
  • 一通り動作確認するタスク
  • 動作確認はほどほどに負荷をかけまくるタスク

このとき以下のinterfaceを用意して各タスクごとに実装してやることでスッキリ書けた。

type Task interface {
  Task(ctx Ctx, driver *Driver) *Driver
  FinishHook(result Result) Result
}

初期化タスクの例

func (t *InitTask) FinishHook(r Result) Result {
  if len(r.Violations) > 0 {
    r.Fail()
  }
  return r
}

func (t *InitTask) Task(ctx Ctx, d *Driver) *Driver {
  d.getAndCheck(nil, "/initialize", "INITIALIZE", func(c *Checker) {
    c.isStatusCode(200)
    c.respondUntil(ctx.workerRunningTime)
  })
  return d
}

ストレージの抽象化

おまけとして書いたpubsubだが、メッセージを保存するストレージを自由に選べるようにしたかったので、以下のようにストレージに期待する動作を定義し、各ストレージごとに実装した。
このパターンはテストでモックを使う場合や、レイヤードアーキテクチャなどにおけるRepository実装としてもよく出てくる気がする。

type Datastore interface {
  Set(key, value interface{}) error
  Get(key interface{}) (interface{}, error)
  Delete(key interface{}) error
  Dump() (map[interface{}]interface{}, error)
}

以下はRedis実装の例

func (r *Redis) Set(key, value interface{}) error {
  conn := r.Pool.Get()
  defer conn.Close()

  _, err := conn.Do("SET", key, value)
  return err
}

func (r *Redis) Get(key interface{}) (interface{}, error) {
  conn := r.Pool.Get()
  defer conn.Close()

  v, err := redis.Bytes(conn.Do("GET", key))
  if err != nil {
    return nil, errors.Wrapf(ErrNotFoundEntry, fmt.Sprintf("detail %v", err))
  }
  return v, nil
}

func (r *Redis) Delete(key interface{}) error {
  conn := r.Pool.Get()
  defer conn.Close()

  _, err := conn.Do("DEL", key)
  return err
}

func (r *Redis) Dump() (map[interface{}]interface{}, error) {
  return r.DumpPrefix("")
}

ストレージの抽象化その2

こちらもおまけで書いたテスト時に使うfixture管理ライブラリだが、テストに使用する(fixtureをロードする)ストレージを自由に選べるよう実装した。
interfaceを実装したパッケージをimportすることで対応したストレージを使えるようにして、必要なものだけ使う形にしている。これは標準パッケージのdatabase/sql実装を参考にした。

fixture.go
type Driver interface {
  TrimComment(sql string) string
  EscapeKeyword(keyword string) string
  EscapeValue(value string) string
  ExecSQL(tx *sql.Tx, sql string) error
}

func Register(name string, driver Driver) {
  driversMu.Lock()
  defer driversMu.Unlock()
  if _, dup := drivers[name]; driver == nil || dup {
    panic(ErrFailRegisterDriver)
  }
  drivers[name] = driver
}

func NewFixture(db *sql.DB, driverName string) (*Fixture, error) {
  driversMu.RLock()
  d, ok := drivers[driverName]
  driversMu.RUnlock()
  if !ok {
    return nil, errors.Wrapf(ErrNotFoundDriver, "driver name %s", driverName)
  }
  return &Fixture{db: db, driver: d}, nil
}

MySQL実装の例

mysql/driver.go
// Driverの各メソッドを実装しておく

func init() {
  fixture.Register("mysql", &FixtureDriver{})
}

実際に使う場合はこのようになる

import (
  "database/sql"

  _ "github.com/go-sql-driver/mysql"
  "github.com/takashabe/go-fixture"
  _ "github.com/takashabe/go-fixture/mysql"
)

func main() {
  db, err := sql.Open("mysql", "fixture@/db_fixture")
  if err != nil {
    panic(err.Error())
  }

  f := fixture.NewFixture(db, "mysql")
  if err := f.Load("fixture/setup.yaml"); err != nil {
    panic(err.Error())
  }
}

goroutineまわり

Worker

最もオーソドックスなgoroutineの使い方だと思う。ベンチマークを並列処理するために使用している。

func (w *Worker) run() *Result {
  allResult := newResult()
  dones := make(chan Result, len(w.tasks))
  for _, t := range w.tasks {
    go func(task Task) {
      driver := &Driver{
        result: newResult(),
        ctx:    w.ctx,
      }
      task.Task(w.ctx, driver)
      r := task.FinishHook(*driver.result)
      dones <- r
    }(t)
  }
  for i := 0; i < len(w.tasks); i++ {
    r := <-dones
    allResult.Merge(r)
  }
  return allResult
}

ちなみにここでTasksには前述のベンチマークタスクのinterfaceで紹介したTaskが詰め込まれている。ベンチマークの順番と並列数(と実行時間)を定義しておき、WorkOrderごとに順次実行していくイメージ。

order := []*WorkOrder{
  {30 * time.Second, []Task{&InitTask{}}},
  {30 * time.Second, []Task{&BootstrapTask{}}},
  {60 * time.Second, []Task{&LoadTask{}, &LoadTask{}, &LoadCheckerTask{}}},
}

Polling

Agentでキューをポーリングしてベンチマークキューを監視するために使用した。
ここではgoroutineを作った後にすぐブロックしているので並列感はあまりないが、context経由でキャンセル可能にする点とgoroutineからエラーを受け取るためにstructを定義する方法はよく見るパターンだと思う。

func (a *Agent) Polling(ctx context.Context) (*client.Message, error) {
  type receiveMessage struct {
    message *client.Message
    err     error
  }

  rmCh := make(chan receiveMessage)
  go func(ch chan receiveMessage) {
    sub := a.pubsub.Subscription(a.pullServer)
    var (
      rm       receiveMessage
      isFinish bool
    )
    for {
      err := sub.Receive(ctx, func(ctx context.Context, msg *client.Message) {
        rm.message = msg
        ch <- rm
        isFinish = true
      })
      if err != nil && err != client.ErrNotFoundMessage {
        rm.err = err
        ch <- rm
        isFinish = true
      }
      if isFinish {
        return
      }
      time.Sleep(a.interval)
    }
  }(rmCh)

  select {
  case <-ctx.Done():
    return nil, ctx.Err()
  case rm := <-rmCh:
    return rm.message, rm.err
  }
}

得られたもの

goはWebアプリケーションはもちろん、CLIツールやミドルウェアにもよく使われる言語なので、それらのものを一通り実装する体験が出来たのは良かった。
そして普段仕事などでWebアプリケーションを主に書いてきたという人間にとって、それ以外の領域に対する心理的ハードルが下がったというのが大きいと感じている。

まとめ

go学習のためにA Tour of Goの次にISUCONのWebapp, Benchmarker, Portalを一通り実装した。

仕様を考えることなく実装を進められ、そこそこの規模かつWebやCLIなどそれぞれ異なるインタフェースのものを書くことになるので学びが多かった。言語に関わらず、チュートリアルの次の学びの題材として良いと思う。

もしこれを読んでやってみたいという方がいれば、まずは公式リポジトリから過去ISUCONのWebapp、もしくはBenchmarkerを移植して残りを実装という順序がオススメ。ベンチマークの通る既存実装と入れ替えながら実装することで効率よく進めることが出来ると思う。