Go標準ライブラリで作るREST APIサーバー:JWT認証とミドルウェアパターンの実践
はじめに
Goでバックエンド開発を行う際、GinやEchoといった高機能なWebフレームワークを選択することが一般的です。しかし、標準ライブラリだけでも十分に実用的なREST APIサーバーを構築できます。標準ライブラリによる実装には、外部依存の削減、学習コストの低減、長期的な保守性の向上といった利点があります。
本記事では、標準ライブラリを最大限活用したREST APIサーバーの実装パターンを解説します。具体的には、JWT認証の自作実装、ミドルウェアチェーン、レート制限、バリデーション、CRUD操作、構造化ロギングを扱います。これらの実装を通じて、Goらしい設計パターンと実践的なコーディング技法を学べます。
対象読者は、Goの基本文法を理解しており、HTTPサーバーの構築経験がある中級者以上を想定しています。完全なソースコードはGitHubで公開しています。
アーキテクチャ概要
本システムは、責務を明確に分離したレイヤードアーキテクチャを採用しています。この設計選択には、保守性、テスタビリティ、拡張性の観点から明確な理由があります。
なぜレイヤードアーキテクチャなのか
標準ライブラリでの実装において、レイヤードアーキテクチャを選択した理由は以下のとおりです。
-
学習曲線の緩やかさ: クリーンアーキテクチャやヘキサゴナルアーキテクチャと比較して、概念がシンプルで理解しやすいです。各層の責務が直感的に把握できます。
-
標準ライブラリとの親和性: Goの
net/http
パッケージは、ハンドラー関数を中心とした設計です。レイヤードアーキテクチャは、この設計思想と自然に調和します。 -
段階的な成長: 小規模なプロジェクトから始めて、必要に応じて各層を細分化できます。初期は単純な構造で、複雑性の増加に応じて発展させられます。
-
テストの容易性: 各層が独立しているため、モックへの差し替えが簡単です。データベース層をモック化してビジネスロジックをテストする、といった戦略が実現できます。
ディレクトリ構成と責務分離
各レイヤーの役割を以下に示します。
├── main.go # エントリーポイント、依存性注入
├── handlers/ # HTTPハンドラー層
│ ├── auth_handler.go # 認証ハンドラー
│ ├── task_handler.go # タスクハンドラー
│ ├── verification_handler.go # メール認証ハンドラー
│ ├── upload_handler.go # 画像アップロードハンドラー
│ └── response.go # レスポンスヘルパー
├── database/ # データベース層
│ ├── db.go # DB接続とマイグレーション
│ ├── migrations/ # SQLマイグレーション
│ │ ├── 001_init.sql
│ │ ├── 002_seed.sql
│ │ └── 003_add_image_url.sql
│ └── repository/ # データアクセス層
│ ├── user_repository.go
│ ├── task_repository.go
│ └── verification_repository.go
├── middleware/ # ミドルウェア層
│ ├── middleware.go # 認証・CORS・ロギング
│ └── rate_limiter.go # レート制限
├── validation/ # バリデーション層
│ └── validator.go # 入力検証ロジック
├── models/ # モデル定義
│ └── models.go # APIモデル
├── utils/ # ユーティリティ
│ ├── jwt.go # JWT生成と検証
│ └── password.go # パスワードハッシュ化
├── storage/ # ストレージサービス
│ └── storage.go # ローカル/S3ストレージ
└── email/ # メールサービス
└── email_service.go # メール送信
このアーキテクチャでは、ハンドラーがビジネスロジックを担当し、リポジトリがデータアクセスを抽象化します。ミドルウェアは横断的関心事を処理し、バリデーションは入力検証を専門に行います。サービス層(storage、email)は外部システムとの連携を抽象化し、テスタビリティを向上させています。
Repositoryパターンの利点
データアクセス層をRepositoryパターンで抽象化することには、以下の具体的な利点があります。
-
データベース実装の交換可能性
// リポジトリインターフェース type UserRepository interface { GetByID(id string) (*User, error) GetByEmail(email string) (*User, error) Create(user *User) error Update(user *User) error } // SQLite実装 type SQLiteUserRepository struct { db *sql.DB } // PostgreSQL実装(将来的に切り替え可能) type PostgreSQLUserRepository struct { db *sql.DB }
この設計により、たとえば、SQLiteからPostgreSQLへの移行時にハンドラー層のコードは変更する必要がありません。ただし、リポジトリ層ではSQLのプレースホルダー記法の違いに対応する必要があります。SQLiteは
?
を使用しますが、PostgreSQLは$1
,$2
のような位置パラメータを使用します。// SQLite版 query := `INSERT INTO users (id, email) VALUES (?, ?)` // PostgreSQL版 query := `INSERT INTO users (id, email) VALUES ($1, $2)`
とはいえ、ビジネスロジックを含むハンドラー層は影響を受けないため、データベース変更の影響範囲を最小限に抑えられます。
-
テストの容易性
// モックリポジトリ(テスト用) type MockUserRepository struct { users map[string]*User } func (m *MockUserRepository) GetByEmail(email string) (*User, error) { for _, user := range m.users { if user.Email == email { return user, nil } } return nil, nil } // ハンドラーのテスト例 func TestHandleLogin(t *testing.T) { mockRepo := &MockUserRepository{ users: map[string]*User{ "1": {ID: "1", Email: "test@example.com", PasswordHash: "..."}, }, } handler := NewAuthHandler(mockRepo, mockVerificationRepo, mockEmailService, logger) // テスト実行... }
モックリポジトリを注入することで、実際のデータベースなしでハンドラーのロジックをテストできます。これにより、テストの実行速度が向上し、外部依存による不安定性を排除できます。
-
クエリの一元管理
複雑なSQLクエリをリポジトリ層に集約することで、以下の利点が得られます。
- SQLインジェクション対策の一元化
- クエリのパフォーマンスチューニングが容易
- データベーススキーマ変更時の影響範囲の限定
-
トランザクション管理の抽象化
リポジトリ層でトランザクションを管理することで、ビジネスロジック層はトランザクション処理の詳細を意識する必要がなくなります。
依存性注入パターン
依存性注入(Dependency Injection, DI)は、コンポーネント間の結合度を下げ、柔軟で保守性の高いアプリケーションを構築するための設計パターンです。
なぜ依存性注入を使うのか
従来の実装では、コンポーネントが必要な依存オブジェクトを自分自身で生成します。
// ❌ 依存性注入を使わない実装(密結合)
type AuthHandler struct {
userRepo repository.UserRepository
}
func NewAuthHandler() *AuthHandler {
// ハンドラー内部で具体的な実装を直接生成
db, _ := sql.Open("postgres", "connection_string")
userRepo := repository.NewUserRepository(db)
return &AuthHandler{
userRepo: userRepo,
}
}
この設計には以下の問題があります。
- テストが困難: ハンドラーをテストする際、必ず実際のデータベース接続が必要になります
- 実装の交換が困難: PostgreSQLからMySQLに変更する場合、ハンドラーのコードを変更する必要があります
- 設定の柔軟性がない: 接続文字列がハードコードされており、環境ごとの切り替えができません
依存性注入を使用すると、これらの問題を解決できます。
// ✅ 依存性注入を使った実装(疎結合)
type AuthHandler struct {
userRepo repository.UserRepository
verificationRepo repository.VerificationRepository
emailService email.EmailSender
tokenService utils.TokenService
passwordHasher utils.PasswordHasher
logger *slog.Logger
}
func NewAuthHandler(
userRepo repository.UserRepository,
verificationRepo repository.VerificationRepository,
emailService email.EmailSender,
tokenService utils.TokenService,
passwordHasher utils.PasswordHasher,
logger *slog.Logger,
) *AuthHandler {
return &AuthHandler{
userRepo: userRepo,
verificationRepo: verificationRepo,
emailService: emailService,
tokenService: tokenService,
passwordHasher: passwordHasher,
logger: logger,
}
}
この設計により、以下の利点が得られます。
- テスタビリティの向上: モックオブジェクトを注入することで、外部依存なしでテストできます
- 柔軟性の向上: 実装を簡単に切り替えられます(例: ローカルストレージ ↔ S3ストレージ)
- 関心の分離: オブジェクトの生成と使用を分離し、各コンポーネントは自身の責務に集中できます
- 設定の一元管理: main.goでアプリケーション全体の依存関係を一箇所で管理できます
Composition Root パターン
main.goは、アプリケーションのエントリーポイントであり、全ての依存関係を組み立てる唯一の場所です。これをComposition Rootと呼びます。
func main() {
// ロガー初期化
logger := initLogger()
// データベース初期化
db, err := database.NewDB(dbURL, logger)
if err != nil {
logger.Error("データベース接続失敗", "error", err)
os.Exit(1)
}
// リポジトリ層の初期化
userRepo := repository.NewUserRepository(db.DB)
taskRepo := repository.NewTaskRepository(db.DB)
verificationRepo := repository.NewVerificationRepository(db.DB)
// サービス層の初期化
emailService := email.NewEmailService(logger)
storageService := storage.NewStorageService(logger)
tokenService := utils.NewTokenService(logger)
passwordHasher := utils.NewPasswordHasher()
// ミドルウェアの初期化
middleware.InitRateLimiters(logger)
// ハンドラー層の初期化(依存性を注入)
authHandler := handlers.NewAuthHandler(
userRepo,
verificationRepo,
emailService,
tokenService,
passwordHasher,
logger,
)
taskHandler := handlers.NewTaskHandler(taskRepo, logger)
verificationHandler := handlers.NewVerificationHandler(
userRepo,
verificationRepo,
emailService,
logger,
)
uploadHandler := handlers.NewUploadHandler(storageService, logger)
// ルーティング設定
mux := http.NewServeMux()
mux.HandleFunc("/auth/login", middleware.LoggingMiddleware(
middleware.AuthRateLimiter.Middleware(
middleware.CORSMiddleware(authHandler.HandleLogin)
)
))
mux.HandleFunc("/tasks", middleware.LoggingMiddleware(
middleware.TaskRateLimiter.Middleware(
middleware.AuthMiddleware(
middleware.CORSMiddleware(taskHandler.HandleTasks)
)
)
))
// サーバー起動
logger.Info("サーバーを起動しました", "port", port)
http.ListenAndServe(":"+port, mux)
}
このComposition Rootパターンにより、依存関係のグラフが明確になり、アプリケーションの構造を一目で理解できます。また、main.go以外のコードは依存関係の生成を一切行わないため、純粋にビジネスロジックに集中できます。
インターフェースによる抽象化
依存性注入の効果を最大化するため、具体的な実装ではなくインターフェースに依存します。
// インターフェース定義
type UserRepository interface {
GetByID(id string) (*User, error)
GetByEmail(email string) (*User, error)
Create(user *User) error
Update(user *User) error
}
type EmailSender interface {
SendVerificationCode(ctx context.Context, to, name, code string) error
SendWelcomeEmail(ctx context.Context, to, name string) error
}
type StorageService interface {
Upload(ctx context.Context, file multipart.File, filename string) (string, error)
Delete(ctx context.Context, filename string) error
GetURL(filename string) string
}
インターフェースを使用することで、以下の利点が得られます。
- 実装の交換が容易: 新しい実装を作成してインターフェースを満たせば、すぐに切り替えられます
- テストが簡単: モック実装を作成してテストに使用できます
- 並行開発が可能: インターフェースを先に定義すれば、実装を待たずに開発を進められます
この設計により、各レイヤーが独立してテスト可能になり、実装の差し替えが容易になります。例えば、ローカルストレージをS3ストレージに交換する際、ハンドラー層のコードは変更する必要がありません。
ミドルウェアのチェーン化により、各エンドポイントに対して柔軟に機能を組み合わせられます。認証が必要なエンドポイントにはAuthMiddleware
を、全てのエンドポイントにはLoggingMiddleware
とCORSMiddleware
を適用するといった制御が可能です。
JWT認証の標準ライブラリ実装
外部ライブラリを使わずにJWT認証を実装することで、JWTの内部構造と認証フローを深く理解できます。ここでは、crypto/hmac
とcrypto/sha256
を使ったHS256アルゴリズムの実装を解説します。
トークンサービスの設計
JWT機能をインターフェースとして定義することで、テスト可能性と拡張性を向上させています。
// TokenService JWT トークンサービスインターフェース
type TokenService interface {
GenerateAccessToken(userID, email string) (string, error)
GenerateRefreshToken(userID, email string) (string, error)
VerifyToken(tokenString string) (*JWTClaims, error)
}
// JWTTokenService JWT トークンサービス実装
type JWTTokenService struct {
logger *slog.Logger
}
func NewTokenService(l *slog.Logger) TokenService {
return &JWTTokenService{logger: l}
}
この設計により、テスト時にモックトークンサービスを注入でき、実装の差し替えも容易になります。
トークン生成の実装
JWTトークンは、ヘッダー、ペイロード、署名の3つの部分から構成されます。各部分をBase64URLエンコードし、ドット(.)で連結します。
func (s *JWTTokenService) generateToken(userID, email string, expiration time.Duration) (string, error) {
now := time.Now()
claims := JWTClaims{
UserID: userID,
Email: email,
Exp: now.Add(expiration).Unix(),
Iat: now.Unix(),
}
// ヘッダー
header := map[string]string{
"alg": "HS256",
"typ": "JWT",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
// ペイロード
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
// Base64URLエンコード
headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON)
claimsEncoded := base64.RawURLEncoding.EncodeToString(claimsJSON)
// 署名作成
message := headerEncoded + "." + claimsEncoded
signature := createSignature(message, getJWTSecret())
token := message + "." + signature
s.logger.Debug("JWTトークン生成成功",
"user_id", userID,
"expiration_minutes", expiration.Minutes(),
)
return token, nil
}
署名の作成には、HMAC-SHA256を使用します。この署名により、トークンが改ざんされていないことを保証できます。
func createSignature(message string, secret []byte) string {
h := hmac.New(sha256.New, secret)
h.Write([]byte(message))
signature := h.Sum(nil)
return base64.RawURLEncoding.EncodeToString(signature)
}
アクセストークンとリフレッシュトークンは、有効期限を変えて生成します。アクセストークンは15分、リフレッシュトークンは7日間の有効期限を設定しています。
func (s *JWTTokenService) GenerateAccessToken(userID, email string) (string, error) {
return s.generateToken(userID, email, 15*time.Minute)
}
func (s *JWTTokenService) GenerateRefreshToken(userID, email string) (string, error) {
return s.generateToken(userID, email, 7*24*time.Hour)
}
この短い有効期限により、トークンが盗まれた場合の被害を最小限に抑えられます。リフレッシュトークンを使用することで、ユーザーは頻繁にログインする必要がなくなります。
トークン検証の実装
トークン検証では、署名の正当性と有効期限を確認します。
func (s *JWTTokenService) VerifyToken(tokenString string) (*JWTClaims, error) {
// トークンを3つの部分に分割
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
return nil, errors.New("invalid token format")
}
headerEncoded := parts[0]
claimsEncoded := parts[1]
signatureEncoded := parts[2]
// 署名検証
message := headerEncoded + "." + claimsEncoded
expectedSignature := createSignature(message, getJWTSecret())
if signatureEncoded != expectedSignature {
s.logger.Warn("JWT署名検証失敗")
return nil, errors.New("invalid signature")
}
// クレームをデコード
claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsEncoded)
if err != nil {
return nil, fmt.Errorf("failed to decode claims: %w", err)
}
var claims JWTClaims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
}
// 有効期限チェック
now := time.Now().Unix()
if now > claims.Exp {
s.logger.Debug("トークン期限切れ",
"exp", claims.Exp,
"now", now,
"user_id", claims.UserID,
)
return nil, errors.New("token expired")
}
s.logger.Debug("トークン検証成功", "user_id", claims.UserID, "email", claims.Email)
return &claims, nil
}
署名の検証に失敗した場合や有効期限が切れている場合は、エラーを返します。これにより、改ざんされたトークンや古いトークンの使用を防ぎます。構造化ログにより、トークン検証の詳細な情報を記録しています。
秘密鍵の遅延初期化
JWT秘密鍵は、環境変数から取得します。sync.Once
を使用して、初回アクセス時のみ初期化を行います。
var (
jwtSecret []byte
jwtSecretOnce sync.Once
)
func getJWTSecret() []byte {
jwtSecretOnce.Do(func() {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
// 本番環境では秘密鍵が必須
fmt.Println("エラー: JWT_SECRETが設定されていません。")
fmt.Println("以下のコマンドで秘密鍵を生成してください:")
fmt.Println(" openssl rand -base64 32")
fmt.Println("その後、環境変数を設定してください:")
fmt.Println(" export JWT_SECRET=\"生成された秘密鍵\"")
os.Exit(1)
}
jwtSecret = []byte(secret)
})
return jwtSecret
}
sync.Once
により、複数のゴルーチンから同時にアクセスされても、初期化処理は一度だけ実行されます。これは、Goの並行処理における典型的なイディオムです。本番環境では秘密鍵が未設定の場合、アプリケーションを起動させないことでセキュリティを担保しています。
ミドルウェアパターンの実装
ミドルウェアは、HTTPリクエストとレスポンスの間に介在し、横断的な処理を行います。Goの標準ライブラリでは、http.HandlerFunc
をラップする関数として実装します。
ロギングミドルウェア
全てのリクエストとレスポンスをログに記録するミドルウェアです。レスポンスのステータスコードをキャプチャするため、カスタムのresponseWriter
を実装しています。
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func LoggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// レスポンスラッパーでステータスコードをキャプチャ
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
// リクエスト開始ログ
logger.Debug("リクエスト開始",
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
next(wrapped, r)
duration := time.Since(start)
// リクエスト完了ログ
logLevel := slog.LevelInfo
if wrapped.statusCode >= 500 {
logLevel = slog.LevelError
} else if wrapped.statusCode >= 400 {
logLevel = slog.LevelWarn
}
logger.Log(r.Context(), logLevel, "リクエスト完了",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", duration.Milliseconds(),
"remote_addr", r.RemoteAddr,
)
}
}
この実装により、ステータスコードに応じて適切なログレベルを自動的に選択できます。500番台のエラーはErrorレベル、400番台はWarnレベル、それ以外はInfoレベルでログ出力されます。リクエスト開始時にはDebugレベルでログを記録し、詳細な診断情報を提供しています。
CORS対応ミドルウェア
クロスオリジンリクエストとは
Webブラウザには、セキュリティ上の理由から「同一オリジンポリシー」という制限があります。これは、あるオリジン(プロトコル + ドメイン + ポート)で動作しているWebページが、異なるオリジンのリソースにアクセスすることを制限する仕組みです。
例えば、以下のような状況でクロスオリジンリクエストが発生します。
- フロントエンド:
https://example.com
- バックエンドAPI:
https://api.example.com
この場合、ドメインが異なるため、ブラウザはデフォルトでAPIへのリクエストをブロックします。これを解決するのがCORS(Cross-Origin Resource Sharing)です。
なぜCORSが必要か
現代のWebアプリケーションでは、フロントエンドとバックエンドを分離して開発することが一般的です。
-
開発環境: フロントエンド(
http://localhost:3000
)とバックエンド(http://localhost:8080
)が異なるポートで動作 -
本番環境: フロントエンド(
https://app.example.com
)とAPI(https://api.example.com
)が異なるドメインで動作
CORSを適切に設定しないと、ブラウザが以下のようなエラーを表示してAPIリクエストが失敗します。
Access to fetch at 'http://localhost:8080/api/tasks' from origin
'http://localhost:3000' has been blocked by CORS policy
サーバー側でCORSヘッダーを返すことで、「このオリジンからのリクエストは安全である」とブラウザに伝え、クロスオリジンリクエストを許可できます。
CORS対応ミドルウェアの実装
本番環境では、許可するオリジンを環境変数で制限します。
func CORSMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// 許可されたオリジンを環境変数から取得
allowedOrigins := getAllowedOrigins()
// オリジンチェック
if isOriginAllowed(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
} else if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
// 開発環境: 全てのオリジンを許可
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
logger.Warn("許可されていないオリジン", "origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "7200")
// プリフライトリクエストの処理
if r.Method == http.MethodOptions {
logger.Debug("CORSプリフライトリクエスト",
"origin", origin,
"method", r.Header.Get("Access-Control-Request-Method"),
)
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// getAllowedOrigins 許可されたオリジンを取得
func getAllowedOrigins() []string {
originsEnv := os.Getenv("ALLOWED_ORIGINS")
if originsEnv == "" {
// デフォルト: 開発環境用(全て許可)
return []string{"*"}
}
// カンマ区切りで分割
var origins []string
for _, origin := range strings.Split(originsEnv, ",") {
trimmed := strings.TrimSpace(origin)
if trimmed != "" {
origins = append(origins, trimmed)
}
}
return origins
}
// isOriginAllowed オリジンが許可されているかチェック
func isOriginAllowed(origin string, allowedOrigins []string) bool {
for _, allowed := range allowedOrigins {
if allowed == "*" || allowed == origin {
return true
}
}
return false
}
OPTIONSメソッドは、ブラウザがプリフライトリクエストとして送信します。このリクエストに対しては、CORSヘッダーを返すだけで処理を終了します。許可されていないオリジンからのリクエストは警告ログを出力します。
認証ミドルウェア
なぜJWT認証が必要なのか
REST APIは通常ステートレスな設計を目指します。つまり、サーバーはクライアントの状態を保持しません。しかし、ユーザーが誰であるかを識別する必要があります。ここで認証の仕組みが必要になります。
従来のセッションベース認証では、サーバー側でセッション情報を保持する必要がありました。
// ❌ セッションベース認証の課題
// - サーバー側でセッションストアが必要(メモリ/Redis等)
// - 複数サーバーでセッションを共有する必要がある
// - サーバーの水平スケーリングが困難
sessions := make(map[string]*UserSession) // サーバーメモリに保存
これに対して、JWT(JSON Web Token)認証には以下の特徴があります。
JWTの利点
ステートレス性
サーバーは何も記憶する必要がありません。トークン自体にユーザー情報が含まれており、署名により改ざんを検証できます。
// ✅ JWT認証はステートレス
// トークンに全ての情報が含まれている
claims := JWTClaims{
UserID: "user123",
Email: "user@example.com",
Exp: time.Now().Add(15 * time.Minute).Unix(),
}
スケーラビリティ
複数のAPIサーバーが存在しても、各サーバーは独立してトークンを検証できます。セッションストアの共有が不要です。
マイクロサービスとの親和性
トークンを一度発行すれば、異なるマイクロサービス間で同じトークンを使用できます。各サービスは同じ秘密鍵でトークンを検証するだけです。
モバイルアプリとの互換性
Cookieベースのセッション管理と異なり、HTTPヘッダーでトークンを送信するため、モバイルアプリやSPAから簡単に利用できます。
他の認証方式との比較
認証方式 | サーバー状態 | スケーリング | 実装の複雑さ |
---|---|---|---|
セッションベース | 必要(セッションストア) | 困難(共有ストア必要) | 中 |
JWT | 不要(ステートレス) | 容易(独立して検証) | 中 |
Basic認証 | 不要 | 容易 | 低(ただしセキュリティ懸念) |
OAuth 2.0 | 外部サービス依存 | 容易 | 高 |
認証ミドルウェアの実装
JWTトークンを検証し、認証済みリクエストのみを通過させるミドルウェアです。
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Authorizationヘッダーからトークンを取得
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
logger.Warn("認証ヘッダーが見つかりません",
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
)
respondWithError(w, http.StatusUnauthorized, "Authorization header required")
return
}
// "Bearer "プレフィックスを削除
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
logger.Warn("無効な認証形式",
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
)
respondWithError(w, http.StatusUnauthorized, "Invalid authorization format")
return
}
// トークン検証
claims, err := utils.VerifyToken(tokenString)
if err != nil {
logger.Warn("トークン検証失敗",
"error", err,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
)
respondWithError(w, http.StatusUnauthorized, "Invalid or expired token")
return
}
logger.Debug("認証成功",
"user_id", claims.UserID,
"email", claims.Email,
"path", r.URL.Path,
)
// クレームをコンテキストに追加(簡易実装のためヘッダーに設定)
r.Header.Set("X-User-ID", claims.UserID)
r.Header.Set("X-User-Email", claims.Email)
next(w, r)
}
}
本実装では、検証済みのユーザー情報をリクエストヘッダーに設定していますが、これは簡易的な方法です。本番環境では、context.Context
を使用してユーザー情報を伝播させることが推奨されます。構造化ログにより、認証の失敗理由を詳細に記録しています。
context.Contextによる認証情報の伝播
より洗練された実装として、context.Context
を使用する方法を示します。
// コンテキストキーの定義(型安全性のため独自型を使用)
type contextKey string
const (
userIDKey contextKey = "userID"
userEmailKey contextKey = "userEmail"
)
// 認証ミドルウェア(context版)
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
respondWithError(w, http.StatusUnauthorized, "Authorization header required")
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
respondWithError(w, http.StatusUnauthorized, "Invalid authorization format")
return
}
claims, err := VerifyToken(tokenString)
if err != nil {
respondWithError(w, http.StatusUnauthorized, "Invalid or expired token")
return
}
// コンテキストにユーザー情報を格納
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, userEmailKey, claims.Email)
// 新しいコンテキストでリクエストを更新
next(w, r.WithContext(ctx))
}
}
// ハンドラーでコンテキストからユーザー情報を取得
func (h *TaskHandlerNew) HandleTasks(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(userIDKey).(string)
if !ok || userID == "" {
respondWithError(w, http.StatusUnauthorized, "User ID not found")
return
}
// userIDを使用してビジネスロジックを実行
switch r.Method {
case http.MethodGet:
h.getTasks(w, r.Context(), userID)
case http.MethodPost:
h.createTask(w, r.Context(), userID)
}
}
この実装の利点は以下のとおりです。
-
型安全性: 独自の型をキーとして使用することで、他のパッケージとのキー衝突を防ぎます。
-
明示的な依存関係: ハンドラー関数のシグネチャを見るだけで、コンテキストを使用していることが分かります。
-
テストの容易性: テスト時に任意のコンテキスト値を注入できます。
func TestGetTasks(t *testing.T) { // テスト用コンテキストの作成 ctx := context.WithValue(context.Background(), userIDKey, "test-user-123") req := httptest.NewRequest(http.MethodGet, "/tasks", nil).WithContext(ctx) // テスト実行... }
-
リクエストスコープの保証: コンテキストはリクエストのライフサイクルに紐づいており、リクエスト間でデータが漏洩する心配がありません。
ミドルウェアのチェーン化
複数のミドルウェアを組み合わせることで、柔軟なエンドポイント設定が可能です。
mux.HandleFunc("/tasks",
middleware.LoggingMiddleware(
middleware.TaskRateLimiter.Middleware(
middleware.AuthMiddleware(
middleware.CORSMiddleware(taskHandler.HandleTasks)
)
)
)
)
ミドルウェアは、外側から内側に向かって実行されます。この例では、ログ記録→レート制限→認証→CORS→ハンドラーの順で処理されます。この順序により、全てのリクエストをログに記録しつつ、認証が必要なエンドポイントのみを保護できます。
レート制限ミドルウェア
APIへの過剰なリクエストを防ぐため、レート制限ミドルウェアを実装します。golang.org/x/time/rate
パッケージを使用して、トークンバケットアルゴリズムを実装します。
type RateLimiter struct {
visitors map[string]*rate.Limiter
mu sync.RWMutex
r rate.Limit
b int
logger *slog.Logger
}
func NewRateLimiter(r rate.Limit, b int, logger *slog.Logger) *RateLimiter {
limiter := &RateLimiter{
visitors: make(map[string]*rate.Limiter),
r: r,
b: b,
logger: logger,
}
go limiter.cleanupVisitors()
return limiter
}
func (rl *RateLimiter) getVisitor(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
limiter, exists := rl.visitors[ip]
if !exists {
limiter = rate.NewLimiter(rl.r, rl.b)
rl.visitors[ip] = limiter
}
return limiter
}
IPアドレスごとに個別のリミッターを管理することで、各クライアントに対して独立したレート制限を適用できます。sync.RWMutex
により、並行アクセスを安全に処理しています。
レート制限をミドルウェアとして実装します。
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := getClientIP(r)
limiter := rl.getVisitor(ip)
if !limiter.Allow() {
rl.logger.Warn("レート制限超過",
"ip", ip,
"path", r.URL.Path,
)
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
Allow()
メソッドは、リクエストが許可されるかどうかを判定します。トークンバケットが空の場合、false
を返してリクエストを拒否します。
エンドポイントの重要度に応じて、異なるレート制限を設定します。
func InitRateLimiters(logger *slog.Logger) {
// 認証: 5リクエスト/分、バースト10
AuthRateLimiter = NewRateLimiter(rate.Limit(5.0/60.0), 10, logger)
// タスク: 60リクエスト/分、バースト100
TaskRateLimiter = NewRateLimiter(rate.Limit(1.0), 100, logger)
// アップロード: 10リクエスト/分、バースト15
UploadRateLimiter = NewRateLimiter(rate.Limit(10.0/60.0), 15, logger)
}
認証エンドポイントは、ブルートフォース攻撃を防ぐため、厳しいレート制限を設定しています。タスクAPIは通常使用を想定し、余裕のある設定です。
バーストサイズは、瞬間的な高負荷を許容するためのバッファです。例えば、ユーザーがページをリロードした際に複数のAPIリクエストが同時に発生しても、バースト範囲内であれば処理されます。
メモリリークを防ぐため、定期的に古いエントリを削除します。
func (rl *RateLimiter) cleanupVisitors() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
rl.visitors = make(map[string]*rate.Limiter)
rl.mu.Unlock()
rl.logger.Debug("レート制限キャッシュをクリアしました")
}
}
この実装では、5分ごとに全てのエントリをクリアしています。より洗練された実装では、最終アクセス時刻を記録し、一定時間アクセスがないエントリのみを削除することが推奨されます。
IPベースレート制限の注意点
企業環境からのアクセスでは、複数のユーザーが同一のプロキシサーバーを経由することがあります。この場合、全ユーザーが同じIPアドレスとして認識されるため、レート制限の閾値に早期に到達する可能性があります。
func getClientIP(r *http.Request) string {
// X-Forwarded-Forヘッダーをチェック(リバースプロキシ対応)
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
// 最初のIPを使用
return forwarded
}
// X-Real-IPヘッダーをチェック
realIP := r.Header.Get("X-Real-IP")
if realIP != "" {
return realIP
}
// RemoteAddrを使用
return r.RemoteAddr
}
この問題への対策として、以下のアプローチが考えられます。
-
ユーザーベースのレート制限: JWT認証後、ユーザーIDをキーとしてレート制限を適用します。これにより、個々のユーザーに対して公平な制限が可能です。
func (rl *RateLimiter) getUserBasedLimiter(userID string) *rate.Limiter { rl.mu.Lock() defer rl.mu.Unlock() limiter, exists := rl.visitors[userID] if !exists { limiter = rate.NewLimiter(rl.r, rl.b) rl.visitors[userID] = limiter } return limiter }
-
組織単位の高い閾値設定: 企業からのアクセスが予想されるエンドポイントには、より高いレート制限を設定します。
-
複合キーの使用: IPアドレスとユーザーIDを組み合わせたキーを使用することで、柔軟な制限が可能です。
key := fmt.Sprintf("%s:%s", ip, userID) limiter := rl.getVisitor(key)
-
ホワイトリスト機能: 信頼できる企業のIPアドレス範囲をホワイトリストに登録し、レート制限を緩和または除外します。
本番環境では、アクセスパターンを監視しながら、適切なレート制限戦略を選択することが重要です。
構造化ロギングミドルウェア
適切なロギングは、本番環境でのトラブルシューティングに不可欠です。Go 1.21で導入されたlog/slog
パッケージを使用して、構造化ロギングミドルウェアを実装します。
// カスタムResponseWriter(ステータスコードとサイズを記録)
type responseWriter struct {
http.ResponseWriter
status int
size int
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
size, err := rw.ResponseWriter.Write(b)
rw.size += size
return size, err
}
// ロギングミドルウェア
func loggingMiddleware(logger *slog.Logger) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// カスタムResponseWriterでラップ
wrapped := &responseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
// 次のハンドラーを実行
next(wrapped, r)
// ログ出力
duration := time.Since(start)
logger.Info("HTTP request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", duration.Milliseconds(),
"size_bytes", wrapped.size,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
}
}
}
この実装により、以下の情報が構造化されたJSON形式で出力されます。
{
"time": "2024-01-15T10:30:45.123Z",
"level": "INFO",
"msg": "HTTP request",
"method": "POST",
"path": "/api/tasks",
"status": 201,
"duration_ms": 45,
"size_bytes": 156,
"remote_addr": "192.168.1.100:54321",
"user_agent": "Mozilla/5.0..."
}
構造化ロギングの利点は以下のとおりです。
-
検索性: JSON形式で出力されるため、ログ集約システム(Elasticsearch、CloudWatch Logs等)で効率的に検索できます。
-
分析の容易性: ログをプログラマティックに解析し、パフォーマンス傾向や異常を検出できます。
-
コンテキスト情報の保持: 構造化されたフィールドにより、リクエスト全体の文脈が失われません。
-
パフォーマンスモニタリング: duration_msフィールドを集計することで、エンドポイントごとのレスポンス時間を監視できます。
ログレベルの使い分け例:
// Debug: 詳細な診断情報
logger.Debug("データベースクエリ実行", "query", sql, "params", params)
// Info: 通常の動作ログ
logger.Info("ユーザー登録完了", "user_id", userID, "email", email)
// Warn: 警告(処理は継続)
logger.Warn("レート制限接近", "ip", ip, "usage_percent", 85)
// Error: エラー(処理失敗)
logger.Error("データベース接続失敗", "error", err, "retry_count", retryCount)
本番環境では、ログレベルを環境変数で制御します。
func initLogger() *slog.Logger {
level := slog.LevelInfo
if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
switch logLevel {
case "DEBUG":
level = slog.LevelDebug
case "WARN":
level = slog.LevelWarn
case "ERROR":
level = slog.LevelError
}
}
opts := &slog.HandlerOptions{
Level: level,
AddSource: true, // ソースコードの位置を追加
}
handler := slog.NewJSONHandler(os.Stdout, opts)
return slog.New(handler)
}
バリデーションとエラーハンドリング
入力データの検証は、セキュリティとデータ整合性の観点から重要です。バリデーションロジックを専用パッケージに分離することで、再利用性とテスト容易性が向上します。
カスタムエラー型の定義
バリデーションエラーに、フィールド名とメッセージを含めるため、カスタムエラー型を定義します。
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
この型を使用することで、どのフィールドがどのような理由で検証に失敗したかを明確に伝えられます。
正規表現によるバリデーション
メールアドレスやパスワードの検証には、正規表現を使用します。
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
func ValidateEmail(email string) error {
email = strings.TrimSpace(email)
if email == "" {
return &ValidationError{Field: "email", Message: "メールアドレスは必須です"}
}
if len(email) > 255 {
return &ValidationError{Field: "email", Message: "メールアドレスは255文字以内である必要があります"}
}
if !emailRegex.MatchString(email) {
return &ValidationError{Field: "email", Message: "無効なメールアドレス形式です"}
}
return nil
}
パスワードのバリデーションでは、長さだけでなく、英字と数字を含むことを要求します。
func ValidatePassword(password string) error {
if password == "" {
return &ValidationError{Field: "password", Message: "パスワードは必須です"}
}
if len(password) < 8 {
return &ValidationError{Field: "password", Message: "パスワードは8文字以上である必要があります"}
}
hasLetter := regexp.MustCompile(`[a-zA-Z]`).MatchString(password)
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)
if !hasLetter || !hasDigit {
return &ValidationError{Field: "password", Message: "パスワードは英字と数字を含む必要があります"}
}
return nil
}
バリデーションの適用
ハンドラーでバリデーションを適用し、エラー時は適切なHTTPステータスコードを返します。
func (h *AuthHandlerNew) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := validation.ValidateEmail(req.Email); err != nil {
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
if err := validation.ValidatePassword(req.Password); err != nil {
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
if err := validation.ValidateName(req.Name); err != nil {
respondWithError(w, http.StatusBadRequest, err.Error())
return
}
// 以降の処理...
}
バリデーションエラーは400 Bad Requestで返します。これにより、クライアントは入力データの修正が必要であることを認識できます。
エラーレスポンスの統一化
全てのエラーレスポンスを統一した形式で返すため、ヘルパー関数を用意します。
type ErrorResponse struct {
Message string `json:"message"`
}
func respondWithError(w http.ResponseWriter, statusCode int, message string) {
respondWithJSON(w, statusCode, ErrorResponse{Message: message})
}
func respondWithJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(payload)
}
この統一化により、クライアント側でのエラー処理が簡潔になります。
RESTful CRUDの実装パターン
タスク管理APIを例に、RESTful CRUDの実装パターンを解説します。リソースベースのルーティング、認可、エラーハンドリングを含めた実装を示します。
リソースベースのルーティング
RESTの原則に従い、リソース(タスク)に対する操作をHTTPメソッドで表現します。
func (h *TaskHandlerNew) HandleTasks(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
if userID == "" {
respondWithError(w, http.StatusUnauthorized, "User ID not found")
return
}
switch r.Method {
case http.MethodGet:
h.getTasks(w, userID)
case http.MethodPost:
h.createTask(w, r, userID)
default:
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
/tasks
エンドポイントは、GETでタスク一覧の取得、POSTで新規タスクの作成を行います。個別のタスク操作は、/tasks/{id}
で処理します。
func (h *TaskHandlerNew) HandleTaskByID(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
path := strings.TrimPrefix(r.URL.Path, "/tasks/")
if strings.HasSuffix(path, "/complete") {
taskID := strings.TrimSuffix(path, "/complete")
h.completeTask(w, r, userID, taskID)
return
}
taskID := path
switch r.Method {
case http.MethodGet:
h.getTask(w, userID, taskID)
case http.MethodPut:
h.updateTask(w, r, userID, taskID)
case http.MethodDelete:
h.deleteTask(w, userID, taskID)
default:
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
所有者チェックによる認可
データの取得・変更時に、リソースの所有者であることを確認します。
func (h *TaskHandlerNew) getTask(w http.ResponseWriter, userID, taskID string) {
task, err := h.taskRepo.GetByID(taskID)
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Internal server error")
return
}
if task == nil {
respondWithError(w, http.StatusNotFound, "Task not found")
return
}
// 所有者チェック
if task.UserID != userID {
h.logger.Warn("タスクへのアクセス権限がありません",
"task_id", taskID,
"task_owner", task.UserID,
"requesting_user", userID,
)
respondWithError(w, http.StatusForbidden, "Access denied")
return
}
respondWithJSON(w, http.StatusOK, task)
}
この認可チェックにより、他のユーザーのタスクにアクセスすることを防ぎます。認可失敗時は403 Forbiddenを返し、認証は通ったがアクセス権限がないことを示します。
PATCH操作の実装
部分的な更新を行うPATCH操作を実装します。ここでは、タスクの完了状態を変更する例を示します。
func (h *TaskHandlerNew) completeTask(w http.ResponseWriter, r *http.Request, userID, taskID string) {
if r.Method != http.MethodPatch {
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
task, err := h.taskRepo.GetByID(taskID)
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Internal server error")
return
}
if task == nil {
respondWithError(w, http.StatusNotFound, "Task not found")
return
}
if task.UserID != userID {
respondWithError(w, http.StatusForbidden, "Access denied")
return
}
if err := h.taskRepo.Complete(taskID); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to complete task")
return
}
updatedTask, _ := h.taskRepo.GetByID(taskID)
respondWithJSON(w, http.StatusOK, updatedTask)
}
PATCH操作は、リソース全体ではなく一部のフィールドのみを更新する場合に使用します。この例では、完了フラグと完了日時のみを更新しています。
リポジトリ層との連携
ハンドラーはビジネスロジックに集中し、データアクセスはリポジトリに委譲します。
func (h *TaskHandlerNew) getTasks(w http.ResponseWriter, userID string) {
tasks, err := h.taskRepo.GetByUserID(userID)
if err != nil {
h.logger.Error("タスク一覧取得エラー", "error", err, "user_id", userID)
respondWithError(w, http.StatusInternalServerError, "Internal server error")
return
}
respondWithJSON(w, http.StatusOK, tasks)
}
この分離により、データベースの実装を変更してもハンドラーは影響を受けません。テスト時には、モックリポジトリを注入することで、データベースを使わずにテストできます。
トランザクション管理
複数のデータベース操作を一貫性を保って実行するため、トランザクション管理が重要です。リポジトリ層でトランザクションを抽象化することで、ビジネスロジック層はトランザクションの詳細を意識せずに済みます。
// トランザクションインターフェース
type Transaction interface {
Commit() error
Rollback() error
}
// リポジトリインターフェースにトランザクション対応を追加
type TaskRepository interface {
BeginTx(ctx context.Context) (Transaction, error)
GetByIDTx(ctx context.Context, tx Transaction, id string) (*Task, error)
CreateTx(ctx context.Context, tx Transaction, task *Task) error
UpdateTx(ctx context.Context, tx Transaction, task *Task) error
}
// SQLトランザクションの実装
type SQLTransaction struct {
tx *sql.Tx
}
func (t *SQLTransaction) Commit() error {
return t.tx.Commit()
}
func (t *SQLTransaction) Rollback() error {
return t.tx.Rollback()
}
// リポジトリでのトランザクション実装
func (r *TaskRepositoryImpl) BeginTx(ctx context.Context) (Transaction, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("トランザクション開始失敗: %w", err)
}
return &SQLTransaction{tx: tx}, nil
}
func (r *TaskRepositoryImpl) CreateTx(ctx context.Context, tx Transaction, task *Task) error {
sqlTx := tx.(*SQLTransaction).tx
query := `INSERT INTO tasks (id, user_id, title, description, created_at)
VALUES (?, ?, ?, ?, ?)`
_, err := sqlTx.ExecContext(ctx, query,
task.ID, task.UserID, task.Title, task.Description, task.CreatedAt)
if err != nil {
return fmt.Errorf("タスク作成失敗: %w", err)
}
return nil
}
ハンドラー層でのトランザクション使用例を示します。
func (h *TaskHandlerNew) createTaskWithRelations(w http.ResponseWriter, r *http.Request, userID string) {
var req TaskWithRelationsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
// トランザクション開始
tx, err := h.taskRepo.BeginTx(r.Context())
if err != nil {
h.logger.Error("トランザクション開始失敗", "error", err)
respondWithError(w, http.StatusInternalServerError, "Internal server error")
return
}
// deferでrollbackを設定(正常終了時はCommitで上書き)
defer func() {
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
h.logger.Error("ロールバック失敗", "error", rbErr)
}
}
}()
// タスク作成
task := &Task{
ID: generateID(),
UserID: userID,
Title: req.Title,
}
if err = h.taskRepo.CreateTx(r.Context(), tx, task); err != nil {
h.logger.Error("タスク作成失敗", "error", err)
respondWithError(w, http.StatusInternalServerError, "Failed to create task")
return
}
// 関連データの作成
for _, tag := range req.Tags {
if err = h.tagRepo.CreateTx(r.Context(), tx, task.ID, tag); err != nil {
h.logger.Error("タグ作成失敗", "error", err)
respondWithError(w, http.StatusInternalServerError, "Failed to create tags")
return
}
}
// コミット
if err = tx.Commit(); err != nil {
h.logger.Error("コミット失敗", "error", err)
respondWithError(w, http.StatusInternalServerError, "Failed to commit transaction")
return
}
h.logger.Info("タスクと関連データを作成しました", "task_id", task.ID)
respondWithJSON(w, http.StatusCreated, task)
}
この実装により、以下の利点が得られます。
-
データ整合性の保証: 複数の操作が全て成功するか、全て失敗するかを保証します。
-
エラー時の自動ロールバック: deferパターンにより、パニックやエラー時に確実にロールバックされます。
-
テストの容易性: トランザクションをインターフェースで抽象化しているため、モックトランザクションを注入してテストできます。
-
デッドロック対策: context.Contextを使用することで、タイムアウトやキャンセルに対応できます。
データベース設計とマイグレーション
本システムでは、PostgreSQLをデータベースとして使用しています。マイグレーション機能により、データベーススキーマのバージョン管理を行います。
データベース初期化
func NewDB(dbURL string, logger *slog.Logger) (*DB, error) {
db, err := sql.Open("postgres", dbURL)
if err != nil {
return nil, fmt.Errorf("データベース接続失敗: %w", err)
}
// 接続確認
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("データベースPing失敗: %w", err)
}
logger.Info("データベースに接続しました")
return &DB{DB: db, logger: logger}, nil
}
接続文字列は環境変数から取得します。
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
logger.Error("DATABASE_URLが設定されていません。環境変数を設定してください。")
logger.Info("例: export DATABASE_URL=\"postgresql://user:password@localhost:5432/dbname?sslmode=disable\"")
os.Exit(1)
}
マイグレーション管理
SQLファイルによるマイグレーション管理を採用しています。
-- 001_init.sql
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
is_verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tasks (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
due_date TIMESTAMP,
is_completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP,
priority VARCHAR(20) DEFAULT 'medium',
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_tags (
task_id VARCHAR(36) NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
tag VARCHAR(50) NOT NULL,
PRIMARY KEY (task_id, tag)
);
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_completed ON tasks(is_completed);
CREATE INDEX idx_task_tags_task_id ON task_tags(task_id);
マイグレーションは起動時に自動実行されます。
func (db *DB) Migrate() error {
migrations := []string{
"migrations/001_init.sql",
"migrations/002_seed.sql",
"migrations/003_add_image_url.sql",
}
for _, migration := range migrations {
if err := db.executeMigration(migration); err != nil {
return err
}
}
return nil
}
この設計により、スキーマ変更の履歴が明確になり、チーム開発での同期も容易になります。
メール認証機能の実装
ユーザー登録時にメール認証を要求することで、メールアドレスの正当性を確認します。
認証フローの設計
- ユーザーが登録リクエストを送信
- サーバーが未認証ユーザーを作成
- 6桁の認証コードを生成してメール送信
- ユーザーが認証コードを入力
- サーバーがコードを検証して認証完了
認証コード生成
func (r *verificationRepository) Create(userID string, expiration time.Duration) (*Verification, error) {
code := generateVerificationCode()
verification := &Verification{
ID: uuid.New().String(),
UserID: userID,
Code: code,
ExpiresAt: time.Now().Add(expiration),
CreatedAt: time.Now(),
}
query := `
INSERT INTO verifications (id, user_id, code, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5)
`
_, err := r.db.Exec(query, verification.ID, verification.UserID, verification.Code, verification.ExpiresAt, verification.CreatedAt)
return verification, err
}
func generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(1000000))
}
メール送信サービス
開発環境ではMailHog、本番環境ではResend APIを使用してメールを送信します。
type EmailService struct {
resendClient *resend.Client
smtpHost string
smtpPort string
from string
logger *slog.Logger
}
func (s *EmailService) SendVerificationCode(ctx context.Context, to, name, code string) error {
subject := "【Task Manager】認証コードのお知らせ"
body := s.buildVerificationEmailBody(name, code)
return s.sendEmail(to, subject, body)
}
環境に応じて適切な送信方法を選択します。
func (s *EmailService) sendEmail(to, subject, body string) error {
// 本番環境: Resend API
if s.resendClient != nil {
return s.sendViaResend(to, subject, body)
}
// 開発環境: SMTP(MailHog)
return s.sendViaSMTP(to, subject, body)
}
画像アップロード機能の実装
タスクに画像を添付できる機能を実装します。ストレージはローカルファイルシステムまたはS3互換ストレージを選択できます。
ストレージサービスの抽象化
type StorageService interface {
Upload(ctx context.Context, file multipart.File, filename string) (string, error)
Delete(ctx context.Context, filename string) error
GetURL(filename string) string
}
インターフェースを定義することで、ストレージの実装を切り替えられます。
ローカルストレージ実装
type LocalStorage struct {
uploadDir string
baseURL string
logger *slog.Logger
}
func (s *LocalStorage) Upload(ctx context.Context, file multipart.File, filename string) (string, error) {
filePath := filepath.Join(s.uploadDir, filename)
dst, err := os.Create(filePath)
if err != nil {
return "", fmt.Errorf("ファイル作成エラー: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
return "", fmt.Errorf("ファイル書き込みエラー: %w", err)
}
url := s.GetURL(filename)
s.logger.Info("ファイルアップロード成功(ローカル)", "filename", filename, "url", url)
return url, nil
}
S3ストレージ実装
type S3Storage struct {
client *s3.Client
bucket string
cdnURL string
logger *slog.Logger
}
func (s *S3Storage) Upload(ctx context.Context, file multipart.File, filename string) (string, error) {
data, err := io.ReadAll(file)
if err != nil {
return "", fmt.Errorf("ファイル読み込みエラー: %w", err)
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(filename),
Body: bytes.NewReader(data),
ContentType: aws.String(detectContentType(filename)),
})
if err != nil {
return "", fmt.Errorf("S3アップロードエラー: %w", err)
}
url := s.GetURL(filename)
s.logger.Info("ファイルアップロード成功(S3)", "filename", filename, "url", url)
return url, nil
}
アップロードハンドラー
func (h *UploadHandler) HandleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
// ファイルを取得(最大10MB)
if err := r.ParseMultipartForm(10 << 20); err != nil {
respondWithError(w, http.StatusBadRequest, "Failed to parse multipart form")
return
}
file, header, err := r.FormFile("image")
if err != nil {
respondWithError(w, http.StatusBadRequest, "Failed to get file")
return
}
defer file.Close()
// ファイル名生成
filename := generateUniqueFilename(header.Filename)
// ストレージにアップロード
url, err := h.storageService.Upload(r.Context(), file, filename)
if err != nil {
h.logger.Error("アップロード失敗", "error", err)
respondWithError(w, http.StatusInternalServerError, "Failed to upload file")
return
}
respondWithJSON(w, http.StatusOK, models.UploadResponse{URL: url})
}
本番環境への考慮事項
開発環境で動作するコードを本番環境で安全に運用するため、いくつかの考慮が必要です。
環境変数による設定管理
機密情報や環境依存の設定は、環境変数で管理します。開発環境では.env
ファイルを使用しますが、本番環境ではクラウドプロバイダーが提供するSecretサービスを使用することが推奨されます。
// データベース接続文字列
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
logger.Error("DATABASE_URLが設定されていません。")
os.Exit(1)
}
// JWT秘密鍵
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
logger.Warn("JWT_SECRETが設定されていません。本番環境では必ず設定してください。")
}
// CORS設定
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
if allowedOrigins == "" {
logger.Warn("ALLOWED_ORIGINSが設定されていません。全てのオリジンを許可します(開発環境用)。")
}
// ストレージ設定
storageType := os.Getenv("STORAGE_TYPE") // "local" or "s3"
環境変数が未設定の場合、開発用のデフォルト値を使用することで、ローカル開発時の手間を削減できます。
クラウド環境でのSecret管理
本番環境では、各クラウドプロバイダーのSecretサービスを使用して機密情報を管理します。
- AWS: AWS Secrets Manager / AWS Systems Manager Parameter Store
- Google Cloud: Google Cloud Secret Manager
- Azure: Azure Key Vault
これらのサービスからSecretを取得する際、ネットワーク遅延やAPIレート制限により取得に時間がかかる場合があります。アプリケーション起動時にこの問題を考慮した実装が必要です。
// リトライ機能付きSecret取得の実装例
func getSecretWithRetry(secretName string, maxRetries int) (string, error) {
var lastErr error
for i := 0; i < maxRetries; i++ {
secret, err := fetchSecretFromProvider(secretName)
if err == nil {
logger.Info("Secret取得成功",
"secret_name", secretName,
"attempt", i+1,
)
return secret, nil
}
lastErr = err
logger.Warn("Secret取得失敗、リトライします",
"secret_name", secretName,
"attempt", i+1,
"error", err,
)
// エクスポネンシャルバックオフ
time.Sleep(time.Duration(math.Pow(2, float64(i))) * time.Second)
}
return "", fmt.Errorf("Secret取得失敗(最大リトライ回数到達): %w", lastErr)
}
// フォールバック機能付き初期化
func initializeConfig() error {
// まずSecret Managerから取得を試みる
jwtSecret, err := getSecretWithRetry("JWT_SECRET", 3)
if err != nil {
logger.Error("Secret Managerからの取得に失敗", "error", err)
// フォールバック: 環境変数を確認
jwtSecret = os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return fmt.Errorf("JWT_SECRETの取得に失敗しました")
}
logger.Warn("環境変数からJWT_SECRETを取得しました(フォールバック)")
}
// 秘密鍵を設定
setJWTSecret(jwtSecret)
return nil
}
この実装により、以下の利点があります。
- リトライ処理: ネットワークの一時的な問題に対応できます
- エクスポネンシャルバックオフ: APIレート制限を考慮した待機時間の設定
- フォールバック: Secret Manager障害時に環境変数にフォールバック
- 詳細なログ: 問題発生時のトラブルシューティングが容易
ただし、フォールバックはあくまで緊急時の対応であり、本番環境では常にSecret Managerからの取得が成功することが前提です。アラート設定により、フォールバックが発生した場合に即座に通知を受け取る体制を整えることが重要です。
タイムアウト設定の重要性
Secret取得時には、適切なタイムアウトを設定します。
func fetchSecretFromProvider(secretName string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// AWS Secrets Manager の例
result, err := secretsClient.GetSecretValueWithContext(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
})
if err != nil {
return "", fmt.Errorf("secret取得エラー: %w", err)
}
return *result.SecretString, nil
}
タイムアウトを設定することで、Secret取得の遅延がアプリケーション全体の起動を妨げることを防ぎます。
パスワードの安全な保存
パスワードの保存方法は、アプリケーションセキュリティの最も重要な要素の一つです。不適切な保存方法は、データベース漏洩時に全ユーザーのパスワードが露出する深刻な事態を招きます。
危険な保存方法
以下の方法は、絶対に使用してはいけません。
-
平文での保存
// 絶対にやってはいけない例 type User struct { Email string Password string // 平文で保存 }
平文保存の問題点は、データベースが漏洩した場合、攻撃者が即座に全ユーザーのパスワードを取得できることです。また、多くのユーザーが複数のサービスで同じパスワードを使い回しているため、他のサービスへの不正アクセスにもつながります。
-
可逆暗号化での保存
// これも危険な例 func EncryptPassword(password string, key []byte) (string, error) { // AESなどの暗号化アルゴリズムを使用 encrypted := encrypt(password, key) return encrypted, nil }
可逆暗号化(AES、DESなど)でパスワードを保存する方法も不適切です。暗号化キーが漏洩すると、全てのパスワードが復号化されてしまいます。また、復号化する必要がないパスワード認証において、復号化可能な状態で保存すること自体がリスクです。
-
単純なハッシュ関数の使用
// これも不十分な例 func HashPasswordMD5(password string) string { hash := md5.Sum([]byte(password)) return hex.EncodeToString(hash[:]) }
MD5やSHA-1などの高速なハッシュ関数は、パスワード保存には不適切です。これらは計算が高速すぎるため、レインボーテーブル攻撃やブルートフォース攻撃に対して脆弱です。
bcryptによる適切な実装
パスワードは、bcryptアルゴリズムでハッシュ化して保存します。bcryptは、パスワード保存専用に設計されたハッシュ関数です。
const bcryptCost = 12
func HashPassword(password string) (string, error) {
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", fmt.Errorf("パスワードハッシュ化失敗: %w", err)
}
return string(hashedBytes), nil
}
func ComparePassword(hashedPassword, password string) error {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
if err != nil {
return fmt.Errorf("パスワードが一致しません")
}
return nil
}
bcryptのコスト係数12は、セキュリティと性能のバランスが取れた値です。この値により、ハッシュ化には約0.25秒かかり、ブルートフォース攻撃を効果的に防げます。
bcryptが安全な理由
-
ソルトの自動生成: bcryptは各パスワードに対してランダムなソルトを自動生成します。これにより、同じパスワードでも異なるハッシュ値が生成され、レインボーテーブル攻撃を無効化できます。
-
計算コストの調整: コスト係数により、ハッシュ化の計算時間を調整できます。コンピューターの性能向上に応じて、コスト係数を増やすことで将来的な攻撃にも対応できます。
-
不可逆性: bcryptは一方向ハッシュ関数であり、ハッシュ値から元のパスワードを復元することは計算量的に不可能です。
-
ブルートフォース攻撃への耐性: 意図的に計算を遅くすることで、攻撃者が大量のパスワードを試行することを困難にします。
データベース漏洩時の安全性
bcryptを使用することで、データベースが漏洩した場合でも、以下の保護が得られます。
// データベースに保存されるハッシュ値の例
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
// | | | |
// | | | ハッシュ値(31文字)
// | | ソルト(22文字)
// | コスト係数
// アルゴリズム識別子
このハッシュ値から元のパスワードを逆算することは、現実的な時間では不可能です。攻撃者は、各パスワード候補に対してbcryptのハッシュ化を実行する必要があり、コスト係数12の場合、1秒間に約4回しか試行できません。
パスワードポリシーとの組み合わせ
bcryptによるハッシュ化と併せて、適切なパスワードポリシーを実装します。
func ValidatePassword(password string) error {
if len(password) < 8 {
return errors.New("パスワードは8文字以上である必要があります")
}
hasLetter := regexp.MustCompile(`[a-zA-Z]`).MatchString(password)
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)
if !hasLetter || !hasDigit {
return errors.New("パスワードは英字と数字を含む必要があります")
}
return nil
}
この組み合わせにより、多層的なセキュリティを実現できます。
セキュリティベストプラクティス
本番環境では、以下のセキュリティ対策を実施します。
- HTTPS通信の強制:平文通信を禁止し、TLS 1.2以上を使用します
- JWT秘密鍵の適切な管理:十分に長く、ランダムな文字列を使用します
- レート制限の適切な設定:DDoS攻撃を防ぐため、厳しい制限を設定します
- CORS設定の厳格化:本番環境では、特定のオリジンのみを許可します
- エラーメッセージの抽象化:詳細な内部情報を外部に漏らしません
デプロイ時の注意点
コンテナ環境へのデプロイを想定し、以下の点に注意します。
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
logger.Info("サーバーを起動しました",
"port", port,
"database", dbPath,
"jwt_secret_configured", jwtSecret != "",
)
if err := http.ListenAndServe(":"+port, mux); err != nil {
logger.Error("サーバーの起動に失敗しました", "error", err)
os.Exit(1)
}
ポート番号を環境変数で指定可能にすることで、様々な環境に柔軟に対応できます。起動時にエラーが発生した場合は、適切なログを出力してプロセスを終了します。
ヘルスチェックエンドポイント
本番環境では、ロードバランサーやオーケストレーションシステム(Kubernetes等)がアプリケーションの健全性を監視するため、ヘルスチェックエンドポイントが必要です。
// ヘルスチェックハンドラー
func handleHealthCheck(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
// データベース接続確認
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
logger.Error("ヘルスチェック失敗: データベース接続エラー", "error", err)
respondWithJSON(w, http.StatusServiceUnavailable, map[string]interface{}{
"status": "unhealthy",
"checks": map[string]string{
"database": "failed",
},
})
return
}
// 正常時
respondWithJSON(w, http.StatusOK, map[string]interface{}{
"status": "healthy",
"checks": map[string]string{
"database": "ok",
},
"version": "1.0.0",
"uptime": time.Since(startTime).String(),
})
}
}
// main関数での登録
func main() {
startTime = time.Now()
// ... 初期化処理 ...
mux.HandleFunc("/health", handleHealthCheck(db.DB))
mux.HandleFunc("/readiness", handleReadinessCheck(db.DB)) // 準備状態確認用
}
// Readinessチェック(アプリケーションがトラフィックを受け入れ可能か)
func handleReadinessCheck(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// データベース接続確認
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
respondWithJSON(w, http.StatusServiceUnavailable, map[string]string{
"status": "not_ready",
"reason": "database unavailable",
})
return
}
// 外部依存サービスの確認(必要に応じて)
// if !checkExternalService() {
// respondWithJSON(w, http.StatusServiceUnavailable, ...)
// return
// }
respondWithJSON(w, http.StatusOK, map[string]string{
"status": "ready",
})
}
}
ヘルスチェックエンドポイントの設計ポイント
-
Liveness vs Readiness: Livenessはプロセスが生きているか、Readinessはトラフィックを受け入れ可能かを示します。
-
タイムアウト設定: データベース確認には短いタイムアウトを設定し、ヘルスチェック自体がボトルネックにならないようにします。
-
認証不要: ヘルスチェックエンドポイントは認証なしでアクセス可能にします。
-
詳細情報の提供: 開発環境では詳細な情報を返し、本番環境では最小限の情報に制限することも検討します。
Graceful Shutdown の実装
処理中のリクエストを適切に完了させてからシャットダウンするため、Graceful Shutdownを実装します。
func main() {
// ... 初期化処理 ...
// HTTPサーバーの設定
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// サーバーを別ゴルーチンで起動
serverErrors := make(chan error, 1)
go func() {
logger.Info("サーバーを起動しました", "port", port)
serverErrors <- srv.ListenAndServe()
}()
// シグナル受信用チャネル
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
// サーバーエラーまたはシャットダウンシグナルを待機
select {
case err := <-serverErrors:
logger.Error("サーバー起動エラー", "error", err)
os.Exit(1)
case sig := <-shutdown:
logger.Info("シャットダウン開始", "signal", sig)
// Graceful Shutdown用のコンテキスト(タイムアウト30秒)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 新しいリクエストの受付を停止し、既存リクエストの完了を待つ
if err := srv.Shutdown(ctx); err != nil {
logger.Error("Graceful Shutdown失敗、強制終了します", "error", err)
// 強制終了
if err := srv.Close(); err != nil {
logger.Error("サーバークローズ失敗", "error", err)
}
os.Exit(1)
}
logger.Info("サーバーを正常にシャットダウンしました")
}
}
この実装の利点は以下のとおりです。
-
リクエストの完全処理: 処理中のリクエストは最後まで実行され、クライアントは正常なレスポンスを受け取れます。
-
タイムアウト制御: 30秒以内に完了しないリクエストは強制終了され、シャットダウンが無限に待機することを防ぎます。
-
データベース接続のクリーンアップ: deferで設定したデータベースクローズ処理が確実に実行されます。
-
ロードバランサーとの協調: ロードバランサーがヘルスチェックで異常を検知し、新しいリクエストを他のインスタンスに振り分けます。
コンテナ環境での考慮事項
Docker/Kubernetesでの実行時、以下の点に注意します。
# Dockerfile例
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY /app/server .
# 非rootユーザーで実行
RUN adduser -D appuser
USER appuser
# ヘルスチェック設定
HEALTHCHECK \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
EXPOSE 8080
CMD ["./server"]
Kubernetes Probeの設定例:
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
readinessProbe:
httpGet:
path: /readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 2
これらの設定により、本番環境での可用性と信頼性が大幅に向上します。
テスト戦略とモック実装
保守性の高いコードを維持するため、適切なテスト戦略が重要です。レイヤードアーキテクチャとRepositoryパターンを採用したことで、各層を独立してテストできます。
ハンドラーのユニットテスト
ハンドラー層をテストする際、リポジトリをモックすることで、データベースへの依存を排除します。
// モックリポジトリの定義
type MockTaskRepository struct {
tasks map[string]*Task
createError error
getError error
}
func NewMockTaskRepository() *MockTaskRepository {
return &MockTaskRepository{
tasks: make(map[string]*Task),
}
}
func (m *MockTaskRepository) Create(ctx context.Context, task *Task) error {
if m.createError != nil {
return m.createError
}
m.tasks[task.ID] = task
return nil
}
func (m *MockTaskRepository) GetByUserID(ctx context.Context, userID string) ([]*Task, error) {
if m.getError != nil {
return nil, m.getError
}
var result []*Task
for _, task := range m.tasks {
if task.UserID == userID {
result = append(result, task)
}
}
return result, nil
}
// ハンドラーのテスト
func TestHandleCreateTask(t *testing.T) {
tests := []struct {
name string
requestBody string
userID string
setupMock func(*MockTaskRepository)
expectedStatus int
}{
{
name: "正常なタスク作成",
requestBody: `{"title":"テストタスク","description":"説明"}`,
userID: "user123",
setupMock: func(m *MockTaskRepository) {
// 正常系なので何も設定しない
},
expectedStatus: http.StatusCreated,
},
{
name: "タイトル欠落",
requestBody: `{"description":"説明のみ"}`,
userID: "user123",
setupMock: func(m *MockTaskRepository) {},
expectedStatus: http.StatusBadRequest,
},
{
name: "リポジトリエラー",
requestBody: `{"title":"テストタスク"}`,
userID: "user123",
setupMock: func(m *MockTaskRepository) {
m.createError = errors.New("database error")
},
expectedStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// モックリポジトリのセットアップ
mockRepo := NewMockTaskRepository()
tt.setupMock(mockRepo)
// ハンドラーの初期化
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
handler := NewTaskHandler(mockRepo, logger)
// リクエストの作成
req := httptest.NewRequest(http.MethodPost, "/tasks",
strings.NewReader(tt.requestBody))
req.Header.Set("Content-Type", "application/json")
// コンテキストにユーザーIDを設定
ctx := context.WithValue(req.Context(), userIDKey, tt.userID)
req = req.WithContext(ctx)
// レスポンスレコーダー
w := httptest.NewRecorder()
// ハンドラーの実行
handler.HandleCreateTask(w, req)
// アサーション
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
このテスト戦略の利点は以下のとおりです。
-
高速実行: データベースへの接続が不要なため、テストが数ミリ秒で完了します。
-
再現性: 外部依存がないため、常に同じ結果が得られます。
-
エッジケースのテスト: モックリポジトリで任意のエラーを発生させ、エラーハンドリングをテストできます。
ミドルウェアのテスト
ミドルウェアは、ハンドラーをラップするため、動作確認が重要です。
func TestAuthMiddleware(t *testing.T) {
tests := []struct {
name string
authHeader string
setupJWT func() string
expectedStatus int
shouldCallNext bool
}{
{
name: "有効なトークン",
setupJWT: func() string {
token, _ := GenerateToken("user123", "test@example.com")
return "Bearer " + token
},
expectedStatus: http.StatusOK,
shouldCallNext: true,
},
{
name: "トークンなし",
authHeader: "",
expectedStatus: http.StatusUnauthorized,
shouldCallNext: false,
},
{
name: "無効なフォーマット",
authHeader: "InvalidFormat token123",
expectedStatus: http.StatusUnauthorized,
shouldCallNext: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 次のハンドラーが呼ばれたかを確認するフラグ
nextCalled := false
nextHandler := func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}
// ミドルウェアの適用
handler := authMiddleware(nextHandler)
// リクエストの作成
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
if tt.setupJWT != nil {
req.Header.Set("Authorization", tt.setupJWT())
} else if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
w := httptest.NewRecorder()
// ハンドラーの実行
handler(w, req)
// アサーション
if w.Code != tt.expectedStatus {
t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code)
}
if nextCalled != tt.shouldCallNext {
t.Errorf("expected nextCalled=%v, got %v", tt.shouldCallNext, nextCalled)
}
})
}
}
統合テスト
ユニットテストに加えて、実際のデータベースを使用した統合テストも重要です。
func TestTaskAPIIntegration(t *testing.T) {
// テスト用データベースのセットアップ
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("データベース作成失敗: %v", err)
}
defer db.Close()
// スキーマの作成
_, err = db.Exec(`
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
created_at DATETIME NOT NULL
)
`)
if err != nil {
t.Fatalf("テーブル作成失敗: %v", err)
}
// リポジトリとハンドラーの初期化
repo := NewTaskRepository(db)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
handler := NewTaskHandler(repo, logger)
// タスク作成のテスト
t.Run("タスク作成と取得", func(t *testing.T) {
// タスク作成
createReq := httptest.NewRequest(http.MethodPost, "/tasks",
strings.NewReader(`{"title":"統合テスト","description":"説明"}`))
createReq.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(createReq.Context(), userIDKey, "testuser")
createReq = createReq.WithContext(ctx)
createW := httptest.NewRecorder()
handler.HandleCreateTask(createW, createReq)
if createW.Code != http.StatusCreated {
t.Fatalf("タスク作成失敗: status=%d", createW.Code)
}
// タスク一覧取得
listReq := httptest.NewRequest(http.MethodGet, "/tasks", nil)
listReq = listReq.WithContext(ctx)
listW := httptest.NewRecorder()
handler.HandleGetTasks(listW, listReq)
if listW.Code != http.StatusOK {
t.Fatalf("タスク取得失敗: status=%d", listW.Code)
}
var tasks []*Task
if err := json.NewDecoder(listW.Body).Decode(&tasks); err != nil {
t.Fatalf("レスポンスのパース失敗: %v", err)
}
if len(tasks) != 1 {
t.Errorf("expected 1 task, got %d", len(tasks))
}
if tasks[0].Title != "統合テスト" {
t.Errorf("expected title '統合テスト', got '%s'", tasks[0].Title)
}
})
}
統合テストでは、実際のデータベース操作やHTTPリクエスト/レスポンスのシリアライゼーション処理も含めて検証します。これにより、ユニットテストでは検出できないバグを発見できます。
テストのベストプラクティス
-
テーブル駆動テスト: 複数のテストケースを構造体のスライスで管理し、コードの重複を削減します。
-
モックの適切な使用: 外部依存をモック化し、テストを高速かつ安定させます。
-
統合テストの補完: ユニットテストだけでは不十分な部分を統合テストで補完します。
-
カバレッジ測定:
go test -cover
でカバレッジを測定し、テストされていないコードを特定します。# カバレッジレポートの生成 go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html
-
並行実行:
t.Parallel()
を使用して、独立したテストを並行実行し、全体の実行時間を短縮します。これらのテスト戦略により、コードの品質と保守性が大幅に向上します。
よくあるミスと対策
実装を進める中で、多くの開発者が直面する典型的なミスとその対策を紹介します。これらを事前に理解することで、デバッグに費やす時間を大幅に削減できます。
ミドルウェアの順序ミス
ミドルウェアの実行順序を誤ると、期待した動作にならないだけでなく、セキュリティ上の問題やパフォーマンス低下を引き起こします。
典型的なミスパターン
// ❌ 間違った順序:認証チェック前にレート制限
mux.HandleFunc("/tasks",
authMiddleware(
middleware.TaskRateLimiter.Middleware(
taskHandler.HandleTasks
)
)
)
この実装では、認証に失敗するリクエストでもレート制限のカウントが増加してしまいます。攻撃者が無効なトークンで大量のリクエストを送ることで、正規ユーザーがレート制限に到達する可能性があります。
// ❌ 間違った順序:ロギング前にCORS
mux.HandleFunc("/tasks",
corsMiddleware(
loggingMiddleware(
taskHandler.HandleTasks
)
)
)
この場合、CORSでリクエストが拒否された際、ログに記録されません。トラブルシューティング時に問題の発見が困難になります。
正しい実装と理由
// ✅ 正しい順序
mux.HandleFunc("/tasks",
loggingMiddleware( // 1. 全てのリクエストをログ記録
corsMiddleware( // 2. CORS検証(高速)
authMiddleware( // 3. 認証チェック(計算コスト中)
middleware.TaskRateLimiter.Middleware( // 4. 認証済みユーザーのレート制限
taskHandler.HandleTasks
)
)
)
)
)
推奨される順序の原則は以下になります。
-
ロギングは最外層: 全てのリクエストとレスポンスを記録するため、最初に実行します。
-
高速な検証を先に: CORSチェックは単純な文字列比較なので、認証より前に実行します。
-
認証は早めに: 不正なリクエストを早期に弾くことで、後続の処理負荷を削減します。
-
レート制限は認証後: 認証済みユーザーに対してのみレート制限を適用し、攻撃者による消費を防ぎます。
-
重い処理は内側に: データベースアクセスを伴う処理は、前段の検証を通過した後に実行します。
デバッグのヒント
ミドルウェアの実行順序を確認するため、各ミドルウェアでログを出力します。
func loggingMiddleware(logger *slog.Logger) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger.Debug("ミドルウェア実行", "middleware", "logging", "path", r.URL.Path)
// ... 処理
next(w, r)
}
}
}
トランザクション管理の漏れ
データベース操作でトランザクションを適切に管理しないと、データ不整合が発生します。
典型的なミスパターン1:ロールバック漏れ
// ❌ エラー時にロールバックされない
func (h *TaskHandler) createTaskWithTags(w http.ResponseWriter, r *http.Request) {
tx, err := h.db.Begin()
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Transaction error")
return
}
// タスク作成
if err := h.createTask(tx, task); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to create task")
return // ❌ ここでreturnするとトランザクションが放置される
}
// タグ作成
if err := h.createTags(tx, tags); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to create tags")
return // ❌ ここでもロールバックされない
}
tx.Commit()
}
この実装では、エラー発生時にトランザクションがコミットもロールバックもされず、データベース接続がリークします。
正しい実装:deferパターン
// ✅ deferで確実にロールバック
func (h *TaskHandler) createTaskWithTags(w http.ResponseWriter, r *http.Request) {
tx, err := h.db.Begin()
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Transaction error")
return
}
// 関数終了時に必ず実行される
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // パニックを再度発生させる
} else if err != nil {
tx.Rollback()
}
}()
// タスク作成
if err = h.createTask(tx, task); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to create task")
return // errが設定されているのでdeferでロールバック
}
// タグ作成
if err = h.createTags(tx, tags); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to create tags")
return
}
// 全て成功した場合のみコミット
if err = tx.Commit(); err != nil {
respondWithError(w, http.StatusInternalServerError, "Failed to commit")
return
}
}
重要なポイントは以下のとおりです。
-
名前付き戻り値を使用:
err
を関数レベルで宣言し、defer内で参照できるようにします。 -
パニックにも対応:
recover()
でパニックをキャッチし、ロールバック後に再度パニックを発生させます。 -
コミット失敗も考慮: コミット自体が失敗する可能性があるため、エラーチェックが必要です。
典型的なミスパターン2:コンテキストキャンセルの未考慮
// ❌ コンテキストがキャンセルされても処理が続く
func (h *TaskHandler) processLongTask(w http.ResponseWriter, r *http.Request) {
tx, _ := h.db.Begin()
defer tx.Rollback()
// 時間がかかる処理
for _, item := range items {
// クライアントが接続を切断しても処理が続く
h.processItem(tx, item)
}
tx.Commit()
}
正しい実装:コンテキストを使用
// ✅ コンテキストでキャンセル可能
func (h *TaskHandler) processLongTask(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Transaction error")
return
}
defer tx.Rollback()
for _, item := range items {
// コンテキストのキャンセルをチェック
select {
case <-ctx.Done():
h.logger.Info("リクエストがキャンセルされました")
return
default:
if err := h.processItem(ctx, tx, item); err != nil {
respondWithError(w, http.StatusInternalServerError, "Processing failed")
return
}
}
}
if err := tx.Commit(); err != nil {
respondWithError(w, http.StatusInternalServerError, "Commit failed")
return
}
}
エラーハンドリングの不統一
エラー処理が統一されていないと、クライアント側での処理が複雑になり、デバッグも困難になります。
典型的なミスパターン1:レスポンス形式の不統一
// ❌ エラーレスポンスの形式がバラバラ
func (h *Handler) endpoint1(w http.ResponseWriter, r *http.Request) {
if err != nil {
// 文字列をそのまま返す
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
}
func (h *Handler) endpoint2(w http.ResponseWriter, r *http.Request) {
if err != nil {
// JSON形式で返す
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
}
func (h *Handler) endpoint3(w http.ResponseWriter, r *http.Request) {
if err != nil {
// 別のJSON形式で返す
json.NewEncoder(w).Encode(map[string]string{"message": "error occurred"})
return
}
}
正しい実装:統一されたエラーレスポンス
// ✅ エラー型を定義
type ErrorResponse struct {
Message string `json:"message"`
Code string `json:"code,omitempty"` // エラーコード(オプション)
Details string `json:"details,omitempty"` // 詳細情報(開発環境のみ)
}
// ✅ ヘルパー関数で統一
func respondWithError(w http.ResponseWriter, statusCode int, message string, logger *slog.Logger) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := ErrorResponse{Message: message}
if err := json.NewEncoder(w).Encode(response); err != nil {
logger.Error("エラーレスポンスの送信に失敗", "error", err)
}
}
// 全てのエンドポイントで同じ形式
func (h *Handler) endpoint1(w http.ResponseWriter, r *http.Request) {
if err != nil {
respondWithError(w, http.StatusInternalServerError, "Internal server error", h.logger)
return
}
}
典型的なミスパターン2:センシティブ情報の漏洩
// ❌ データベースエラーをそのまま返す
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
user, err := h.repo.GetByID(userID)
if err != nil {
// データベースの内部情報が漏洩
respondWithError(w, http.StatusInternalServerError, err.Error())
// 例: "pq: password authentication failed for user 'postgres'"
return
}
}
正しい実装:エラーの抽象化
// ✅ 内部エラーは抽象化して返す
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
user, err := h.repo.GetByID(userID)
if err != nil {
// 詳細はログに記録
h.logger.Error("ユーザー取得失敗",
"error", err,
"user_id", userID,
"query", "GetByID",
)
// クライアントには抽象的なメッセージを返す
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve user")
return
}
}
典型的なミスパターン3:エラーログの重複
// ❌ 各層でログを出力すると重複する
func (r *Repository) GetByID(id string) (*User, error) {
user, err := r.db.QueryRow("SELECT * FROM users WHERE id = ?", id)
if err != nil {
r.logger.Error("データベースクエリ失敗", "error", err) // ログ1
return nil, err
}
return user, nil
}
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
user, err := h.repo.GetByID(userID)
if err != nil {
h.logger.Error("ユーザー取得失敗", "error", err) // ログ2(重複)
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve user")
return
}
}
正しい実装:エラーログは最上位層で
// ✅ リポジトリ層はエラーを返すのみ
func (r *Repository) GetByID(id string) (*User, error) {
user, err := r.db.QueryRow("SELECT * FROM users WHERE id = ?", id)
if err != nil {
// エラーをラップして返す(コンテキスト情報を追加)
return nil, fmt.Errorf("failed to query user: %w", err)
}
return user, nil
}
// ✅ ハンドラー層でログとレスポンス
func (h *Handler) getUser(w http.ResponseWriter, r *http.Request) {
user, err := h.repo.GetByID(userID)
if err != nil {
// 一度だけログに記録
h.logger.Error("ユーザー取得失敗",
"error", err, // ラップされたエラーで呼び出し元も分かる
"user_id", userID,
)
respondWithError(w, http.StatusInternalServerError, "Failed to retrieve user")
return
}
}
エラーハンドリングのベストプラクティスは以下になります。
-
エラーレスポンス形式の統一: 全エンドポイントで同じJSON形式を使用します。
-
センシティブ情報の保護: 内部エラーの詳細はログに記録し、クライアントには抽象的なメッセージを返します。
-
エラーログの重複回避: ログは最上位層(ハンドラー層)で一度だけ記録します。
-
エラーのラップ:
fmt.Errorf("%w", err)
でエラーをラップし、呼び出し元の情報を保持します。 -
適切なHTTPステータスコード: 4xx(クライアントエラー)と5xx(サーバーエラー)を正しく使い分けます。
これらのミスを回避することで、デバッグしやすく、保守性の高いコードを維持できます。
まとめ
本記事では、Go標準ライブラリを使用したREST APIサーバーの実装パターンを解説しました。外部フレームワークに依存しない実装により、Goの基礎を深く理解し、長期的な保守性を確保できます。
標準ライブラリによる実装の主な利点は、以下のとおりです。
- 依存関係の最小化:外部ライブラリのバージョンアップや廃止の影響を受けにくくなります
- 学習コストの低減:標準ライブラリの知識は、どのGoプロジェクトでも活用できます
- パフォーマンスの最適化:不要な抽象化がなく、細かい制御が可能です
- 深い理解の獲得:内部動作を理解することで、問題解決能力が向上します
本記事で実装した主な機能は以下のとおりです。
- JWT認証(自作実装)
- ミドルウェアパターン(ロギング、CORS、認証、レート制限)
- PostgreSQLとの連携
- マイグレーション管理
- メール認証機能
- 画像アップロード機能(ローカル/S3対応)
- バリデーションとエラーハンドリング
- 構造化ロギング
これらの機能は、インターフェースによる抽象化とRepositoryパターンにより、テスタビリティと保守性を確保しています。例えば、ストレージをローカルからS3に切り替える際、ハンドラー層のコードは一切変更する必要がありません。
本記事で学んだパターンは、マイクロサービス、社内ツール、API Gatewayなど、様々な用途に応用できます。特に、JWT認証、ミドルウェアチェーン、レート制限、メール認証は、多くのWebアプリケーションで必要とされる機能です。
今後の発展として、以下のような機能拡張が考えられます。
- WebSocketによるリアルタイム通信
- gRPCサポート
- 分散トレーシング(OpenTelemetry)
- キャッシング戦略(Redis連携)
- 全文検索機能(PGroonga対応/Elasticsearch連携)
これらの機能も、標準ライブラリを基盤として段階的に追加できます。
完全なソースコードはGitHubで公開していますので、実際の動作を確認しながら学習を進めてください。Docker Composeによる環境構築も含まれているため、すぐに動作を確認できます。
Discussion