Contents
1️⃣ 環境構築とプロジェクト初期化
1.1 Go 1.22+ のインストール(Ubuntu/Debian 編)
公式パッケージは Ubuntu のリポジトリに古いバージョンが残っていることが多く、apt だけでは最新の go1.22.x が入らないケースがあります。確実に最新版を入れる手順は次のとおりです。
| 手順 | コマンド例 |
|---|---|
| ① ダウンロード 公式サイトから tarball を取得します。 |
bash<br>curl -LO https://go.dev/dl/go1.22.5.linux-amd64.tar.gz |
② 既存の Go を削除(/usr/local/go がデフォルトインストール先) |
bash<br>sudo rm -rf /usr/local/go |
| ③ 展開してシステムに配置 | bash<br>sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz |
④ パスを通す(~/.profile か /etc/profile.d/go.sh) |
bash<br>echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile && source ~/.profile |
| ⑤ バージョン確認 | bash<br>go version # → go1.22.5 linux/amd64 |
Tip
-aptでインストールしたい場合は、golang-1.22-goが Ubuntu 23.10 以降の公式リポジトリに入っています。sudo apt install golang-1.22-goとすれば同様に最新版が取得できます(ただし LTS 系ディストロではパッケージが遅れることがあります)。
- 複数バージョンを切り替える必要がある場合は asdf、gvm、または Docker を併用すると管理が楽です。
1.2 go.mod によるモジュール初期化
|
1 2 3 |
mkdir -p ~/go-projects/gin-bun-api && cd "$_" go mod init github.com/yourname/gin-bun-api |
2026年4月時点の依存バージョン(参考)
|
1 2 3 4 5 6 7 8 9 10 11 12 |
module github.com/yourname/gin-bun-api go 1.22 require ( github.com/gin-gonic/gin v1.10.0 // 2026‑04 の最新安定版 github.com/uptrace/bun v1.3.5 // MySQL / PostgreSQL 両対応 github.com/go-playground/validator/v10 v10.15.2 github.com/golang-jwt/jwt/v5 v5.2.0 go.uber.org/zap v1.26.0 ) |
依存バージョンの自動更新
- ローカル:
go get -u ./...で一括アップデート。 - CI/CD: Dependabot(GitHub)や Renovate を有効化し、プルリクエストで定期的に最新バージョンを取り込む。
- 注意点: メジャーアップデートは破壊的変更が入る可能性があるので、テストスイートのカバレッジを 80 % 以上確保した上で自動マージしない設定が安全です。
|
1 2 |
go mod tidy # 未使用依存の削除と go.sum の整合化 |
2️⃣ クリーンアーキテクチャで設計するディレクトリ構成
2.1 基本概念(円環モデル)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
+-------------------+ | Frameworks | ← cmd/, Dockerfile, main.go +-------------------+ ▲ │ 依存は外側 → 内側 のみ +-------------------+ | Interface Adapters| ← internal/handler, internal/repository (実装) +-------------------+ ▲ +-------------------+ | Use Cases | ← internal/usecase (ビジネスロジック) +-------------------+ ▲ +-------------------+ | Entities | ← internal/entity (純粋なドメイン構造体) +-------------------+ |
- 依存関係の方向は常に外側から内側へ。これにより、内部ロジックはフレームワークや DB に依存しないテスト可能なコードになる。
- 各層は インターフェース で結合し、実装は外側の層が提供する。
2.2 推奨ディレクトリ構成
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
├── cmd/ │ └── server/ # main パッケージ(エントリポイント) ├── internal/ │ ├── entity/ # ドメインモデル │ ├── usecase/ # ビジネスロジック │ ├── repository/ # Bun を用いた DB アダプタ (interface + impl) │ └── handler/ # Gin ハンドラ、ミドルウェア ├── pkg/ │ ├── config/ # 環境変数・設定構造体 │ ├── middleware/ # JWT, ロギング, エラー処理等 │ └── logger/ # zap ラッパー ├── Dockerfile ├── docker-compose.yml └── go.mod / go.sum |
2.3 Mermaid 図(視覚的に把握)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
flowchart LR subgraph D[Domain Layer] Entity["Entity"] Usecase["Usecase"] Entity --> Usecase end subgraph A[Interface Adapters] Repo["Repository Interface"] Handler["HTTP Handler"] Usecase --> Repo Usecase --> Handler end subgraph F[Frameworks & Drivers] Router["Gin Router"] DB["Bun + DB"] Main["cmd/server/main.go"] Handler --> Router Repo --> DB Router --> Main end |
3️⃣ Gin と Bun ORM を用いた実装例
3.1 Gin のルーティング(インポート忘れの修正)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// internal/handler/router.go package handler import ( "net/http" // ← 追加 "github.com/gin-gonic/gin" "github.com/yourname/gin-bun-api/internal/middleware" "github.com/yourname/gin-bun-api/internal/usecase" ) func NewRouter(u *usecase.UserUseCase) http.Handler { r := gin.New() r.Use(gin.Recovery()) r.Use(middleware.RequestID()) r.Use(middleware.Logger()) api := r.Group("/api/v1") { users := api.Group("/users") { users.POST("", CreateUser(u)) users.GET("/:id", GetUser(u)) users.PUT("/:id", UpdateUser(u)) users.DELETE("/:id", DeleteUser(u)) } } return r } |
*gin.Engineはhttp.Handlerインターフェースを実装しているため、上記のシグネチャで問題なく利用できます。
3.2 Bun の DB 接続ユーティリティ
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
// pkg/config/database.go package config import ( "context" "database/sql" "fmt" "log" "os" "strings" "github.com/uptrace/bun" _ "github.com/go-sql-driver/mysql" // MySQL driver _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver ) func NewDB() *bun.DB { dsn := os.Getenv("DATABASE_URL") if dsn == "" { log.Fatal("environment variable DATABASE_URL is required") } driver := driverFromDSN(dsn) sqldb, err := sql.Open(driver, dsn) if err != nil { log.Fatalf("failed to open DB: %v", err) } // 接続テスト if err := sqldb.PingContext(context.Background()); err != nil { log.Fatalf("cannot ping DB: %v", err) } return bun.NewDB(sqldb, &bun.Compat{}) } func driverFromDSN(dsn string) string { switch { case strings.HasPrefix(dsn, "mysql://"): return "mysql" case strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "pgx://"): return "pgx" default: log.Fatalf("unsupported DSN scheme: %s", dsn) return "" } } |
3.3 自動マイグレーション
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// internal/repository/migration.go package repository import ( "context" "fmt" "github.com/uptrace/bun" "github.com/yourname/gin-bun-api/internal/entity" ) func AutoMigrate(db *bun.DB) error { ctx := context.Background() models := []interface{}{ (*entity.User)(nil), // 追加エンティティはここに列挙 } for _, m := range models { if _, err := db.NewCreateTable(). Model(m). IfNotExists(). Exec(ctx); err != nil { return fmt.Errorf("migration failed for %T: %w", m, err) } } return nil } |
実運用のベストプラクティス
- 開発環境はアプリ起動時にAutoMigrateを呼び出す。
- 本番環境は SQL マイグレーションファイル(例:db/migrations/*.sql)をバージョン管理し、CI でgo run ./cmd/migrateのみ実行してチェックする。
3.4 CRUD ハンドラとバリデーション
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// internal/handler/user.go package handler import ( "net/http" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" "github.com/yourname/gin-bun-api/internal/usecase" ) var validate = validator.New() type createUserReq struct { Name string `json:"name" binding:"required,min=2,max=50"` Email string `json:"email" binding:"required,email"` } // POST /users func CreateUser(u *usecase.UserUseCase) gin.HandlerFunc { return func(c *gin.Context) { var req createUserReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := validate.Struct(req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"validation": err.Error()}) return } user, err := u.Create(c.Request.Context(), req.Name, req.Email) if err != nil { // エラーはミドルウェアで統一的に処理される想定 c.Error(err) return } c.JSON(http.StatusCreated, user) } } |
ポイント
-validator/v10はフィールドタグだけでなく、構造体レベルのカスタムバリデーションも簡単に追加できる。
- エラーはハンドラ内でc.Error(err)だけ返し、共通ミドルウェアが JSON へ変換する設計にするとコード量が大幅に削減できる。
3.5 Usecase の実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
// internal/usecase/user.go package usecase import ( "context" "github.com/yourname/gin-bun-api/internal/entity" "github.com/yourname/gin-bun-api/internal/repository" ) type UserUseCase struct { repo repository.UserRepository } func NewUserUseCase(r repository.UserRepository) *UserUseCase { return &UserUseCase{repo: r} } // Create はトランザクション管理やバリデーションを行わず、純粋にリポジトリへ委譲するだけ。 func (uc *UserUseCase) Create(ctx context.Context, name, email string) (*entity.User, error) { u := &entity.User{Name: name, Email: email} if err := uc.repo.Create(ctx, u); err != nil { return nil, err } return u, nil } // Read / Update / Delete は同様に実装(省略) |
3.6 JWT 認証ミドルウェア
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// pkg/middleware/auth.go package middleware import ( "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) func JWTAuth(secret string) gin.HandlerFunc { return func(c *gin.Context) { auth := c.GetHeader("Authorization") parts := strings.SplitN(auth, " ", 2) if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid Authorization header"}) return } tokenStr := parts[1] token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return []byte(secret), nil }) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } // 必要に応じて claims から情報をコンテキストへ格納 if claims, ok := token.Claims.(jwt.MapClaims); ok { c.Set("user_id", claims["sub"]) } c.Next() } } |
router.go の保護対象エンドポイントは次のようにラップします。
|
1 2 3 4 |
// internal/handler/router.go(抜粋) auth := middleware.JWTAuth(os.Getenv("JWT_SECRET")) api.Use(auth) // すべての /api/v1/* に適用 |
4️⃣ エラーハンドリング・ロギング・テスト
4.1 統一エラー型と HTTP ステータスマッピング
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// pkg/error/api_error.go package apierror import "net/http" type APIError struct { Code int `json:"code"` // HTTP status Message string `json:"message"` // 人間が読めるメッセージ } func New(code int, msg string) *APIError { return &APIError{Code: code, Message: msg} } var ( ErrBadRequest = New(http.StatusBadRequest, "リクエストが不正です") ErrNotFound = New(http.StatusNotFound, "対象が見つかりません") ErrInternalServerError = New(http.StatusInternalServerError, "サーバ内部でエラーが発生しました") ) |
4.2 エラーハンドリングミドルウェア
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// pkg/middleware/error_handler.go package middleware import ( "errors" "net/http" "github.com/gin-gonic/gin" apierror "github.com/yourname/gin-bun-api/pkg/error" ) func ErrorHandler() gin.HandlerFunc { return func(c *gin.Context) { c.Next() if len(c.Errors) == 0 { return } // 最初のエラーだけ処理(必要なら全件ループ可) err := c.Errors[0].Err var apiErr *apierror.APIError if errors.As(err, &apiErr) { c.JSON(apiErr.Code, gin.H{"error": apiErr.Message}) return } // 予期しないエラーは 500 にマッピング c.JSON(http.StatusInternalServerError, gin.H{"error": "予期せぬサーバエラー"}) } } |
NewRouter の冒頭でミドルウェアを登録します。
|
1 2 |
r.Use(middleware.ErrorHandler()) |
4.3 構造化ロギング(zap)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// pkg/logger/zap.go package logger import ( "os" "go.uber.org/zap" ) var Log *zap.Logger func Init() { cfg := zap.NewProductionConfig() // LOG_LEVEL 環境変数でレベル切替(例: DEBUG, INFO, WARN, ERROR) if lvl := os.Getenv("LOG_LEVEL"); lvl != "" { _ = cfg.Level.UnmarshalText([]byte(lvl)) } var err error Log, err = cfg.Build() if err != nil { panic(err) } } |
ハンドラやリポジトリからは次のように呼び出すだけです。
|
1 2 |
logger.Log.Info("user created", zap.String("email", user.Email)) |
4.4 ユニットテスト(usecase)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// internal/usecase/user_test.go package usecase import ( "context" "testing" "github.com/stretchr/testify/mock" "github.com/yourname/gin-bun-api/internal/entity" "github.com/yourname/gin-bun-api/internal/repository/mocks" ) func TestCreateUser_Success(t *testing.T) { repo := new(mocks.UserRepository) uc := NewUserUseCase(repo) // 期待する呼び出しを設定 repo.On("Create", mock.Anything, mock.MatchedBy(func(u *entity.User) bool { return u.Name == "Alice" && u.Email == "alice@example.com" })).Return(nil).Once() user, err := uc.Create(context.Background(), "Alice", "alice@example.com") if err != nil { t.Fatalf("unexpected error: %v", err) } if user.Name != "Alice" || user.Email != "alice@example.com" { t.Errorf("user fields mismatch: %+v", user) } repo.AssertExpectations(t) } |
4.5 統合テスト(Gin ハンドラ)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// internal/handler/user_integration_test.go package handler import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/mock" "github.com/yourname/gin-bun-api/internal/usecase" "github.com/yourname/gin-bun-api/internal/repository/mocks" ) func TestCreateUserEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) // Mock リポジトリ repo := new(mocks.UserRepository) uc := usecase.NewUserUseCase(repo) repo.On("Create", mock.Anything, mock.AnythingOfType("*entity.User")).Return(nil).Once() router := NewRouter(uc) // http.Handler を返す body := `{"name":"Bob","email":"bob@example.com"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201 Created, got %d", w.Code) } } |
5️⃣ コンテナ化と CI/CD パイプライン
5.1 Dockerfile(マルチステージ)と実際のサイズ感
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
# syntax=docker/dockerfile:1.4 ## ---------- Build Stage ---------- FROM golang:1.22-alpine AS builder WORKDIR /src # キャッシュを有効にするため go.mod と go.sum だけ先にコピー COPY go.mod go.sum ./ RUN go mod download # ソース全体をコピーしてビルド COPY . . RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -ldflags="-s -w" -o /app/server ./cmd/server ## ---------- Runtime Stage ---------- FROM alpine:3.20 AS runtime WORKDIR /app # Alpine に必要なのは ca-certificates のみ(HTTPS 通信のため) RUN apk add --no-cache ca-certificates COPY --from=builder /app/server . EXPOSE 8080 ENTRYPOINT ["./server"] |
- サイズ目安:
docker images実行時、上記イメージは 約22 MB(Alpine + statically linked binary)になることが多いです。 - 「15 MB 前後」という表現は最適化しすぎた期待値であり、実際の環境では 20‑30 MB が一般的です。サイズをさらに削減したい場合は distroless (
gcr.io/distroless/static) をベースにすると約15 MB に近づきますが、デバッグツールが全く無いためローカルでのトラブルシューティングはやや面倒です。
5.2 docker‑compose(API + DB)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
version: "3.9" services: api: build: . ports: - "8080:8080" environment: - DATABASE_URL=${DATABASE_URL} - JWT_SECRET=${JWT_SECRET} depends_on: - db db: image: mysql:8.0 command: ["--default-authentication-plugin=mysql_native_password"] environment: MYSQL_DATABASE: app MYSQL_USER: user MYSQL_PASSWORD: pass MYSQL_ROOT_PASSWORD: rootpass ports: - "3306:3306" volumes: - db-data:/var/lib/mysql volumes: db-data: |
DATABASE_URLは 環境変数で注入 し、コード側ではos.Getenv("DATABASE_URL")を読むだけにすることで、ローカル・ステージング・本番すべてで同一設定ファイルを使い回せます。
5.3 GitHub Actions(テスト → ビルド → デプロイ)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# .github/workflows/ci.yml name: CI/CD on: push: branches: [ main ] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.22' - name: Cache modules uses: actions/cache@v3 with: path: | ~/.cache/go-build ~/go/pkg/mod key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - run: go test ./... -coverprofile=coverage.out build-and-push: needs: test runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build & push image uses: docker/build-push-action@v5 with: context: . push: true tags: | ghcr.io/${{ github.repository }}:${{ github.sha }} ghcr.io/${{ github.repository }}:latest cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache cache-to: type=inline - name: Deploy to Cloud Run (optional) if: ${{ github.ref == 'refs/heads/main' }} uses: google-github-actions/deploy-cloudrun@v2 with: service: gin-bun-api image: ghcr.io/${{ github.repository }}:${{ github.sha }} region: us-central1 env: GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }} |
- テストが失敗したらビルドは走りません(
needs: testによる依存関係)。 dependabot.ymlをリポジトリに追加すれば、ライブラリのバージョン更新プルリクエストを自動生成できます。
5.4 デプロイ先別環境変数管理
| プラットフォーム | 設定例(シークレット) |
|---|---|
| Google Cloud Run | gcloud run services update gin-bun-api --set-env-vars=DATABASE_URL=${{ secrets.DATABASE_URL }},JWT_SECRET=${{ secrets.JWT_SECRET }} |
| Render | Dashboard → Environment タブで Add Variable。 |
| Railway | CLI で railway variables set DATABASE_URL=... JWT_SECRET=... または UI の Variables セクション。 |
セキュリティ注意点
- 環境変数やシークレットは決してコードベースにハードコーディングしないこと。
-docker-compose.ymlにはプレーンテキストの URL を書かず、.env.exampleにキーだけを書いて.gitignoreに実ファイルを除外する。
6️⃣ まとめと次のアクション
| フェーズ | 主な作業 |
|---|---|
| ① 環境構築 | Go 1.22 を公式 tarball または golang-1.22-go パッケージでインストールし、go.mod で依存を管理。Dependabot により自動更新を設定。 |
| ② 設計 | クリーンアーキテクチャに沿った cmd / internal / pkg の層分け。インターフェース中心の設計でテスト容易性を確保。 |
| ③ 実装 | Gin + Bun で CRUD、バリデーション、JWT 認証を実装。エラーハンドリングは共通ミドルウェア、ロギングは構造化 zap を使用。 |
| ④ 品質向上 | ユニットテスト(usecase)と統合テスト(Gin ハンドラ)を httptest と testify/mock で網羅。カバレッジ 80 %+ を目標に。 |
| ⑤ コンテナ化 | マルチステージ Dockerfile → Alpine + static binary ≈ 22 MB(distroless なら ~15 MB)。docker‑compose と GitHub Actions による CI/CD パイプライン構築。 |
| ⑥ デプロイ | Cloud Run / Render / Railway のいずれかへデプロイし、シークレットは全て外部管理。デプロイ後は kubectl logs 相当のモニタリング(Stackdriver, Grafana Loki 等)を設定。 |
次にやるべきこと
- ローカルで一通り動かす
bash
go run ./cmd/server
curl -v http://localhost:8080/api/v1/health - テストを書き足す
- すべての Usecase、Repository のインターフェースに対するモックテスト。
- エラーハンドリングミドルウェアのシナリオ(500, 404 等)もカバー。
- CI パイプラインを有効化
dependabot.ymlと GitHub Actions をコミットし、プルリクエストで自動ビルド・テストが走ることを確認。- 本番環境へデプロイ
- GCP の Cloud Run か Render にデプロイし、実際のトラフィックで負荷測定(
hey,k6)を行う。 - モニタリングとアラート
zapの JSON 出力を Loki/Prometheus に流す。- エラー率が一定閾値を超えたら Slack 通知するように Alertmanager を設定。
以上のステップを踏めば、最新 Go 1.22 と Gin + Bun の組み合わせで、ローカル開発から本番運用まで一貫したフローが完成します。ぜひご自身のビジネスドメインに合わせてエンティティやユースケースを拡張し、本格的なプロダクトへと成長させてください 🚀.