GoのHTTPルーティングで困ったときの対処法—Chi vs Ginの選び方と実装のコツ

GoのHTTPルーティングで困ったときの対処法—Chi vs Ginの選び方と実装のコツ | mohablog
目次

Goのルーティングライブラリ選びで何が変わるか

GoでWebサーバーを構築するとき、net/httpパッケージだけで実装することもできますが、実務レベルのプロジェクトではルーティングライブラリを選ぶことになります。調べてみると「Chi」「Gin」「Echo」など選肢が多く、どれを選べば良いのか迷う場面が意外と多いですね。

最初のプロジェクトでは何となくGinを使っていたのですが、チーム規模が大きくなったときにルータの細かい使い分けで摩擦が生まれることに気づきました。今回は、実装経験を踏まえてChi と Gin の違いを整理し、プロジェクト特性に応じた選び方を解説します。

標準的なgo-httpサーバーの課題と、ライブラリが必要な理由

まず、net/httpだけでルーティングを書くとどうなるか見てみましょう。

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "GET /users")
    })
    http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodPost {
            fmt.Fprintf(w, "POST /users")
        }
    })
    http.ListenAndServe(":8080", nil)
}

問題点がいくつか見えてきます。

  • パス内のパラメータ(/users/:id)の抽出が複雑
  • HTTPメソッドの分岐を手動で書く必要がある
  • 複数のハンドラーが同じパスに登録される可能性がある
  • ミドルウェアの組み込みが難しい

こうした課題を解決するために、ルーティングライブラリが存在します。バージョン情報としては Go 1.22 以降で標準的なHTTPルーティングが改善されていますが、複雑なAPIサーバーではやはり専用ライブラリを使う方が実装がシンプルになります。

Chi と Gin の基本的な違いを理解する

二つのライブラリを選ぶ前に、アーキテクチャの違いを押さえておくと判断がしやすくなります。

項目 Chi Gin
設計思想 標準ライブラリに準拠、シンプル パフォーマンス重視、Expressに近い
パフォーマンス 安定的(ベンチマークでは平均的) 高速(ベンチマークで優位)
ミドルウェア 標準のhttp.Handlerと互換 独自のHandlerFunc
学習曲線 標準ライブラリに近いため緩やか Expressに似ているため直感的だが独自要素も多い
本番環境での実績 大規模Goプロジェクトで広く採用 スタートアップや小〜中規模向け

Chi を選ぶべきプロジェクトの特徴

Chi は標準の net/http インターフェースに忠実に設計されています。そのため、既存の標準ライブラリ知識が活かしやすく、他の開発者にとって学習コストが低いという大きな利点があります。

package main

import (
    "fmt"
    "net/http"
    "github.com/go-chi/chi/v5"
)

func main() {
    r := chi.NewRouter()
    
    // グローバルミドルウェア
    r.Use(chi.Logger)
    r.Use(chi.Recoverer)
    
    // ルート定義
    r.Get("/users", listUsers)
    r.Post("/users", createUser)
    r.Get("/users/{id}", getUser)
    r.Delete("/users/{id}", deleteUser)
    
    http.ListenAndServe(":8080", r)
}

func listUsers(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "List all users")
}

func createUser(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Create user")
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    fmt.Fprintf(w, "Get user: %s", id)
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    fmt.Fprintf(w, "Delete user: %s", id)
}

Chiの利点はchi.URLParam(r, "id")という明示的なパラメータ抽出です。標準的なhttp.ResponseWriter*http.Requestをそのまま使うため、他のGoフレームワークやミドルウェアとの互換性が高い状態を保てます。

Chiを選ぶべき場面:

  • 複数のGoプロジェクトが共存し、統一した標準に従いたい
  • 長期的なメンテナンスを考え、依存関係をシンプルに保ちたい
  • 標準ライブラリのnet/http知識で対応したい
  • 大規模で成長を続けるAPIサーバー

Gin を選ぶべきプロジェクトの特徴

Gin は Node.js の Express から強い影響を受けており、パフォーマンスと開発速度の両立を目指しています。

package main

import (
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    
    // ルート定義
    r.GET("/users", listUsers)
    r.POST("/users", createUser)
    r.GET("/users/:id", getUser)
    r.DELETE("/users/:id", deleteUser)
    
    r.Run(":8080")
}

func listUsers(c *gin.Context) {
    c.JSON(200, gin.H{"message": "List all users"})
}

func createUser(c *gin.Context) {
    c.JSON(201, gin.H{"message": "Create user"})
}

func getUser(c *gin.Context) {
    id := c.Param("id")
    c.JSON(200, gin.H{"id": id})
}

func deleteUser(c *gin.Context) {
    id := c.Param("id")
    c.JSON(204, gin.H{})
}

Gin の*gin.Contexthttp.ResponseWriter*http.Request を内包していますが、便利なヘルパーメソッドを多数提供します。c.JSON()で JSON 応答が簡潔に書け、c.Param()でパラメータ抽出もシンプルです。

Ginを選ぶべき場面:

  • スタートアップやプロトタイプで素早く開発したい
  • チームに Express (Node.js) 経験者が多い
  • JSON API が中心で、パフォーマンスが重要
  • 既に他のGo関連プロジェクトがなく、独立した開発

ミドルウェアの設計パターンで見える違い

実務レベルでは、ログ出力や認証、エラーハンドリングをミドルウェアで統一管理することが多いです。ここで両者の設計の違いが顕著になります。

Chi のミドルウェア:

package main

import (
    "fmt"
    "net/http"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Printf("[%s] %s %s\n", r.Method, r.URL.Path, r.RemoteAddr)
        next.ServeHTTP(w, r)
    })
}

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(loggingMiddleware)
    
    r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, "OK")
    })
    
    http.ListenAndServe(":8080", r)
}

Chi のミドルウェアは標準的な http.Handler インターフェースに基づいています。つまり、他のGoライブラリで作成されたミドルウェア(例えば CORS ハンドラ)をそのまま流用できます。

Gin のミドルウェア:

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
)

func loggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        fmt.Printf("[%s] %s %s\n", c.Request.Method, c.Request.URL.Path, c.ClientIP())
        c.Next()
    }
}

func main() {
    r := gin.Default()
    r.Use(loggingMiddleware())
    
    r.GET("/test", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "OK"})
    })
    
    r.Run(":8080")
}

Gin のミドルウェアは gin.HandlerFunc という独自型で、c.Next() によって次のハンドラーに処理を譲ります。これは Express の設計に近く、チェーン状に処理を繋ぐことが明示的です。

パフォーマンス面での実測データ

公開ドキュメントを参考にすると、ベンチマークテストでは Gin がやや優位です。一般的なシナリオでは:

  • Chi:1秒あたり約 10,000 〜 15,000 リクエスト(複雑なルーティング)
  • Gin:1秒あたり約 20,000 〜 25,000 リクエスト(複雑なルーティング)

ただし、実務では「ルーティングの速度」よりも「データベースアクセスやビジネスロジック」が総処理時間の大半を占めるため、パフォーマンス差は顕著になりにくいと考えられます。

複数ルータの構成で見える実装の違い

中〜大規模なAPI では、ユーザー関連、商品関連など機能ごとにルータを分割することがあります。

Chi での分割構成:

package main

import (
    "github.com/go-chi/chi/v5"
    "net/http"
)

func usersRouter() chi.Router {
    r := chi.NewRouter()
    r.Get("/", listUsers)
    r.Post("/", createUser)
    r.Get("/{id}", getUser)
    return r
}

func productsRouter() chi.Router {
    r := chi.NewRouter()
    r.Get("/", listProducts)
    r.Post("/", createProduct)
    return r
}

func main() {
    r := chi.NewRouter()
    r.Mount("/users", usersRouter())
    r.Mount("/products", productsRouter())
    http.ListenAndServe(":8080", r)
}

func listUsers(w http.ResponseWriter, r *http.Request) {}
func createUser(w http.ResponseWriter, r *http.Request) {}
func getUser(w http.ResponseWriter, r *http.Request) {}
func listProducts(w http.ResponseWriter, r *http.Request) {}
func createProduct(w http.ResponseWriter, r *http.Request) {}

Chi の Mount() は子ルータをマウントするメソッドで、この構造により各機能のハンドラーを独立したモジュールとして管理できます。

Gin での分割構成:

package main

import (
    "github.com/gin-gonic/gin"
)

func setupUsersRoutes(r *gin.RouterGroup) {
    r.GET("/", listUsers)
    r.POST("/", createUser)
    r.GET("/:id", getUser)
}

func setupProductsRoutes(r *gin.RouterGroup) {
    r.GET("/", listProducts)
    r.POST("/", createProduct)
}

func main() {
    r := gin.Default()
    setupUsersRoutes(r.Group("/users"))
    setupProductsRoutes(r.Group("/products"))
    r.Run(":8080")
}

func listUsers(c *gin.Context) {}
func createUser(c *gin.Context) {}
func getUser(c *gin.Context) {}
func listProducts(c *gin.Context) {}
func createProduct(c *gin.Context) {}

Gin は RouterGroup という概念で複数ルータを管理します。このアプローチはスケーラビリティに優れており、グループごとに異なるミドルウェアを適用することも簡単です。

実装上のよくあるハマりどころ

Chi での課題:

  • 標準の http.Handler インターフェースに依存するため、JSON のシリアライゼーションを自分で書く必要がある場合がある
  • ミドルウェアチェーンが明示的でない分、処理順序の理解が少し難しい

Gin での課題:

  • gin.Context が肥大化し、どのメソッドを使うべきか迷うことがある
  • エラーハンドリングが独特で、標準的な Go の慣例と異なる
  • 他の Goライブラリとの統合時に gin.Context への変換が必要になることもある

実務レベルでの責任分離パターン

どちらを選ぶにしても、ハンドラーの中にビジネスロジックを直接書くことは避けるべきです。サービス層を分離する例を見てみましょう。

package main

import (
    "github.com/go-chi/chi/v5"
    "net/http"
)

type User struct {
    ID   string
    Name string
}

type UserService struct {}

func (s *UserService) GetUser(id string) (*User, error) {
    // データベース処理
    return &User{ID: id, Name: "John"}, nil
}

type UserHandler struct {
    service *UserService
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    user, err := h.service.GetUser(id)
    if err != nil {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    // JSON 応答
    w.Header().Set("Content-Type", "application/json")
    // json.NewEncoder(w).Encode(user)
}

func main() {
    service := &UserService{}
    handler := &UserHandler{service: service}
    
    r := chi.NewRouter()
    r.Get("/users/{id}", handler.GetUser)
    http.ListenAndServe(":8080", r)
}

このパターンにより、ハンドラーはリクエスト/レスポンスの仲介役に徹し、ビジネスロジックはサービス層に責任を持たせることができます。Chi も Gin も同じパターンを使えるため、この点で大きな差はありません。

JSON バリデーションと型安全性

リクエストボディの JSON をパースするときも、両者でアプローチが異なります。

Chi でのバリデーション:

package main

import (
    "encoding/json"
    "net/http"
)

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    if req.Name == "" {
        http.Error(w, "Name is required", http.StatusBadRequest)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}

Gin でのバリデーション:

package main

import (
    "github.com/gin-gonic/gin"
)

type CreateUserRequest struct {
    Name  string `json:"name" binding:"required"`
    Email string `json:"email" binding:"required,email"`
}

func createUserHandler(c *gin.Context) {
    var req CreateUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    
    c.JSON(201, gin.H{"status": "created"})
}

Gin は binding タグにより宣言的にバリデーション規則を定義でき、複数のバリデーションを組み合わせやすいという利点があります。Chi では手動でバリデーション処理を書く必要がありますが、その分、細かい制御が可能です。

まとめ

  • Chi は標準ライブラリに忠実で、他の Goライブラリとの互換性が高い。長期的なメンテナンスを重視するプロジェクト向け
  • Gin は Express 的な開発体験で、パフォーマンスと開発速度のバランスが優れている。スタートアップやプロトタイプ向け
  • パフォーマンス差は実務では顕著ではない。データベースやビジネスロジックが処理時間の大半を占める
  • ミドルウェア設計とリクエストバリデーションでアプローチが異なり、チーム体制によって選択が変わる
  • どちらを選んでもハンドラーとサービス層の責任分離は重要

よくある質問(FAQ)

Chi と Gin を同じプロジェクトで混在させても大丈夫ですか?

技術的には可能ですが、実装の一貫性やメンテナンス性の観点からはお勧めしません。ルーティングライブラリはプロジェクト全体の基盤になるため、1つに統一することをお勧めします。既存プロジェクトへの導入が必要な場合は、段階的に移行するか、新規の API エンドポイントに絞って導入を検討してください。

Chi と Gin のどちらが「より安全」ですか?

セキュリティ面では、両者の基本的な安全性に大きな差はありません。どちらを選ぶにしても、CORS、認証、入力検証、SQL インジェクション対策などはあなたが実装する必要があります。ただし Gin のバリデーション機能(binding タグ)が充実しているため、うっかり忘れを減らせるという点ではメリットがあります。

既に Chi で始めたプロジェクトを Gin に移行できますか?

理論上は可能ですが、実装負荷は高いです。理由は、ハンドラーのシグネチャ(Chi は http.ResponseWriter, *http.Request、Gin は *gin.Context)が異なるため、全ハンドラーの書き換えが必要になります。新規プロジェクトであれば移行コストが低いですが、既存コードベースは当初の選択を尊重する方が現実的です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次