【第7回】実践知識を深める ── Go の慣習・設計・観測性・gRPC・パフォーマンス

Go
B!

シリーズ構成

  1. Go の基本文法
  2. CLI ツールを作る
  3. HTTP サーバーと REST API
  4. データベース連携
  5. 実務的な周辺技術
  6. ポートフォリオを作る
  7. 実践知識を深める(本記事)

最終回の目標は 「動くコードから、チームで保守できる良いコードへ進むための判断基準を持つ」 ことです。

ここまでの 6 回で API は完成しています。この回では「なぜ Go ではこう書くのか」という 慣習の背景、中規模以上で必要になる 設計・観測性・性能 の知識、そして 学び続けるための地図 を提供します。すぐに全部を身につける必要はありません。実務で壁に当たったとき、ここに戻ってきてください。


  1. 目次
  2. 1. Go の設計哲学を理解する
    1. Go が最適化しているもの
    2. Go Proverbs
  3. 2. Effective Go と Code Review Comments の要点
    1. 命名
    2. ドキュメントコメント
    3. コードの形
    4. 避けるべきもの
  4. 3. パッケージ設計
    1. 「何を提供するか」で切る
    2. 循環参照を避ける
    3. internal の使い方
    4. パッケージの API 面を小さく
  5. 4. インターフェース設計の原則
    1. 使う側で定義する(Consumer-defined interfaces)
    2. 小さく、合成する
    3. いつ抽象化するか
    4. インターフェースの実装をコンパイル時に確認
    5. インターフェースを返さない
  6. 5. エラー設計の実践
    1. エラーは 3 種類に分類する
    2. ラップの深さ
    3. エラーを公開 API にする
    4. panic の使いどころ
    5. エラーをログするのは 1 回
  7. 6. アーキテクチャの選択
    1. レイヤード(第6回で採用)
    2. クリーンアーキテクチャ / ヘキサゴナル
    3. モジュラーモノリス
    4. 判断の目安
  8. 7. 観測性 1:メトリクス(Prometheus)
    1. 標準的な HTTP メトリクス
    2. ミドルウェア
    3. エンドポイント公開
    4. 何を計測するか(RED メソッド)
    5. ラベルの注意
  9. 8. 観測性 2:分散トレーシング(OpenTelemetry)
    1. 初期化
    2. HTTP を自動計装
    3. 手動でスパンを切る
    4. DB クエリの計装
    5. ログとの紐付け
    6. ローカルで見る
  10. 9. 観測性 3:プロファイリング(pprof)
    1. 有効化
    2. プロファイルの取得と分析
    3. テストからプロファイル
    4. 読み方の基本
    5. 継続的プロファイリング
  11. 10. gRPC 入門
    1. REST との違い
    2. Protocol Buffers の定義
    3. コード生成:buf
    4. サーバー実装
    5. 起動
    6. インターセプター(ミドルウェア相当)
    7. Connect:gRPC とブラウザの橋渡し
  12. 11. パフォーマンスの考え方
    1. 原則:計測してから最適化
    2. Go 固有の性能ポイント
      1. メモリ割り当てを減らす
      2. エスケープ解析
      3. ベンチマークの書き方
      4. JSON の高速化
      5. DB が 9 割
    3. GOMAXPROCS とコンテナ
    4. GC チューニング
  13. 12. OSS のコードを読む
    1. 読む順番
    2. 読み方
    3. 標準ライブラリのソースを開く
  14. 13. 書籍と情報源
    1. 書籍(読む順)
    2. 公式ドキュメント
    3. 動画・講演
    4. コミュニティ
    5. 継続的に追うもの
  15. 14. キャリアの次の一歩
    1. ポートフォリオの次に作ると差がつくもの
    2. 面接で聞かれること
    3. 実務で最初に求められること
  16. 15. 演習問題
    1. 設計
    2. 観測性
    3. gRPC
    4. 読解とアウトプット
  17. おわりに ── 7 回を振り返って

目次

  1. Go の設計哲学を理解する
  2. Effective Go と Code Review Comments の要点
  3. パッケージ設計
  4. インターフェース設計の原則
  5. エラー設計の実践
  6. アーキテクチャの選択:レイヤード vs クリーン vs モジュラーモノリス
  7. 観測性 1:メトリクス(Prometheus)
  8. 観測性 2:分散トレーシング(OpenTelemetry)
  9. 観測性 3:プロファイリング(pprof)
  10. gRPC 入門
  11. パフォーマンスの考え方
  12. OSS のコードを読む
  13. 書籍と情報源
  14. キャリアの次の一歩
  15. 演習問題

1. Go の設計哲学を理解する

Go の慣習の多くは、言語の設計目標から導かれます。目標を知ると「なぜ」が分かります。

Go が最適化しているもの

目標具体的な設計
読みやすさ書き方の選択肢を減らす(for だけ、gofmt で整形固定、例外なし)
大規模開発コンパイルが速い、依存が明示的、未使用 import がエラー
単純さ継承なし、演算子オーバーロードなし、暗黙の型変換なし
並行処理goroutine と channel が言語機能
運用単一バイナリ、GC、クロスコンパイル

「賢いコード」より 「退屈で予測できるコード」 が評価されます。他言語から来た人がまず調整すべきは、この価値観です。

Go Proverbs

Rob Pike による箴言集から、実務に直結するものを抜き出します。

  • Don’t communicate by sharing memory, share memory by communicating.
    ロックより channel を先に考える(ただし単純なカウンタは Mutex で十分)
  • The bigger the interface, the weaker the abstraction.
    インターフェースは小さく。io.Reader は 1 メソッド
  • Make the zero value useful.
    var mu sync.Mutex が初期化なしで使えるように、自作型もそうする
  • A little copying is better than a little dependency.
    10 行の関数のために外部パッケージを入れない
  • Clear is better than clever.
    トリッキーなコードは書き直す
  • Errors are values.
    例外ではなく値。プログラミングできる
  • Don’t just check errors, handle them gracefully.
    if err != nil { return err } だけでなく、文脈を足し、リトライや代替を考える
  • Documentation is for users.
    ドキュメントコメントは実装ではなく使い方を書く

2. Effective Go と Code Review Comments の要点

Effective Go と Go Code Review Comments は全 Go エンジニアが読んでいる前提で会話が進みます。要点を整理します。

命名

// パッケージ名:小文字、単数形、短く。utils/common/helpers は避ける
package user      // ✓
package userUtils // ✗

// パッケージ名との重複を避ける
user.User      // ✗ 冗長
user.Account   // △
user.New()     // ✓ user.NewUser() より簡潔

// 変数名:スコープが狭ければ短く、広ければ説明的に
for i, v := range items {}         // ✓ 短いスコープ
var userRepository *UserRepository // ✓ 長く使う

// 頭字語は全大文字または全小文字
userID, httpClient, parseURL       // ✓
userId, HttpClient, parseUrl       // ✗

// インターフェース:メソッド名 + er
Reader, Writer, Stringer, Handler

// Getter に Get をつけない
u.Name()     // ✓
u.GetName()  // ✗
u.SetName()  // Setter は Set をつける

// レシーバ名は 1〜2 文字で一貫させる
func (s *Server) Start()   // ✓
func (srv *Server) Stop()  // ✗ 同じ型で変えない
func (this *Server) ...    // ✗ this/self は使わない

ドキュメントコメント

公開する識別子には 名前で始まる コメントを書きます。go doc と pkg.go.dev がそのまま表示します。

// TodoService は Todo に関するユースケースを提供する。
// リポジトリの実装には依存せず、TodoRepository インターフェースを通じて永続化を行う。
type TodoService struct { ... }

// Create は新しい Todo を作成する。
// title が空または 200 文字を超える場合は domain.ValidationErrors を返す。
func (s *TodoService) Create(ctx context.Context, ...) (*domain.Todo, error)

// Package handler は HTTP リクエストをサービス層の呼び出しに変換する。
package handler

コードの形

// 早期リターンでネストを浅く。正常系を左端に揃える
func process(u *User) error {
    if u == nil {
        return ErrNilUser
    }
    if !u.Active {
        return ErrInactive
    }
    // 正常系
    return nil
}

// else の中に return があるなら else を消す
if err != nil {
    return err
}
doSomething() // else 不要

// 初期化文つき if でスコープを絞る
if v, err := compute(); err == nil {
    use(v)
}

// 関数の引数が多いなら構造体に
func New(cfg Config) *Server  // ✓
func New(host string, port int, timeout time.Duration, ...) // ✗

// コンストラクタで返す型:具象型を返し、使う側でインターフェースにする
func NewPostgresRepo(db *sql.DB) *PostgresRepo  // ✓
func NewPostgresRepo(db *sql.DB) Repository     // ✗(呼び出し側の自由を奪う)

避けるべきもの

  • init() の多用:実行順が追いにくい。ドライバ登録のような限定用途に
  • パッケージレベルの可変変数:テストで衝突する。依存注入で渡す
  • panic をエラー処理に使う:バグ検出のみ
  • interface{} / any の乱用:型安全性が失われる
  • 過度な抽象化:使わないインターフェース、1 実装しかない factory
  • ゴルーチンの起動を隠す:関数が内部で go するなら、終了を待つ方法を提供する

3. パッケージ設計

「何を提供するか」で切る

✗ 技術的な種類で切る(レイヤーだけの分割)
internal/
  models/       # 全部の構造体
  controllers/  # 全部のハンドラ
  services/     # 全部のサービス

✓ ドメインで切る(中規模以上)
internal/
  todo/
    todo.go         # 型
    service.go
    handler.go
    postgres.go     # repository 実装
  user/
    ...
  auth/
    ...

第6回では小規模向けにレイヤーで切りましたが、機能が 5〜10 を超えるとドメイン単位(todo/, user/, billing/)の方が 変更が局所化 します。1 つの機能を触るとき 1 つのディレクトリで完結するからです。

循環参照を避ける

Go は パッケージの循環 import をコンパイルエラー にします。これは制約ではなく設計のガードレールです。

todo → user(Todo が User を参照)
user → todo(User が Todo 一覧を持つ)  ✗ 循環

解決策

  1. 共通の型を下位パッケージ(domain/)に出す
  2. 片方向にする(user は todo を知らず、todo が userID を持つだけ)
  3. インターフェースを使う側で定義し、実装の import を逆転させる

internal の使い方

myapp/
  internal/      # myapp 以下からのみ import 可
  pkg/           # 外部からも import 可(本当に公開する場合のみ)
  cmd/

internal/ は Go の言語仕様で保護されます。「公開しない」を明示でき、後から自由に変更できます。迷ったら internal。

パッケージの API 面を小さく

公開する識別子(大文字)が多いほど、変更コストが上がります。

// パッケージ外から使わないなら小文字
type todoRow struct{}        // DB 行の中間表現
func scanTodo(...)           // ヘルパー

// 公開するものは意図的に
type Service struct{}
func New(...) *Service

golangci-lint の unused や、go vet では検出できませんが、定期的に「本当に公開が必要か」を見直します。


4. インターフェース設計の原則

使う側で定義する(Consumer-defined interfaces)

Java や C# と最も違う点です。実装側がインターフェースを宣言するのではなく、使う側が「私はこれだけ必要」と宣言します。

// ✗ repository パッケージで巨大なインターフェースを定義
package repository
type TodoRepository interface {
    Create(...); Get(...); List(...); Update(...); Delete(...)
    Count(...); Search(...); BulkInsert(...); ...
}

// ✓ service パッケージで必要な分だけ
package service
type todoStore interface {
    Get(ctx context.Context, userID, id int64) (*domain.Todo, error)
    Update(ctx context.Context, t *domain.Todo) error
}

func (s *CompleteService) Complete(ctx context.Context, store todoStore, ...)

利点

  • モックが小さくて済む(2 メソッドだけ実装)
  • 実装側は自由にメソッドを追加できる
  • 何に依存しているかが関数の署名から読める

小さく、合成する

type Reader interface{ Read([]byte) (int, error) }
type Writer interface{ Write([]byte) (int, error) }
type Closer interface{ Close() error }

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

1 メソッドのインターフェースは、関数型で代替できることもあります。

// インターフェース
type Notifier interface{ Notify(msg string) error }

// 関数型(実装が 1 つの関数だけなら十分)
type NotifyFunc func(msg string) error
func (f NotifyFunc) Notify(msg string) error { return f(msg) }

http.HandlerFunc がこのパターンです。

いつ抽象化するか

1 つ目の実装 → インターフェース不要。具象型を使う
2 つ目の実装 or テストでモックが必要 → インターフェースを切る

「将来必要になるかも」でインターフェースを作ると、1 実装しかないインターフェースだらけになり、コードジャンプが 2 段階になって読みにくくなります。

インターフェースの実装をコンパイル時に確認

var _ service.TodoRepository = (*PostgresTodoRepository)(nil)

メソッドの署名を変えたときに、実装漏れを即座に検出できます。

インターフェースを返さない

// ✗
func NewCache() Cache { return &memCache{} }

// ✓ 具象型を返す。呼び出し側が必要ならインターフェースに代入する
func NewCache() *MemCache { return &MemCache{} }

例外:実装を完全に隠したいライブラリ(sql.Open が *sql.DB を返すのは具象型)。


5. エラー設計の実践

第1回・第6回で基礎は扱いました。ここでは チーム開発でのエラーの流儀 を整理します。

エラーは 3 種類に分類する

種類例呼び出し側の対応表現
予期される失敗見つからない、重複、権限なし分岐して処理センチネル or 型
入力エラーバリデーション失敗ユーザーに詳細を返す構造化された型
予期しない失敗DB 接続断、バグログして 500。リトライラップした汎用 error

「予期される失敗」だけを 判定可能 にし、他は fmt.Errorf("...: %w") でラップして上に流します。

ラップの深さ

// repository
return fmt.Errorf("get todo %d: %w", id, err)
// service
return fmt.Errorf("complete todo: %w", err)
// handler → ログ
// "complete todo: get todo 42: pq: connection refused"

各層で 1 回だけ 文脈を足します。同じ情報を 2 回書かない。

// ✗ 冗長
return fmt.Errorf("failed to get todo: error getting todo from database: %w", err)

エラーを公開 API にする

パッケージが返すエラーの種類は ドキュメントに書く べき API です。

// Get は指定された Todo を返す。
// 存在しない場合は ErrNotFound を、userID が所有者でない場合も ErrNotFound を返す
//(存在の有無を漏らさないため)。
func (r *Repo) Get(ctx context.Context, userID, id int64) (*Todo, error)

panic の使いどころ

// ✓ プログラマのミスを起動時に検出
var tmpl = template.Must(template.ParseFiles("a.html"))
func MustLoadConfig() Config { ... }

// ✓ 到達不能なコード
default:
    panic(fmt.Sprintf("unknown state: %v", s))

// ✗ ユーザー入力や外部システムの失敗
if input == "" { panic("empty") }

Must〜 という命名は「失敗したら panic する」の慣習です。

エラーをログするのは 1 回

// ✗ 各層でログ → 同じエラーが 3 回出る
if err != nil {
    log.Error(err)
    return err
}

// ✓ 処理する層(通常は handler か main)で 1 回ログ。途中はラップして返すだけ

6. アーキテクチャの選択

レイヤード(第6回で採用)

handler → service → repository → domain
  • 適する規模:小〜中(〜数万行)
  • 利点:理解しやすい、Go コミュニティで最も一般的
  • 欠点:機能が増えると各層のディレクトリが肥大化

クリーンアーキテクチャ / ヘキサゴナル

                 ┌──────────────────┐
  HTTP adapter ─→│                  │←─ Postgres adapter
  gRPC adapter ─→│  Application     │←─ Redis adapter
  CLI adapter  ─→│  (usecase)       │←─ Email adapter
                 │  ┌────────────┐  │
                 │  │  Domain    │  │
                 │  └────────────┘  │
                 └──────────────────┘
       Port(インターフェース)を通じて依存を逆転
  • 中心(domain / usecase)は 外側を一切知らない
  • 外側(HTTP、DB)は中心が定義した Port(インターフェース) を実装する
  • 適する規模:中〜大、複数の入出力(REST + gRPC + バッチ)
  • 欠点:ファイルと変換コードが増える。小規模では過剰

第6回の構成は実質「レイヤード + 依存逆転」で、クリーンアーキテクチャの要点は取り込んでいます。用語にこだわる必要はありません。

モジュラーモノリス

internal/
  todo/         # 完結したモジュール
    api.go      # 他モジュールに公開する API(インターフェース)
    service.go
    postgres.go
    handler.go
  user/
    api.go
    ...
  billing/
  • モジュール間は api.go で公開したもの だけを通じて呼び合う
  • DB のテーブルもモジュールごとに所有(他モジュールのテーブルを直接触らない)
  • 将来マイクロサービスに切り出せる境界を、1 つのバイナリで保つ
  • 適する規模:中〜大。マイクロサービスは時期尚早だが境界は欲しいとき

判断の目安

状況選択
個人 / 小チーム、機能 10 個以下レイヤード
入出力が複数(REST + gRPC + Worker)ヘキサゴナル
機能ドメインが 5 つ以上、チームが分かれ始めたモジュラーモノリス
最初からマイクロサービスほぼ常に早すぎる

Go の文化では、必要になるまで最も単純な構成を選びます。 移行は後からできます。


7. 観測性 1:メトリクス(Prometheus)

本番では「今何が起きているか」を 数値で 把握します。Prometheus は Go エコシステムの標準です。

go get github.com/prometheus/client_golang

標準的な HTTP メトリクス

// internal/metrics/metrics.go
package metrics

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    HTTPRequestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "route", "status"},
    )

    HTTPRequestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request latency",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
        },
        []string{"method", "route"},
    )

    DBQueryDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "db_query_duration_seconds",
            Help:    "Database query latency",
            Buckets: prometheus.DefBuckets,
        },
        []string{"query"},
    )

    TodosCreated = promauto.NewCounter(prometheus.CounterOpts{
        Name: "todos_created_total",
        Help: "Todos created",
    })
)

ミドルウェア

func Metrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w}

        next.ServeHTTP(rec, r)

        // ルートパターンを使う(/todos/123 ではなく /todos/{id})。カーディナリティ爆発を防ぐ
        route := chi.RouteContext(r.Context()).RoutePattern()
        metrics.HTTPRequestsTotal.WithLabelValues(r.Method, route, strconv.Itoa(rec.status)).Inc()
        metrics.HTTPRequestDuration.WithLabelValues(r.Method, route).Observe(time.Since(start).Seconds())
    })
}

エンドポイント公開

import "github.com/prometheus/client_golang/prometheus/promhttp"

r.Handle("/metrics", promhttp.Handler())
curl localhost:8080/metrics | grep http_requests_total
# http_requests_total{method="GET",route="/api/v1/todos",status="200"} 42

Go ランタイムのメトリクス(goroutine 数、GC、メモリ)は自動で含まれます。

何を計測するか(RED メソッド)

意味メトリクス
Rateリクエスト数/秒rate(http_requests_total[5m])
Errorsエラー率rate(http_requests_total{status=~"5.."}[5m])
Durationレイテンシ分布histogram_quantile(0.99, http_request_duration_seconds_bucket)

この 3 つがあれば、サービスの健全性はほぼ把握できます。

ラベルの注意

ラベルの 値の種類が増えると、メトリクスの時系列が爆発 します。ユーザー ID、リクエスト ID、生の URL パスをラベルにしてはいけません。


8. 観測性 2:分散トレーシング(OpenTelemetry)

1 リクエストが「HTTP → service → DB → 外部 API」と流れるとき、どこで時間がかかったか を可視化します。マイクロサービスでは必須、モノリスでも DB クエリの遅さ特定に有効です。

go get go.opentelemetry.io/otel \
       go.opentelemetry.io/otel/sdk \
       go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
       go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp

初期化

// internal/tracing/tracing.go
package tracing

import (
    "context"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func Setup(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
    exporter, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName(serviceName),
        )),
        sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))), // 10% サンプリング
    )
    otel.SetTracerProvider(tp)
    return tp.Shutdown, nil
}

HTTP を自動計装

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

handler := otelhttp.NewHandler(router, "server")

// クライアント側
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}

これだけで全リクエストに トレース ID とスパン が付きます。

手動でスパンを切る

var tracer = otel.Tracer("todo-api/service")

func (s *TodoService) Create(ctx context.Context, userID int64, in CreateTodoInput) (*domain.Todo, error) {
    ctx, span := tracer.Start(ctx, "TodoService.Create")
    defer span.End()

    span.SetAttributes(attribute.Int64("user.id", userID))

    todo, err := s.repo.Create(ctx, ...)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return nil, err
    }
    return todo, nil
}

DB クエリの計装

github.com/XSAM/otelsql で database/sql をラップするか、pgx なら github.com/exaring/otelpgx を使います。SQL ごとのスパンが自動で作られます。

ログとの紐付け

トレース ID をログに含めると、「このエラーログのリクエストは、トレースで見るとどこで遅かったか」を追えます。

func RequestLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        span := trace.SpanFromContext(r.Context())
        l := slog.Default().With(
            "trace_id", span.SpanContext().TraceID().String(),
            "span_id", span.SpanContext().SpanID().String(),
        )
        ctx := logging.WithLogger(r.Context(), l)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

ローカルで見る

# compose.yaml に追加
  jaeger:
    image: jaegertracing/all-in-one:1.60
    ports:
      - "16686:16686"  # UI
      - "4318:4318"    # OTLP HTTP

OTEL_EXPORTER_OTLP_ENDPOINT=jaeger:4318 で送り、http://localhost:16686 で見ます。


9. 観測性 3:プロファイリング(pprof)

「CPU を何に使っているか」「メモリを誰が持っているか」を 本番でも 調べられます。Go の強力な武器です。

有効化

import _ "net/http/pprof"

// 別ポートで公開(外部には出さない)
go func() {
    slog.Info("pprof listening", "addr", "localhost:6060")
    http.ListenAndServe("localhost:6060", nil)
}()

プロファイルの取得と分析

# CPU:30 秒間サンプリング
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30

# ヒープ(現在のメモリ)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

# goroutine(リーク調査)
curl localhost:6060/debug/pprof/goroutine?debug=2

# ブロッキング(ロック待ち)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/block

# 割り当て(GC 負荷の原因)
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/allocs

ブラウザで Flame Graph が開きます。横幅が広い関数ほど時間(またはメモリ)を消費しています。

テストからプロファイル

go test -cpuprofile=cpu.out -memprofile=mem.out -bench=. ./...
go tool pprof -http=:8081 cpu.out

読み方の基本

  1. Top ビューで flat(その関数自身)と cum(子を含む累積)を見る
  2. 自分のコードで flat が大きい → アルゴリズムの問題
  3. runtime.mallocgc が大きい → 割り当てが多すぎる。allocs プロファイルで誰が割り当てているか見る
  4. runtime.gcBgMarkWorker が大きい → GC 負荷。メモリ割り当てを減らす
  5. sync.(*Mutex).Lock が大きい → ロック競合。粒度を細かくするか、shard する

継続的プロファイリング

Pyroscope や Datadog Continuous Profiler を使うと、本番の pprof を 常時収集 して時系列で比較できます。「先週のデプロイから CPU が 20% 増えた原因」がすぐ分かります。


10. gRPC 入門

社内サービス間通信では REST より gRPC が選ばれることが多いです。Go は gRPC の第一級言語です。

REST との違い

REST/JSONgRPC
スキーマ任意(OpenAPI)必須(Protocol Buffers)
シリアライズJSON(テキスト)Protobuf(バイナリ、小さく速い)
型安全弱い強い(コード生成)
ストリーミング難しい双方向ストリーミングが標準
ブラウザ直接呼べるgrpc-web / Connect が必要
用途公開 API、フロントエンド向けサービス間、高頻度通信

Protocol Buffers の定義

// proto/todo/v1/todo.proto
syntax = "proto3";

package todo.v1;

option go_package = "github.com/yourname/todo-api/gen/todo/v1;todov1";

import "google/protobuf/timestamp.proto";

service TodoService {
  rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse);
  rpc GetTodo(GetTodoRequest) returns (GetTodoResponse);
  rpc ListTodos(ListTodosRequest) returns (ListTodosResponse);
  rpc WatchTodos(WatchTodosRequest) returns (stream TodoEvent); // サーバーストリーミング
}

message Todo {
  int64 id = 1;
  string title = 2;
  string description = 3;
  bool done = 4;
  repeated string tags = 5;
  google.protobuf.Timestamp created_at = 6;
}

message CreateTodoRequest {
  string title = 1;
  string description = 2;
  repeated string tags = 3;
}

message CreateTodoResponse {
  Todo todo = 1;
}

message GetTodoRequest {
  int64 id = 1;
}

message GetTodoResponse {
  Todo todo = 1;
}

message ListTodosRequest {
  int32 page_size = 1;
  string page_token = 2;
  optional bool done = 3;
}

message ListTodosResponse {
  repeated Todo todos = 1;
  string next_page_token = 2;
}

message WatchTodosRequest {}

message TodoEvent {
  enum Type {
    TYPE_UNSPECIFIED = 0;
    TYPE_CREATED = 1;
    TYPE_UPDATED = 2;
    TYPE_DELETED = 3;
  }
  Type type = 1;
  Todo todo = 2;
}

コード生成:buf

protoc を直接使うより buf が現代的です。

brew install bufbuild/buf/buf

buf.yaml

version: v2
modules:
  - path: proto
lint:
  use: [STANDARD]
breaking:
  use: [FILE]

buf.gen.yaml

version: v2
plugins:
  - remote: buf.build/protocolbuffers/go
    out: gen
    opt: paths=source_relative
  - remote: buf.build/grpc/go
    out: gen
    opt: paths=source_relative
buf lint
buf generate
# gen/todo/v1/todo.pb.go と todo_grpc.pb.go が生成される

サーバー実装

// internal/grpcserver/todo.go
package grpcserver

import (
    "context"

    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "google.golang.org/protobuf/types/known/timestamppb"

    todov1 "github.com/yourname/todo-api/gen/todo/v1"
    "github.com/yourname/todo-api/internal/domain"
    "github.com/yourname/todo-api/internal/service"
)

type TodoServer struct {
    todov1.UnimplementedTodoServiceServer // 前方互換性のため必ず埋め込む
    svc *service.TodoService
}

func NewTodoServer(svc *service.TodoService) *TodoServer {
    return &TodoServer{svc: svc}
}

func (s *TodoServer) CreateTodo(ctx context.Context, req *todov1.CreateTodoRequest) (*todov1.CreateTodoResponse, error) {
    userID := userIDFromContext(ctx) // メタデータ(ヘッダ相当)から

    todo, err := s.svc.Create(ctx, userID, service.CreateTodoInput{
        Title:       req.GetTitle(),
        Description: req.GetDescription(),
        Tags:        req.GetTags(),
    })
    if err != nil {
        return nil, toGRPCError(err)
    }
    return &todov1.CreateTodoResponse{Todo: toProto(todo)}, nil
}

func (s *TodoServer) GetTodo(ctx context.Context, req *todov1.GetTodoRequest) (*todov1.GetTodoResponse, error) {
    todo, err := s.svc.Get(ctx, userIDFromContext(ctx), req.GetId())
    if err != nil {
        return nil, toGRPCError(err)
    }
    return &todov1.GetTodoResponse{Todo: toProto(todo)}, nil
}

// ドメインエラー → gRPC ステータス。HTTP の writeError と同じ役割
func toGRPCError(err error) error {
    var ve domain.ValidationErrors
    switch {
    case errors.As(err, &ve):
        return status.Error(codes.InvalidArgument, ve.Error())
    case errors.Is(err, domain.ErrNotFound):
        return status.Error(codes.NotFound, "not found")
    case errors.Is(err, domain.ErrUnauthorized):
        return status.Error(codes.Unauthenticated, "unauthenticated")
    case errors.Is(err, domain.ErrForbidden):
        return status.Error(codes.PermissionDenied, "forbidden")
    case errors.Is(err, domain.ErrConflict):
        return status.Error(codes.AlreadyExists, err.Error())
    default:
        slog.Error("grpc internal error", "err", err)
        return status.Error(codes.Internal, "internal error")
    }
}

func toProto(t *domain.Todo) *todov1.Todo {
    return &todov1.Todo{
        Id: t.ID, Title: t.Title, Description: t.Description,
        Done: t.Done, Tags: t.Tags,
        CreatedAt: timestamppb.New(t.CreatedAt),
    }
}

同じ service を REST と gRPC の両方から呼べる ことに注目してください。第6回でサービス層を HTTP から独立させた効果です。

起動

import (
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
)

lis, _ := net.Listen("tcp", ":9090")
srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(
        loggingInterceptor,
        authInterceptor(tokens),
    ),
)
todov1.RegisterTodoServiceServer(srv, NewTodoServer(todoSvc))
reflection.Register(srv) // grpcurl で叩けるように

go srv.Serve(lis)
// ... shutdown 時に srv.GracefulStop()
# 動作確認
brew install grpcurl
grpcurl -plaintext localhost:9090 list
grpcurl -plaintext -d '{"title":"hello"}' localhost:9090 todo.v1.TodoService/CreateTodo

インターセプター(ミドルウェア相当)

func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    start := time.Now()
    resp, err := handler(ctx, req)
    slog.Info("grpc", "method", info.FullMethod, "duration", time.Since(start), "err", err)
    return resp, err
}

Connect:gRPC とブラウザの橋渡し

connectrpc.com/connect を使うと、同じ proto から gRPC・gRPC-Web・Connect(JSON over HTTP) を全部話すサーバーが net/http 互換で作れます。ブラウザから直接呼べ、chi のミドルウェアも使えます。新規プロジェクトでは有力な選択肢です。


11. パフォーマンスの考え方

原則:計測してから最適化

1. ベンチマーク or pprof で「どこが遅いか」を特定
2. その箇所だけ直す
3. 再計測して効果を確認

推測で最適化すると、読みにくくなるだけで速くならないことがほとんどです。

Go 固有の性能ポイント

メモリ割り当てを減らす

GC 負荷はほぼ 割り当て回数 で決まります。

// スライスは容量を先に確保
out := make([]Item, 0, len(in))  // ✓
var out []Item                    // ✗ append ごとに再割り当て

// strings.Builder で連結
var sb strings.Builder
sb.Grow(estimatedSize)

// sync.Pool で一時オブジェクトを再利用(高頻度の場合のみ)
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
buf := bufPool.Get().(*bytes.Buffer)
defer func() { buf.Reset(); bufPool.Put(buf) }()

エスケープ解析

ローカル変数がヒープに逃げる(escape)と割り当てが発生します。

go build -gcflags='-m' ./... 2>&1 | grep escapes

ポインタを返す、インターフェースに入れる、クロージャで捕捉する、と escape します。ホットパスだけ気にすれば十分です。

ベンチマークの書き方

func BenchmarkNormalizeTags(b *testing.B) {
    tags := []string{"Go", " api ", "GO", "", "backend"}
    b.ReportAllocs()
    for b.Loop() {   // Go 1.24+。それ以前は for i := 0; i < b.N; i++
        normalizeTags(tags)
    }
}
go test -bench=NormalizeTags -benchmem -count=5 > old.txt
# 変更後
go test -bench=NormalizeTags -benchmem -count=5 > new.txt
benchstat old.txt new.txt   # go install golang.org/x/perf/cmd/benchstat@latest

benchstat で統計的に有意な差かを判定します。1 回の計測で判断しないこと。

JSON の高速化

標準の encoding/json はリフレクションを使い、大量処理では遅いことがあります。goccy/go-json はドロップイン置き換えで 2〜3 倍速。ただし まず計測して JSON がボトルネックだと確認してから。

DB が 9 割

Web API では大抵 DB クエリが支配的 です。Go コードの最適化より:

  • インデックスが使われているか(EXPLAIN ANALYZE)
  • N+1 が起きていないか
  • 不要な列を SELECT していないか
  • コネクションプールが枯渇していないか
  • 読み取りが多いなら Redis などでキャッシュ

を先に見ます。

GOMAXPROCS とコンテナ

コンテナの CPU 制限(cpu: 2)を Go ランタイムは自動認識しません(Go 1.25 で改善)。go.uber.org/automaxprocs を import すると自動調整されます。

import _ "go.uber.org/automaxprocs"

GC チューニング

GOGC=200         # GC 頻度を下げる(メモリを 2 倍使って CPU を節約)
GOMEMLIMIT=1GiB  # メモリ上限(Go 1.19+)。コンテナの制限より少し下に設定

GOMEMLIMIT を設定すると、上限に近づいたとき GC が積極的に働き、OOM Kill を避けられます。


12. OSS のコードを読む

上達の近道は 良いコードを読む ことです。Go は標準ライブラリも有名 OSS も読みやすく書かれています。

読む順番

段階対象学べること
1net/http の server.go、fs.goHandler の実装、ServeMux、goroutine の使い方
2encoding/jsonリフレクション、ストリーム処理
3sync パッケージMutex、WaitGroup の実装(意外と短い)
4go-chi/chiルーターの実装、ミドルウェアの合成。1 万行以下
5sqlcSQL パース、コード生成
6spf13/cobraCLI フレームワークの設計
7golang-migrateドライバ抽象化、プラグイン設計
8uber-go/guideUber の Go スタイルガイド(コードではないが必読)
9grafana/loki、prometheus大規模な Go プロジェクトの構成

Kubernetes は巨大で独自の慣習が多いため、最初は避けます。

読み方

  1. go doc または pkg.go.dev で 公開 API を眺める
  2. main またはエントリポイントから 依存の組み立て を追う
  3. 1 つの機能(例:chi の Route)を選び、呼び出しの連鎖を最後まで 追う
  4. テストコードを読む。期待される振る舞い が最も明確に書かれている
  5. git log で なぜその変更が入ったか を読む。Issue や PR の議論は設計判断の宝庫

標準ライブラリのソースを開く

go env GOROOT
# $GOROOT/src/net/http/server.go

VS Code で http.ListenAndServe を Cmd+クリックすれば実装にジャンプします。


13. 書籍と情報源

書籍(読む順)

段階書籍内容
入門完了後『実用 Go 言語』(オライリー)現場の「こう書く」を網羅。第3〜5回の後に読むと効果的
並行処理『Go 言語による並行処理』(オライリー)goroutine / channel / context を体系的に。第5回の補強
Web 開発『詳解 Go 言語 Web アプリケーション開発』(C&R研究所)実務レベルの API 構築を 1 冊で。第6回の別解として
中級の壁『100 Go Mistakes and How to Avoid Them』(Manning、英語)ありがちなミス 100 個。中級への壁を越える
設計『Learning Go, 2nd Edition』(O’Reilly、英語)言語仕様の深い理解。ジェネリクスも網羅
内部『Go 言語 100 本ノック』 や 『Go 言語プログラミングエッセンス』内部構造、ツールチェーン

公式ドキュメント

動画・講演

  • GopherCon の講演。特に Rob Pike「Concurrency Is Not Parallelism」、Dave Cheney「SOLID Go Design」、Mat Ryer「How I Write HTTP Services After Eight Years」
  • Go Conference(日本):日本語で実務の話が聞ける

コミュニティ

  • Gophers Slack(invite.slack.golangbridge.org):#japan チャンネルあり
  • Go Forum、r/golang
  • Go Weekly(ニュースレター)
  • 日本:Go Conference、Gopher 道場、golang.tokyo

継続的に追うもの

  • Go のリリースノート(半年ごと、2 月と 8 月)
  • golang/go リポジトリの proposal(言語の未来)
  • awesome-go(ライブラリ探し。ただし星の数より最終更新日と Issue の対応を見る)

14. キャリアの次の一歩

ポートフォリオの次に作ると差がつくもの

  1. CLI ツールを公開して go install で配布:小さくても「ユーザーがいる」経験
  2. OSS への貢献:ドキュメント修正、テスト追加から。Issue の「good first issue」ラベルを探す
  3. 技術記事:詰まった点とその解決を書く。第2回の内容を自分の言葉で書き直すだけでも価値がある
  4. 既存プロジェクトの改善提案:使っているライブラリの Issue に再現コードつきで報告する

面接で聞かれること

  • goroutine と OS スレッドの違い、スケジューラの概要
  • channel と Mutex の使い分け
  • context の役割、キャンセルの伝播
  • インターフェースの暗黙的実装、nil インターフェースの罠
  • エラーハンドリングの方針(errors.Is / As、ラップ)
  • スライスの内部構造と append の挙動
  • defer の評価タイミング
  • database/sql のコネクションプール
  • 設計した API のエンドポイント、認証方式、なぜその選択をしたか

このシリーズを一通り手を動かして終えていれば、すべて 自分の経験として 語れるはずです。

実務で最初に求められること

  • 既存コードを読んで、規約に沿って追加・修正できる
  • テストを書き、CI を通す
  • PR の説明を書き、レビューを受けて修正する
  • ログとメトリクスから障害の原因を追える
  • 「分からないことを分からないと言い、調べて報告する」

言語の知識よりこれらの習慣が評価されます。第6回の Git 作法と README、第7回の観測性はそのためのものです。


15. 演習問題

設計

  1. モジュラーモノリス化:第6回の Todo API を internal/todo/、internal/user/ のドメイン単位に再編成する。モジュール間は各 api.go のインターフェースを通じてのみ依存させる。循環参照が起きた場合はどう解消したか記録する
  2. Consumer-defined interface:service 層のインターフェースを、使うメソッドだけに絞った小さなインターフェース(todoGetter、todoUpdater)に分割する。モックの実装量がどれだけ減るか比較する
  3. エラー設計レビュー:自分のコードで errors.Is / As で判定しているエラーをすべて列挙し、「予期される失敗 / 入力エラー / 予期しない失敗」に分類する。分類できないものは設計を見直す

観測性

  1. Prometheus 導入:RED メトリクスを実装し、Grafana でダッシュボードを作る。compose.yaml に Prometheus と Grafana を追加し、docker compose up でグラフが見える状態にする
  2. トレーシング導入:OpenTelemetry + Jaeger で、1 リクエストの HTTP → service → SQL のスパンが見えるようにする。意図的に遅い SQL(pg_sleep(1))を入れ、トレースで特定できることを確認する
  3. pprof で最適化:GET /todos に 10 万件の Todo を返す負荷をかけ(hey や vegeta)、CPU プロファイルを取る。最も時間を使っている関数を特定し、改善して benchstat で効果を示す

gRPC

  1. gRPC 化:Todo API の Create / Get / List を gRPC でも提供する。同じ service 層を使い、REST と gRPC が同じ振る舞いをすることをテストで確認する
  2. ストリーミング:WatchTodos サーバーストリーミングを実装し、別クライアントが Todo を作成するとリアルタイムに通知が届くようにする(内部は channel で fan-out)
  3. Connect 移行:gRPC サーバーを Connect に置き換え、curl で JSON を POST して呼べることと、grpcurl で gRPC として呼べることの両方を確認する

読解とアウトプット

  1. chi を読む:chi.Mux の routeHTTP から、パスパラメータがどう抽出されて r.Context() に入るかを追い、300 字で説明する
  2. 標準ライブラリを読む:net/http の Server.Serve を読み、リクエストごとに goroutine が起動される箇所と、Shutdown が処理中のコネクションをどう待つかを特定する
  3. 記事を書く:このシリーズの中で最も苦労した回について、「何に詰まり、どう理解したか」を 2000 字程度の技術記事にして公開する

おわりに ── 7 回を振り返って

回身についたこと
1文法、インターフェース、エラー処理という Go の骨格
2io.Reader / io.Writer の抽象、テストの書き方
3http.Handler を中心とした HTTP の仕組み
4SQL とトランザクション、DB を含めたテスト
5本番運用の道具と、並行処理の設計
6層の分離、依存注入、他人に伝える技術
7慣習の背景、観測性、性能、学び続ける方法

Go は「覚えることが少ない」言語です。だからこそ、差がつくのは 文法ではなく判断 です。どこに境界を引くか、どのエラーを判定可能にするか、いつ抽象化するか、何を計測するか。このシリーズで扱ったのはすべてその判断の材料です。

最後に、最も重要な習慣を 3 つ。

  1. 書く前に読む:標準ライブラリと良い OSS のコードを読み続ける
  2. 推測せず計測する:-race、pprof、ベンチマーク、EXPLAIN ANALYZE
  3. 小さく始めて必要になったら足す:抽象化も、マイクロサービスも、ライブラリも

分からないことが出てきたら、そのコードを持って質問してください。7 回分の内容はいつでも参照できます。

B!
← 一覧へ戻る