前回の記事で、社内ISUCONをしたという話を書きました。
patorash.hatenablog.com
そのときにベンチマーカーを作るのに、isucandarを使ったので、あとでまた記事を書く!と宣言していたのですが、なかなか書けず…。でも忘れないうちに書く!
isucandarとは?
isucandarとは、ISUCON用のベンチマーカーフレームワークです。
github.com
@catatsuy さんが作ったISUCON9のベンチマーカーに感銘を受けた @rosylilly さんが作成したそうです。
@catatsuyさんがisucandarについて書いたZennの記事があります。
zenn.dev
私も実装前はこの記事読みながらも「わからんな?😇」と思ってましたが、今読むとなんとなくわかるくらいにはなりました(なんとなく、かい!)
私なりの雑な説明
以下、私なりの雑な説明をしていきます。(GitHubのREADMEを読んだ方がいいと思われるが…)
agent
実際にリクエストを扱うパッケージ。ブラウザのような挙動を行う。セッションも使えるし、アクセス後に取得したレスポンスを解析してまた何かアクセスさせたりもできる。
failure
エラーを扱うパッケージ。独自のエラーを扱ったりできる。エラー数を数えたりもできるので、例えば100件以上エラーになったら失格扱いや減点、とかの処理に使える。
score
スコア集計のためのパッケージ。複数の処理用にタグを定義しておく。タグ毎に得点を付けられるので、難しい処理をしたら10点、簡単な処理をしたら1点などの設定が可能。
worker
同じ処理を複数回実行したり、並列数を抑えながら無限に実行したりする処理の制御を提供する。
基本的には、このworkerの中でAgentを動かして色々処理するのを繰り返させる。
parallel
同時実行数を制御しつつ、複数のジョブを実行させる処理を提供する。
私的には使いどころがわからず、使わなかった(が、workerで並列数を指定しているので、自動的に使ってたと思う)。
とまぁ、ここまでは、isucandarのREADMEに書いてあります。しかし、他にも便利なやつがあります。
READMEに書いてなかったその他の使い方
ベンチマーカーの実装の参考にしようと、ISUCON10-finalのベンチマーカー実装を読んでいたら、上記以外の構造体が使われていたので、それを読み解きながら作りました。
github.com
先に、私が実装したやつの簡易版を出します。
package main
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
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{}
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秒にしました。(本当は定数で定義してますが、わかりやすくするために直書きに)
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{}
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
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を使いました。
github.com
以下が、その実装例です。
type Action struct {
Agent *agent.Agent
Ctx context.Context
}
var (
ErrAuthenticityToken failure.StringCode = "authenticity-token-not-found"
)
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が見つかりませんでした。"))
}
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をすることができました。あんまりコード的にコントリビュートできるレベルではないので、こういう、使ってみた系の記事を公開することで、貢献できたらなと思います。もちろん、ツッコミもありそうなので、おかしなところがあったらご指摘お願いします!😀