Goでアクセスログを集計してみよう(総合演習)
ここまでの Go レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。構造体・スライス・マップ・for range・関数・switch——すでに練習したものだけで組み立てます。
作るのはアクセスログの集計です。平均応答時間を出し、ステータスコードごとに数え、いちばん遅かったリクエストを見つけて、レポートにまとめます。
これはGoがいちばん得意とする仕事です。Goはサーバーやコマンドラインの道具を書くために作られた言語で、ログを読んで数えて出すのは、その中心にある用途です。
このページではコードを実行していません。Goはコンパイルが必要な言語で、ブラウザの中では走らせられないためです。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。
表示している実行結果は本物です。このページを作るときに、実際のGoでコンパイルして走らせた出力をそのまま載せています。
完成イメージ
最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。
===== アクセスログ集計 ===== リクエスト数: 8件 平均応答時間: 287.0ms 遅いリクエスト(300ms以上): 2件 --- ステータス別 --- 200 成功 5件 401 クライアントエラー 1件 404 クライアントエラー 1件 500 サーバーエラー 1件 最も遅かった: /api/items (1420ms, status 500)
STEP 1ログ1行を構造体で表す
🎯 このステップのゴール: 型を定義し、それを並べたスライスを作る
まずデータの形を決めます。Goでは先に型を宣言するのが出発点です。
ログ1行が持つのは「パス・ステータスコード・応答時間」の3つ。type Entry struct { ... }と書いて、この3つを持つ新しい型を作ります。
フィールド名を大文字で始めているのに注目してください。Goでは大文字で始まる名前だけが、他のパッケージから見えるという決まりがあります。小文字にすると同じパッケージの中でしか使えません。アクセス修飾子の代わりを、名前の1文字目が果たしているのです。
そして[]Entry{...}でスライス——伸び縮みする配列——を作ります。{Path: "/", Status: 200, MsTime: 42}のようにフィールド名を書いて初期化するのが安全です。名前を省いて順番だけで書くこともできますが、フィールドの順番を変えた瞬間に、静かに壊れます。
len(logs)で件数を数えます。Goではlogs.lengthではなくlen(logs)——長さは組み込み関数で取ります。
リクエスト数: 8件 1件目: / (status 200)
STEP 2平均応答時間を出す
🎯 このステップのゴール: for rangeで合計し、割り算のために型を変換する
応答時間を全部足して、件数で割ります。使うのはfor rangeです。
for _, e := range logs——この_(アンダースコア)が Go らしいところです。rangeは添字と値の2つを返しますが、添字は要らない。Goでは使わない変数を宣言するとコンパイルエラーになるので、_で「捨てる」と明示します。
この厳しさはGoの設計思想です。使われていない変数はたいてい書き間違いか消し忘れなので、動かす前に止めてしまう。慣れるまで煩わしいのですが、後から効いてきます。
次が割り算です。total / len(logs)と書くと整数の割り算になり、小数点以下が切り捨てられます。float64(total) / float64(len(logs))と両方を明示的に変換します。
Goは自動で型を変換しません。intとfloat64を混ぜて計算しようとするだけでコンパイルエラーです。これも厳しく見えますが、「気づかないうちに精度が落ちていた」という事故が起きないという利点があります。
0件のときに割るとNaNという値になります。だからaverageの中で件数が0なら0を返すと先に書いておきます。割る前に0を確かめる——これは書く習慣にしてください。
合計: 2296ms 平均応答時間: 287.0ms
STEP 3ステータスコードごとに数える
🎯 このステップのゴール: マップで集計し、switchで分類名を付ける
ステータスコードごとの件数を数えます。ここでマップ——キーと値の対応表——を使います。
counts := map[int]int{}は「整数をキーに、整数を値に持つマップ」という意味です。そしてcounts[e.Status]++と書くだけで数えられます。
ここがGoのマップの便利なところです。まだ無いキーを読むと、エラーではなく「その型のゼロ値」が返ります。intのゼロ値は0なので、初回でも0 + 1になって正しく1になる。「キーが無ければ0を入れてから足す」という前処理が要りません。
ただしこの親切さが罠になる場面もあります。「キーが無い」のか「値が0だった」のかを区別したいときは、v, ok := counts[key]と2つ受け取ってokで判定します。
分類名を付けるのがstatusLabelです。条件を書くswitch——switch { case 条件: }という形——を使うと、ifを並べるより読みやすくなります。
Goのswitchにはbreakが要りません。C言語やJavaのように、書き忘れて次のcaseに流れ込む事故が起きない設計です。
200 成功: 5件 404 クライアントエラー: 1件 500 サーバーエラー: 1件
STEP 4マップを決まった順番で表示する
🎯 このステップのゴール: キーを取り出して並べ替えてから回す
ステップ3で作ったマップを、そのままfor rangeで回して表示すると——実行するたびに順番が変わります。
これはバグではなく、Goの仕様です。マップの反復順は意図的にばらばらにされています。「順番があると思い込んだコード」を書かせないために、わざとそうしてあるのです。
他の多くの言語と違うところなので、必ず一度は踏みます。手元では正しく見えたのに、本番で並びが変わって「表示がおかしい」と言われる——よくある話です。
直し方は決まっています。キーだけを取り出して、並べ替えてから回す。
codes := make([]int, 0, len(counts))で入れ物を作ります。3つ目の引数は容量——「これだけ入る見込み」を先に伝えておくと、appendのたびに配列を作り直さずに済みます。件数が分かっているなら書いておくのが作法です。
for code := range counts——値を受け取らず1つだけ書くと、キーが取れます。あとはsort.Ints(codes)で並べ替えて、その順に表示します。
%-18sで分類名の幅を揃えます。日本語は文字の幅が違うので完全には揃いませんが、大きくは整います。
200: 5件 401: 1件 404: 1件 500: 1件
STEP 5いちばん遅かったリクエストを探す
🎯 このステップのゴール: 2つの値を返して「見つかったか」も一緒に伝える
いちばん時間のかかったリクエストを探します。やり方は1件目を暫定の1位にして、順に比べて入れ替える——集計の定番です。
問題はログが0件だったときです。返すものがありません。他の言語ならnullを返すところですが、Goにはnullを返すという発想がありません。
かわりに2つの値を返します。func slowest(logs []Entry) (Entry, bool)——結果と、「見つかったかどうか」です。
これはGoのいたるところに現れる形です。マップのv, ok := m[key]も、ファイルを開くf, err := os.Open(...)も、同じ考え方です。「失敗するかもしれない」を戻り値で正直に表す——例外を投げて呼び出し元に押し付けない、というのがGoの立場です。
受け取る側はif worst, ok := slowest(logs); ok {と書きます。ifの中で変数を作って、その場で判定する書き方で、worstとokはifの中でしか使えません。使う範囲が狭いほど、間違いにくくなります。
0件のときに返しているEntry{}はゼロ値の構造体です。フィールドが全部ゼロ値(空文字と0)で埋まったもので、「中身は見ないでほしい」という印として使います。
最も遅かった: /api/items (1420ms, status 500) ログが0件のときは、見つからないと返ります
STEP 6ひとつの関数にまとめて仕上げる
🎯 このステップのゴール: 全部を report にまとめ、しきい値を定数にする
最後の仕上げです。ここまでの部品をreportという1つの関数にまとめます。
あわせて「遅い」の境目を定数にします。const SlowMs = 300——300という数字をコードのあちこちに直接書くと、何を意味するのか後から読んで分からなくなります。
しかもこの値は必ず変わります。「100msにしよう」となったとき、定数なら1か所です。Goの定数は型を書かなくても、使う場所に合わせて解釈されます——e.MsTime >= SlowMsでも%dに渡しても、そのまま通ります。
並べる順番にも意味を持たせます。まず全体の数字、次にステータス別の内訳、最後に個別のいちばん遅かった1件——大きいものから細かいものへという流れです。読む人はまず全体を掴みたいからです。
完成です。logsに行を足したり、MsTimeの値を変えたりしてみてください。平均も件数も、いちばん遅い1件も、全部連動して変わります。
SlowMsを100に変えると、「遅いリクエスト」の件数だけが増えます。1か所直すだけで意味が変わる——定数に切り出しておいた効果が、ここで確かめられます。
次に進むなら、このログをファイルから読むところです。行をstrings.Splitで分けてEntryに詰め直せば、このreportはそのまま使えます。集計の部分を関数に切り出しておいたから、入り口だけ差し替えられるのです。
===== アクセスログ集計 ===== リクエスト数: 8件 平均応答時間: 287.0ms 遅いリクエスト(300ms以上): 2件 --- ステータス別 --- 200 成功 5件 401 クライアントエラー 1件 404 クライアントエラー 1件 500 サーバーエラー 1件 最も遅かった: /api/items (1420ms, status 500)
よくある質問
このページではコードを実行しないのですか?
していません。Goはコンパイルが必要な言語で、ブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。
ただし表示している実行結果は本物です。このページを作るときに実際のGoでコンパイルして走らせた出力を、そのまま載せています。自分のパソコンにGoを入れれば、まったく同じ結果が出ます。
マップの表示順が実行するたびに変わります
Goの仕様です。バグではありません。マップの反復順は意図的にばらばらにされています。「順番があると思い込んだコード」を書かせないためです。
決まった順で表示したいなら、ステップ4のようにキーだけを取り出し、並べ替えてから回します。この一手間を省くと、手元では正しく見えたのに本番で並びが変わる、という見つけにくい不具合になります。
declared and not used というエラーが出ます
使っていない変数があります。Goでは変数を宣言したのに一度も使わないと、コンパイルが通りません。
for _, e := range logsの_は、「受け取るけれど使わない」と明示するための書き方です。添字が要らないときは、変数名の代わりに_を書きます。
import も同じで、使っていないパッケージを import したままだと落ちます。厳しく感じますが、消し忘れが積もらない利点があります。
エラーはなぜ例外ではなく戻り値なのですか
「失敗するかもしれない」ことを、呼び出す側から隠さないためです。
例外は、書かなくても勝手に上へ飛んでいきます。便利ですが、どの行が失敗しうるのかがコードから読み取れません。
Goは戻り値で返すので、if err != nilを書くまで先に進めません。冗長に見えますが、失敗の扱いを書き忘れることがなくなります。ステップ5の(Entry, bool)も、同じ考え方の小さな例です。
🚩 次はこのカテゴリへ
Goで書いた道具は、1つの実行ファイルにまとまるのが大きな強みです。それをそのままコンテナに入れれば、どこでも同じように動きます。Dockerカテゴリの最終演習では、イメージの取得から起動・片付けまでを通しで練習します。自分で書いたプログラムを世に出すとき、次に通る道です。
Dockerのレッスンへ進む