シリーズ構成
- Go の基本文法
- CLI ツールを作る
- HTTP サーバーと REST API
- データベース連携
- 実務的な周辺技術
- ポートフォリオを作る
- 実践知識を深める(本記事)
最終回の目標は 「動くコードから、チームで保守できる良いコードへ進むための判断基準を持つ」 ことです。
ここまでの 6 回で API は完成しています。この回では「なぜ Go ではこう書くのか」という 慣習の背景、中規模以上で必要になる 設計・観測性・性能 の知識、そして 学び続けるための地図 を提供します。すぐに全部を身につける必要はありません。実務で壁に当たったとき、ここに戻ってきてください。
目次
- Go の設計哲学を理解する
- Effective Go と Code Review Comments の要点
- パッケージ設計
- インターフェース設計の原則
- エラー設計の実践
- アーキテクチャの選択:レイヤード vs クリーン vs モジュラーモノリス
- 観測性 1:メトリクス(Prometheus)
- 観測性 2:分散トレーシング(OpenTelemetry)
- 観測性 3:プロファイリング(pprof)
- gRPC 入門
- パフォーマンスの考え方
- OSS のコードを読む
- 書籍と情報源
- キャリアの次の一歩
- 演習問題
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 一覧を持つ) ✗ 循環
解決策
- 共通の型を下位パッケージ(
domain/)に出す - 片方向にする(
userはtodoを知らず、todoがuserIDを持つだけ) - インターフェースを使う側で定義し、実装の 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
読み方の基本
- Top ビューで
flat(その関数自身)とcum(子を含む累積)を見る - 自分のコードで
flatが大きい → アルゴリズムの問題 runtime.mallocgcが大きい → 割り当てが多すぎる。allocsプロファイルで誰が割り当てているか見るruntime.gcBgMarkWorkerが大きい → GC 負荷。メモリ割り当てを減らすsync.(*Mutex).Lockが大きい → ロック競合。粒度を細かくするか、shard する
継続的プロファイリング
Pyroscope や Datadog Continuous Profiler を使うと、本番の pprof を 常時収集 して時系列で比較できます。「先週のデプロイから CPU が 20% 増えた原因」がすぐ分かります。
10. gRPC 入門
社内サービス間通信では REST より gRPC が選ばれることが多いです。Go は gRPC の第一級言語です。
REST との違い
| REST/JSON | gRPC | |
|---|---|---|
| スキーマ | 任意(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 も読みやすく書かれています。
読む順番
| 段階 | 対象 | 学べること |
|---|---|---|
| 1 | net/http の server.go、fs.go | Handler の実装、ServeMux、goroutine の使い方 |
| 2 | encoding/json | リフレクション、ストリーム処理 |
| 3 | sync パッケージ | Mutex、WaitGroup の実装(意外と短い) |
| 4 | go-chi/chi | ルーターの実装、ミドルウェアの合成。1 万行以下 |
| 5 | sqlc | SQL パース、コード生成 |
| 6 | spf13/cobra | CLI フレームワークの設計 |
| 7 | golang-migrate | ドライバ抽象化、プラグイン設計 |
| 8 | uber-go/guide | Uber の Go スタイルガイド(コードではないが必読) |
| 9 | grafana/loki、prometheus | 大規模な Go プロジェクトの構成 |
Kubernetes は巨大で独自の慣習が多いため、最初は避けます。
読み方
go docまたは pkg.go.dev で 公開 API を眺めるmainまたはエントリポイントから 依存の組み立て を追う- 1 つの機能(例:chi の
Route)を選び、呼び出しの連鎖を最後まで 追う - テストコードを読む。期待される振る舞い が最も明確に書かれている
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 言語プログラミングエッセンス』 | 内部構造、ツールチェーン |
公式ドキュメント
- Effective Go
- Go Code Review Comments
- Google Go Style Guide
- Go Blog:新機能の設計意図。特に「Go Concurrency Patterns」「Error handling」「Contexts」
- The Go Memory Model:並行処理の厳密な保証
- Go Wiki:CommonMistakes、SliceTricks
動画・講演
- 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. キャリアの次の一歩
ポートフォリオの次に作ると差がつくもの
- CLI ツールを公開して
go installで配布:小さくても「ユーザーがいる」経験 - OSS への貢献:ドキュメント修正、テスト追加から。Issue の「good first issue」ラベルを探す
- 技術記事:詰まった点とその解決を書く。第2回の内容を自分の言葉で書き直すだけでも価値がある
- 既存プロジェクトの改善提案:使っているライブラリの Issue に再現コードつきで報告する
面接で聞かれること
- goroutine と OS スレッドの違い、スケジューラの概要
- channel と Mutex の使い分け
contextの役割、キャンセルの伝播- インターフェースの暗黙的実装、
nilインターフェースの罠 - エラーハンドリングの方針(
errors.Is/As、ラップ) - スライスの内部構造と
appendの挙動 deferの評価タイミングdatabase/sqlのコネクションプール- 設計した API のエンドポイント、認証方式、なぜその選択をしたか
このシリーズを一通り手を動かして終えていれば、すべて 自分の経験として 語れるはずです。
実務で最初に求められること
- 既存コードを読んで、規約に沿って追加・修正できる
- テストを書き、CI を通す
- PR の説明を書き、レビューを受けて修正する
- ログとメトリクスから障害の原因を追える
- 「分からないことを分からないと言い、調べて報告する」
言語の知識よりこれらの習慣が評価されます。第6回の Git 作法と README、第7回の観測性はそのためのものです。
15. 演習問題
設計
- モジュラーモノリス化:第6回の Todo API を
internal/todo/、internal/user/のドメイン単位に再編成する。モジュール間は各api.goのインターフェースを通じてのみ依存させる。循環参照が起きた場合はどう解消したか記録する - Consumer-defined interface:service 層のインターフェースを、使うメソッドだけに絞った小さなインターフェース(
todoGetter、todoUpdater)に分割する。モックの実装量がどれだけ減るか比較する - エラー設計レビュー:自分のコードで
errors.Is/Asで判定しているエラーをすべて列挙し、「予期される失敗 / 入力エラー / 予期しない失敗」に分類する。分類できないものは設計を見直す
観測性
- Prometheus 導入:RED メトリクスを実装し、Grafana でダッシュボードを作る。
compose.yamlに Prometheus と Grafana を追加し、docker compose upでグラフが見える状態にする - トレーシング導入:OpenTelemetry + Jaeger で、1 リクエストの HTTP → service → SQL のスパンが見えるようにする。意図的に遅い SQL(
pg_sleep(1))を入れ、トレースで特定できることを確認する - pprof で最適化:
GET /todosに 10 万件の Todo を返す負荷をかけ(heyやvegeta)、CPU プロファイルを取る。最も時間を使っている関数を特定し、改善してbenchstatで効果を示す
gRPC
- gRPC 化:Todo API の
Create/Get/Listを gRPC でも提供する。同じ service 層を使い、REST と gRPC が同じ振る舞いをすることをテストで確認する - ストリーミング:
WatchTodosサーバーストリーミングを実装し、別クライアントが Todo を作成するとリアルタイムに通知が届くようにする(内部は channel で fan-out) - Connect 移行:gRPC サーバーを Connect に置き換え、
curlで JSON を POST して呼べることと、grpcurlで gRPC として呼べることの両方を確認する
読解とアウトプット
- chi を読む:
chi.MuxのrouteHTTPから、パスパラメータがどう抽出されてr.Context()に入るかを追い、300 字で説明する - 標準ライブラリを読む:
net/httpのServer.Serveを読み、リクエストごとに goroutine が起動される箇所と、Shutdownが処理中のコネクションをどう待つかを特定する - 記事を書く:このシリーズの中で最も苦労した回について、「何に詰まり、どう理解したか」を 2000 字程度の技術記事にして公開する
おわりに ── 7 回を振り返って
| 回 | 身についたこと |
|---|---|
| 1 | 文法、インターフェース、エラー処理という Go の骨格 |
| 2 | io.Reader / io.Writer の抽象、テストの書き方 |
| 3 | http.Handler を中心とした HTTP の仕組み |
| 4 | SQL とトランザクション、DB を含めたテスト |
| 5 | 本番運用の道具と、並行処理の設計 |
| 6 | 層の分離、依存注入、他人に伝える技術 |
| 7 | 慣習の背景、観測性、性能、学び続ける方法 |
Go は「覚えることが少ない」言語です。だからこそ、差がつくのは 文法ではなく判断 です。どこに境界を引くか、どのエラーを判定可能にするか、いつ抽象化するか、何を計測するか。このシリーズで扱ったのはすべてその判断の材料です。
最後に、最も重要な習慣を 3 つ。
- 書く前に読む:標準ライブラリと良い OSS のコードを読み続ける
- 推測せず計測する:
-race、pprof、ベンチマーク、EXPLAIN ANALYZE - 小さく始めて必要になったら足す:抽象化も、マイクロサービスも、ライブラリも
分からないことが出てきたら、そのコードを持って質問してください。7 回分の内容はいつでも参照できます。
Analyzegear