第 2 章 · 路由、参数与中间件
本章目标:搭建 api-go-demo 的 /api/v1 路由组;绑定路径参数、查询参数、JSON Body;实现 RequestID、Recovery、CORS 中间件;将 ch01 单文件重构为多包结构;提供本章结束时可运行的完整代码;对照 fastapi-web ch02 APIRouter 与 flask-web ch02 Blueprint。
学时建议:3~4 小时(含 2 小时跟练)
前置:完成 gin-web ch01、go-dev ch12(HTTP)。
2.1 场景说明:商品 REST 路由骨架
api-go-demo 需对外提供与 svc-demo、api-demo 风格一致的商品读接口,字段统一为 slug + is_published + price(分)。
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | /health | 无 | 健康检查 |
| GET | /api/v1/products | 无 | 列表(默认仅 is_published=true) |
| GET | /api/v1/products/:slug | 无 | 按 slug 详情 |
| POST | /api/v1/products | 无(ch06 改 admin) | 创建商品(本章 mock 返回) |
ch01 main.go 单文件
│
▼ 本章重构
api-go-demo/
├── cmd/server/main.go
└── internal/
├── router/router.go
├── handler/product.go
└── middleware/
├── request_id.go
├── recovery.go
└── cors.go
对照章节:
| 主题 | flask-web ch02 | fastapi-web ch02 | gin-web ch02 |
|---|---|---|---|
| 路由模块化 | Blueprint | APIRouter | Group + handler 包 |
| 公共逻辑 | before_request | Depends | 中间件链 |
| 版本前缀 | url_prefix | prefix= | r.Group("/api/v1") |
2.2 逐步操作表
| 步骤 | 操作 | 验证 |
|---|---|---|
| 1 | 创建 internal/router/router.go | 包可编译 |
| 2 | 创建 internal/handler/product.go | Handler 方法签名正确 |
| 3 | 创建三个 middleware 文件 | 各自 package middleware |
| 4 | 修改 cmd/server/main.go 调用 router.New | go build 无错 |
| 5 | go run ./cmd/server | 监听 8080 |
| 6 | curl -i GET /api/v1/products/go-handbook | 200 + X-Request-ID |
| 7 | POST JSON 创建商品 | 201 或 400 校验 |
| 8 | OPTIONS 预检 | 204 |
2.3 路由组与 Engine 初始化
// internal/router/router.go
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"example.com/api-go-demo/internal/handler"
"example.com/api-go-demo/internal/middleware"
)
func New(h *handler.ProductHandler) *gin.Engine {
r := gin.New()
r.Use(gin.Logger())
r.Use(middleware.Recovery())
r.Use(middleware.RequestID())
r.Use(middleware.CORS())
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1 := r.Group("/api/v1")
{
v1.GET("/products", h.ListProducts)
v1.GET("/products/:slug", h.GetProduct)
v1.POST("/products", h.CreateProduct)
}
return r
}
| 设计点 | 说明 |
|---|---|
gin.New() | 不用 Default,避免重复 Logger |
| 中间件顺序 | Recovery → RequestID → CORS → 路由 |
Group("/api/v1") | 版本化 API,与 Flask/FastAPI 前缀一致 |
fastapi-web ch02 等价写法:
router = APIRouter(prefix="/api/v1/products", tags=["商品"])
app.include_router(router)
2.4 ProductHandler 完整代码
// internal/handler/product.go
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type ProductHandler struct {
// ch05 注入 *service.ProductService
}
func NewProductHandler() *ProductHandler {
return &ProductHandler{}
}
// GET /api/v1/products
func (h *ProductHandler) ListProducts(c *gin.Context) {
onlyPub := c.DefaultQuery("published", "true") == "true"
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
// ch04/ch05 接数据库;本章 mock
items := []gin.H{}
if onlyPub {
items = append(items, gin.H{
"slug": "go-handbook", "name": "Go 手册",
"price": 6800, "is_published": true, "stock": 50,
})
}
rid, _ := c.Get("request_id")
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": len(items),
"page": page,
"page_size": pageSize,
"request_id": rid,
"only_public": onlyPub,
})
}
// GET /api/v1/products/:slug
func (h *ProductHandler) GetProduct(c *gin.Context) {
slug := c.Param("slug")
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": "VALIDATION_ERROR", "message": "slug required",
})
return
}
// mock:仅 go-handbook 存在
if slug != "go-handbook" {
c.JSON(http.StatusNotFound, gin.H{
"code": "NOT_FOUND", "message": "product not found",
})
return
}
c.JSON(http.StatusOK, gin.H{
"slug": slug,
"name": "Go 手册",
"price": 6800,
"is_published": true,
"stock": 50,
})
}
// POST /api/v1/products
type CreateProductReq struct {
Slug string `json:"slug" binding:"required,min=2,max=64"`
Name string `json:"name" binding:"required,max=200"`
Price int64 `json:"price" binding:"required,gte=0"`
Stock int `json:"stock" binding:"gte=0"`
IsPublished bool `json:"is_published"`
}
func (h *ProductHandler) CreateProduct(c *gin.Context) {
var req CreateProductReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": "VALIDATION_ERROR", "message": err.Error(),
})
return
}
c.JSON(http.StatusCreated, req)
}
2.5 参数绑定对照表
| 类型 | Gin API | 示例 URL / Body | FastAPI 等价 | Flask 等价 |
|---|---|---|---|---|
| 路径 | c.Param("slug") | /products/go-handbook | 路径参数 | <slug> |
| 查询 | c.Query / DefaultQuery | ?published=true&page=1 | Query(...) | request.args |
| JSON | c.ShouldBindJSON | POST body | Pydantic 模型 | get_json() |
| Header | c.GetHeader | Authorization | Header(...) | request.headers |
| Form | c.ShouldBind | multipart/form | Form(...) | request.form |
ShouldBind 系列:
| 方法 | 行为 |
|---|---|
ShouldBindJSON | 失败返回 error,不 abort |
BindJSON | 失败自动 400 并 abort |
ShouldBindQuery | 绑定查询到 struct |
本章选用 ShouldBindJSON 以便自定义错误 JSON 格式(ch07 统一 envelope)。
2.6 RequestID 中间件
// internal/middleware/request_id.go
package middleware