前回の記事で、社内ISUCONをしたという話を書きました。
そのときにベンチマーカーを作るのに、isucandarを使ったので、あとでまた記事を書く!と宣言していたのですが、なかなか書けず…。でも忘れないうちに書く!
isucandarとは?
isucandarとは、ISUCON用のベンチマーカーフレームワークです。
@catatsuy さんが作ったISUCON9のベンチマーカーに感銘を受けた @rosylilly さんが作成したそうです。
@catatsuyさんがisucandarについて書いたZennの記事があります。
私も実装前はこの記事読みながらも「わからんな?😇」と思ってましたが、今読むとなんとなくわかるくらいにはなりました(なんとなく、かい!)
私なりの雑な説明
以下、私なりの雑な説明をしていきます。(GitHubのREADMEを読んだ方がいいと思われるが…)
agent
実際にリクエストを扱うパッケージ。ブラウザのような挙動を行う。セッションも使えるし、アクセス後に取得したレスポンスを解析してまた何かアクセスさせたりもできる。
failure
エラーを扱うパッケージ。独自のエラーを扱ったりできる。エラー数を数えたりもできるので、例えば100件以上エラーになったら失格扱いや減点、とかの処理に使える。
score
スコア集計のためのパッケージ。複数の処理用にタグを定義しておく。タグ毎に得点を付けられるので、難しい処理をしたら10点、簡単な処理をしたら1点などの設定が可能。
worker
同じ処理を複数回実行したり、並列数を抑えながら無限に実行したりする処理の制御を提供する。
基本的には、このworkerの中でAgentを動かして色々処理するのを繰り返させる。
parallel
同時実行数を制御しつつ、複数のジョブを実行させる処理を提供する。
私的には使いどころがわからず、使わなかった(が、workerで並列数を指定しているので、自動的に使ってたと思う)。
とまぁ、ここまでは、isucandarのREADMEに書いてあります。しかし、他にも便利なやつがあります。
READMEに書いてなかったその他の使い方
ベンチマーカーの実装の参考にしようと、ISUCON10-finalのベンチマーカー実装を読んでいたら、上記以外の構造体が使われていたので、それを読み解きながら作りました。
先に、私が実装したやつの簡易版を出します。
package main // importは省略… func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 実行時間は60秒 b, err := isucandar.NewBenchmark(isucandar.WithLoadTimeout(60 * time.Second)) if err != nil { panic(err) } b.OnError(func(err error, step *isucandar.BenchmarkStep) { // エラーを検知した場合の処理を行う critical, _, _ := checkError(err) if critical { // 実行中のベンチマークを止める step.Cancel() } }) // 私が作ったシナリオの構造体 s := &scenario.Scenario{} // targetAddressは引数で指定する。 s.BaseURL = fmt.Sprintf("http://%s/", targetAddress) // シナリオをベンチマーカーにセット b.AddScenario(s) // シナリオを実行する result := b.Start(ctx) result.Score.Set(score.ScoreTag(scenario.SuccessGet), 5) result.Score.Set(score.ScoreTag(scenario.SuccessPost), 10) result.Score.Set(score.ScoreTag(scenario.SuccessAsset), 1) fmt.Printf("Score: %d\n", result.Score.Total()) }
これを踏まえて、書いていきます。
benchmark.go
isucandar/benchmark.go at master · isucon/isucandar · GitHub
ベンチマーカーの構造体が定義してあります。
ベンチマークオブジェクトを作る
NewBenchmark
メソッドで作ります。タイムアウト時間を指定できるので60秒にしました。(本当は定数で定義してますが、わかりやすくするために直書きに)
// 実行時間は60秒 b, err := isucandar.NewBenchmark(isucandar.WithLoadTimeout(60 * time.Second)) if err != nil { panic(err) }
エラー制御を定義する
OnError
メソッドでエラーが起きた場合のコールバックを定義しておきます。
ここで、致命的なエラーとかが起きたらCancelで止めます。BenchmarkStep
の説明は後で…。
checkError
メソッドはエラーの種類を検証して返すメソッドですが、ここは適当に(ISUCON10のをそのまま採用した)。
b.OnError(func(err error, step *isucandar.BenchmarkStep) { // エラーを検知した場合の処理を行う critical, _, _ := checkError(err) if critical { // 実行中のベンチマークを止める step.Cancel() } })
シナリオを設定する
シナリオの構造体はagent, worker, failure, scoreを使って作ったシナリオです。これも後ほど。
ベンチマークオブジェクトに対して、AddScenario
メソッドでシナリオを登録します。
複数登録できそうでもあるんですが、1つのほうがよさそう。
// 私が作ったシナリオの構造体 s := &scenario.Scenario{} // targetAddressは引数で指定する。 s.BaseURL = fmt.Sprintf("http://%s/", targetAddress) // シナリオをベンチマーカーにセット b.AddScenario(s)
ベンチマークを実行する
実際にベンチマークを実行します。戻り値は、BenchmarkResult
オブジェクトです。
// シナリオを実行する
result := b.Start(ctx)
スコア集計する
最後に、BenchmarkResult
オブジェクトに設定されているScore
オブジェクトに得点ルールを書きます。
得点ルールは先に定義しなくても、最後に定義すればいいです。
最後に、スコアの合計を出力しました。
result.Score.Set(score.ScoreTag(scenario.SuccessGet), 5) result.Score.Set(score.ScoreTag(scenario.SuccessPost), 10) result.Score.Set(score.ScoreTag(scenario.SuccessAsset), 1) fmt.Printf("Score: %d\n", result.Score.Total())
benchmark_result.go
isucandar/benchmark_result.go at master · isucon/isucandar · GitHub
ベンチマーク結果オブジェクトは、scoreとfailureのオブジェクトを持ってます。
なので、最後にスコア集計ができるわけですが、このオブジェクトは BenchmarkStep
に入っています。
benchmark_step.go
isucandar/benchmark_step.go at master · isucon/isucandar · GitHub
BenchmarkStep
構造体はBenchmarkResult
を持っているので、BenchmarkStepのAddScore
メソッドなどを呼び出すと、BenchmarkResultにあるScoreオブジェクトを介してスコアが登録できます。AddError
メソッドも同様。
benchmark_scenario.go
isucandar/benchmark_scenario.go at master · isucon/isucandar · GitHub
インターフェースが定義されています。
- PrepareScenario
- LoadScenario
- ValidationScenario
これらのどれか1つでも定義されているものが、シナリオとして認識されます。なので、これらのインターフェースを含むシナリオを定義します。
isucandarのシナリオの作り方
どれか1つでもあればいいので、LoadScenario
を持つシナリオを構造体で定義しました。
package scenario // importは省略 type Scenario struct { BaseURL string isucandar.LoadScenario }
LoadScenario
を持たせているので、これにLoad
メソッドを定義します。
ざっくりと、ログインしていないユーザー用のシナリオと、ログインしているユーザー用のシナリオを並列に実行させました。
func (s *Scenario) Load(ctx context.Context, step *isucandar.BenchmarkStep) error { wg := sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() // ログインしていないユーザー用のシナリオを実行する if err := s.loadNoSignInUserBenchmark(ctx, step); err != nil { step.AddError(err) } }() wg.Add(1) go func() { defer wg.Done() // ログインするユーザー用のシナリオを実行する if err := s.loadSignInUserBenchmark(ctx, step); err != nil { step.AddError(err) } }() wg.Wait() return nil }
このシナリオの内容は普通に実装の話になってしまうので、省略します。やってることは、workerで無限にアクセスさせるようなもので、成功したらスコアを追加、失敗したらエラーに追加をするくらいです。
トップページにアクセスして成功したら得点GET!を書くとしたら、こんな感じだろうか?ベンチマークで指定された時間だけ6並列で、延々とトップページにアクセスさせます。時間を迎えたら自動的に終わります。
func (s *Scenario) loadNoSignInUserBenchmark(ctx context.Context, step *isucandar.BenchmarkStep) error { w, err := worker.NewWorker(func(ctx context.Context, _ int) { for ctx.Err() == nil { agent, err := agent.NewAgent(WithBaseURL(s.BaseURL)) if err != nil { step.AddError(err) continue } req, err := agent.GET("/") if err != nil { step.AddError(err) continue } res, err := agent.Do(ctx, req) if err != nil { step.AddError(err) continue } defer res.Body.Close() if res.StatusCode == 200 { step.AddScore(score.ScoreTag("GET")) } } }, worker.WithInfinityLoop(), worker.WithMaxParallelism(6)) if err != { return err } w.Process(ctx) return nil }
ざっと、こんなところでしょうか。今はこれが精一杯😇
Railsアプリに対してPOSTするために工夫したこと
Railsアプリに対してPOSTしようとすると、CSRFトークンが必要になります。アクセスしたページを解析してCSRFトークンを取得するのに、goqueryを使いました。
以下が、その実装例です。
// ログイン状態を保持したいので、AgentとContextを持つ構造体を定義 type Action struct { Agent *agent.Agent Ctx context.Context } var ( ErrAuthenticityToken failure.StringCode = "authenticity-token-not-found" ) // goqueryを使ってCSRFトークンを取得する func getAuthenticityToken(responseBody io.ReadCloser, index int) (string, error) { doc, err := goquery.NewDocumentFromReader(responseBody) if err != nil { return "", err } selection := doc.Find("input[name='authenticity_token']").Eq(index) authenticityToken, exists := selection.Attr("value") if exists == false { return "", failure.NewError(ErrAuthenticityToken, fmt.Errorf("authenticity_tokenが見つかりませんでした。")) } // fmt.Println("authenticityToken = " + authenticityToken) return authenticityToken, nil } func (a *Action) SignIn(email, password string) (*http.Response, error) { req, err := a.Agent.GET("/users/sign_in") if err != nil { return nil, err } res, err := a.Agent.Do(a.Ctx, req) if err != nil { return nil, err } defer res.Body.Close() // ログイン情報入力時間 time.Sleep(2 * time.Second) authenticityToken, err := getAuthenticityToken(res.Body, 0) if err != nil { return nil, err } // ログインする values := url.Values{} values.Set("user[email]", email) values.Set("user[password]", password) values.Set("authenticity_token", authenticityToken) req, err := a.Agent.POST("/users/sign_in", strings.NewReader(values.Encode())) if err != nil { return nil, err } res, err := a.Agent.Do(a.Ctx, req) if err != nil { return nil, err } return res, nil }
以上になります。
まとめ
初めてのベンチマーカー作り、初めてのGO言語、初めての社内ISUCON開催と、初めて尽くしで準備も大変でしたが、isucandarのおかげで、ワイワイと社内ISUCONをすることができました。あんまりコード的にコントリビュートできるレベルではないので、こういう、使ってみた系の記事を公開することで、貢献できたらなと思います。もちろん、ツッコミもありそうなので、おかしなところがあったらご指摘お願いします!😀