この記事の内容
- 3つのエンドポイントからそれぞれポケモンの日本語名取得、画像取得、データ取得している処理をゴルーチンで並行処理で行うようにする
- Goの標準のテストパッケージのベンチマークテストを行い、並行処理実装前と比較を行う
並行処理を行う前のソースコード
// GetPokeDataHandler ポケモンのデータを取得する。
func GetPokeDataHandler(w http.ResponseWriter, req *http.Request) {
var err error
var pokeParams models.PokeParams
var pokeData models.PokeData
//クエリパラメータからレベル、努力値、個体値を取得
query := req.URL.Query()
keys := []string{"lv", "ef", "in"}
pokeParams, err = GetQueryParams(query, keys)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
//パスパラメータを元にポケモンのデータを取得
vars := mux.Vars(req)
id := vars["id"]
//ポケモンの画像を取得
pokeEncData, err := services.GetPokeImageService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
//ポケモンの日本語名を取得
pokeName, err := services.GetPokeNameService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
//ポケモンのステータスを取得
pokeData, err = services.GetPokeDataService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pokeData.EncImg = pokeEncData
pokeData.Name = pokeName
// レベル、努力値、個体値を元にステータスを計算
services.CalPokeStat(&pokeData, pokeParams)
//レスポンスをjson形式で返す
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pokeData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
現在1つのポケモンのデータを返すGetPokeDataHandlerは3つの異なるエンドポイントにリクエストを送り、取得したデータを1つの構造体にまとめ、レスポンスとして返しております。
//ポケモンの画像を取得
pokeEncData, err := services.GetPokeImageService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
//ポケモンの日本語名を取得
pokeName, err := services.GetPokeNameService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
//ポケモンのステータスを取得
pokeData, err = services.GetPokeDataService(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
これらをゴルーチンを使用して、並行処理で行えるようにしていきます。
現在の処理速度をベンチマークで計測
並行処理にすることで本当に処理速度が速くなるのか、検証するため、現在の処理速度をベンチマークで計測します。
Goにはベンチマークをとるための仕組みが標準のtestingパッケージに備わっているのでそれを使用して行います。
handlerディレクトリ配下にhandler_test.goを作成します。
package handlers_test
import (
"github.com/rikuya98/go-poke-data-api/handlers"
"net/http"
"net/http/httptest"
"testing"
)
func TestMain(m *testing.M) {
m.Run()
}
func BenchmarkGetPokeDataHandler(b *testing.B) {
reqURL := "/pokemon/1?lv=50&ef=252&in=31"
req := httptest.NewRequest(http.MethodGet, reqURL, nil)
w := httptest.NewRecorder()
b.ResetTimer()
for i := 0; i < b.N; i++ {
handlers.GetPokeDataHandler(w, req)
}
}
ベンチマーク用のテスト関数は
func BenchmarkXxx(*testing.B)
という形になります。
Xxx の部分は任意の関数名を入れます。
テスト関数の中でどのようにベンチマークを取るかというと
「処理時間を測りたい関数・メソッドを複数回実行し、その平均を求める」という方法で行います。
今回はGetPokeDataHandlerを対象にしたいので、それをFor分の中で実施します。
実行回数はtesting.B 構造体が持つb.Nによって自動的に決定されます。
ハンドラーのメソッドなので引数となるリクエストとレスポンスを用意する必要があります。
本来は、リクエストが来た際によしなにリクエストとレスポンスの構造体を作成しハンドラーに渡してくれるのですが、今回はテストなので自前で用意する必要があります。
それらはhttptestパッケージが用意してくれています。
NewRequest関数の定義
func NewRequest(method string, target string, body io.Reader) *http.Request
リクエストの構造体はNewRequest関数で作成できます。
第1引数にHTTPメソッド、第2引数に対象となるパス、第3引数がボディです。
戻り値としてRequest構造体を返します。
今回はリクエストボディはないのでnilにします。
req := httptest.NewRequest(http.MethodGet, reqURL, nil)
レスポンス構造体はNewRecorder関数で作成できます。
w := httptest.NewRecorder()
これで準備ができました。
テストを実施するにはbenchオプションをつけます
テストファイルがあるディレクトリを指定して実行
go test --bench .
実行結果
go test -bench .
goos: darwin
goarch: arm64
pkg: github.com/rikuya98/go-poke-data-api/handlers
BenchmarkGetPokeDataHandler-8 13 1562190291 ns/op
PASS
ok github.com/rikuya98/go-poke-data-api/handlers 1.998s
BenchmarkGetPokeDataHandler-8 13 1562190291 ns/op
13回関数が実行され1回の実行時間が1562190291 nsだったことがわかります。
秒に直すと約1.562秒です。
致命的な遅さですね笑
ゴルーチンを使用して並行処理を実装
ということでゴルーチンを使用するようにしたのがこちらになります。
// GetPokeDataHandler ポケモンのデータを取得する。
func GetPokeDataHandler(w http.ResponseWriter, req *http.Request) {
var err error
var getImgErr error
var getDataErr error
var getNameErr error
var pokeParams models.PokeParams
var pokeData models.PokeData
var wg sync.WaitGroup
//クエリパラメータからレベル、努力値、個体値を取得
query := req.URL.Query()
keys := []string{"lv", "ef", "in"}
pokeParams, err = GetQueryParams(query, keys)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
//パスパラメータを元にポケモンのデータを取得
vars := mux.Vars(req)
id := vars["id"]
wg.Add(3)
//ポケモンの画像を取得
var pokeEncData string
go func() {
defer wg.Done()
pokeEncData, getImgErr = services.GetPokeImageService(id)
}()
var pokeName string
go func() {
defer wg.Done()
pokeName, getNameErr = services.GetPokeNameService(id)
}()
go func() {
defer wg.Done()
pokeData, getDataErr = services.GetPokeDataService(id)
}()
wg.Wait()
if getNameErr != nil {
http.Error(w, getNameErr.Error(), http.StatusInternalServerError)
return
}
if getImgErr != nil {
http.Error(w, getImgErr.Error(), http.StatusInternalServerError)
return
}
if getDataErr != nil {
http.Error(w, getDataErr.Error(), http.StatusInternalServerError)
return
}
pokeData.EncImg = pokeEncData
pokeData.Name = pokeName
// レベル、努力値、個体値を元にステータスを計算
services.CalPokeStat(&pokeData, pokeParams)
//レスポンスをjson形式で返す
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pokeData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
goキーワードをつけ、新しいゴルーチンを立てる
ゴルーチンを用意するにはgo文が必要になります。
go文の後に記述する関数は戻り値が無しでないといけないという制約があります。
そのため無名関数を定義し、その中で実行させるようにすることで、中のGetPokeDatasServiceを別のゴルーチンで実行できるようにしました。
go func() {
defer wg.Done()
pokeData, getDataErr = services.GetPokeDataService(id)
}()
並行処理のロックについて
本来、このような並行処理を行う時は、競合が起きないように値をロックする必要があります。
syncパッケージのMutexを使う必要があるのです。
ロックのイメージ
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock() //実行前、ロック開始
pokeData, getDataErr = services.GetPokeDataService(id)
mu.Unlock() //実行後、ロック解除
}()
しかし今回はそれぞれ別のAPIを叩き、結果を取得するだけのため、競合するものはありません。
よってロックは行っておりません。
並行処理の待ち合わせ
並行処理にはもう1つ意識すべきことがあります。
それが待ち合わせです。
Goのプログラムは「メインゴルーチン」が終了するとプログラムそのものがその場で終了するという特性を持っています。
今回APIを叩くため3つのゴルーチンを立てましたが、その処理を終えるよりも速く、メインゴルーチンの処理(つまりGetPokeDataHandlerの処理)が終了すると他の日本語名を取得するゴルーチンの処理が終わっていなくても終了してしまいます。
それを避けるために待ち合わせ処理を行う必要があります。
sync.WaitGroupによる待ち合わせ
待ち合わせ処理にはsyncパッケージのWaitGroupを使用します。
WaitGroupの以下の3つのメソッドを使用して、実現することができます
wg.Add([任意の数値])
wg.Done()
wg.Wait()
WaitGroupは内部にカウンタを持っています。
宣言した時は0になります
var wg sync.WaitGroup //この時は0
カウンタを追加するにはaddメソッドを使います。
wg.Add([任意の数値])
引数に渡した数値分カウンタを増やします。
カウンタを減らすにはDoneメソッドを使います。
wg.Done()
Done()メソッドを実行するとカウンタが-1されます。
そしてカウンタが0になるまで待機させる機能を持つのがWaitメソッドです。
wg.Wait()
Waitメソッドは他のゴルーチンによってカウンタの値が0になるまでその場で待機させます。
この仕組みを使って、他のゴルーチンの処理が終わるまでメインゴルーチンを待機させることができます。
今回は3つのゴルーチンを立てたので
Addではカウンタを3つ増やし
それぞれのゴルーチンの処理を終えた時にDone()を実行。
全てのゴルーチンが終わるまでWaitで待機させました。
wg.Add(3)
//ポケモンの画像を取得
var pokeEncData string
go func() {
defer wg.Done()
pokeEncData, getImgErr = services.GetPokeImageService(id)
}()
var pokeName string
go func() {
defer wg.Done()
pokeName, getNameErr = services.GetPokeNameService(id)
}()
go func() {
defer wg.Done()
pokeData, getDataErr = services.GetPokeDataService(id)
}()
wg.Wait()
エラー処理はそれぞれ別のエラー変数に格納し、全て終えてから中身を見て処理を行うようにしましたが、あまりいけてない実装かもしれません。
本来ならエラーチャネルなど利用して行う方が良いと思うのでまた学んでいきたいです。
if getNameErr != nil {
http.Error(w, getNameErr.Error(), http.StatusInternalServerError)
return
}
if getImgErr != nil {
http.Error(w, getImgErr.Error(), http.StatusInternalServerError)
return
}
if getDataErr != nil {
http.Error(w, getDataErr.Error(), http.StatusInternalServerError)
return
}
並行処理実装後、再度ベンチマークの実行
go test -bench .
goos: darwin
goarch: arm64
pkg: github.com/rikuya98/go-poke-data-api/handlers
BenchmarkGetPokeDataHandler-8 66 19091054 ns/op
PASS
ok github.com/rikuya98/go-poke-data-api/handlers 1.879s
以前より実行回数は多いですが
19091054 ns
約0.0191秒
と劇的に早くなりました!
並行処理前との比較
### ベンチマーク結果の比較
| 実行方法 | 実行回数 | 平均実行時間 (ns/op) | 平均実行時間 (秒) |
| -------- | -------- | --------------------- | ----------------- |
| 並行処理前 | 13 | 1,562,190,291 | 約1.562 |
| 並行処理後 | 66 | 19,091,054 | 約0.019 |
並行処理すごい!
まとめ
ということで今回はゴルーチンにより並行処理に挑戦してみました。
間違った点もあるかもしれませんが参考になれば幸いです。
参考文献