Skip to content

RESTful API 与 Gin 集成

本篇是 Go 微服务系列的第四篇。上一篇我们学习了 gRPC——它是内部服务间通信的最佳选择。但对外(前端、移动端、第三方)暴露的接口通常还是 RESTful API,因为浏览器和移动端对 HTTP/JSON 支持最好。本篇将系统讲解 RESTful API 设计规范、Gin 框架的微服务集成、统一响应格式、API 版本管理、Swagger 文档,以及如何在一个进程中同时提供 gRPC 和 RESTful 双端口服务。

一、RESTful API 设计规范

1. REST 的核心思想

REST(Representational State Transfer)由 Roy Fielding 在 2000 年的博士论文中提出。核心思想:

  • 资源(Resource):所有事物都抽象为资源,每个资源有唯一 URI。
  • 统一接口:使用标准 HTTP 方法表达对资源的操作。
  • 无状态:每个请求包含全部所需信息,服务端不保存会话状态。
  • 分层架构:客户端不感知中间是否存在代理、网关。

2. HTTP 方法语义

方法含义安全幂等示例
GET获取资源GET /users/1
POST创建资源POST /users
PUT全量更新资源PUT /users/1
PATCH部分更新资源PATCH /users/1
DELETE删除资源DELETE /users/1
  • 安全:不修改服务端状态。
  • 幂等:多次执行结果相同(重试安全)。

3. URI 设计原则

  • 用名词复数表示资源集合:/users/orders
  • 用 ID 表示具体资源:/users/123
  • 用路径表达从属关系:/users/123/orders
  • 用查询参数表达过滤、排序、分页:/users?role=admin&page=2&page_size=20
  • 不在 URI 中放动词:/getUser?id=1 ❌ → GET /users/1 ✅。
  • 使用 kebab-case:/order-items 而不是 /order_items/orderItems

4. HTTP 状态码

分类含义常见码
2xx成功200 OK / 201 Created / 204 No Content
3xx重定向301 / 302 / 304 Not Modified
4xx客户端错误400 / 401 / 403 / 404 / 409 / 422 / 429
5xx服务端错误500 / 502 / 503 / 504

RESTful 实践中,应当用 HTTP 状态码表达结果,而不是统一返回 200 然后在 body 里写 code: 500。但国内不少团队采用「统一 200 + 业务码」的方案,后面我们会讲如何取舍。

5. RESTful 与 gRPC 的选择

场景推荐理由
浏览器 / 移动端调用REST原生支持
第三方开放 APIREST通用、易调试
内部服务间同步调用gRPC高性能、强类型
大数据量、低延迟gRPCProtobuf 二进制 + HTTP/2 多路复用
流式传输gRPC原生支持双向流
简单 CRUD、对外服务REST工具链丰富、可读性好

实际项目:对外 REST + 内部 gRPC,由 API 网关做协议转换。

二、Gin 构建 RESTful 微服务

1. 最简 RESTful 服务

go
package main

import (
	"log"
	"net/http"
	"strconv"
	"sync"
	"sync/atomic"

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

type Product struct {
	ID    int64   `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
	Stock int     `json:"stock"`
}

type Store struct {
	mu      sync.RWMutex
	products map[int64]*Product
	nextID  int64
}

func NewStore() *Store {
	return &Store{
		products: make(map[int64]*Product),
		nextID:   1,
	}
}

func (s *Store) Create(p *Product) *Product {
	s.mu.Lock()
	defer s.mu.Unlock()
	p.ID = atomic.AddInt64(&s.nextID, 1) - 1
	s.products[p.ID] = p
	return p
}

func (s *Store) Get(id int64) (*Product, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	p, ok := s.products[id]
	return p, ok
}

func (s *Store) List() []*Product {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]*Product, 0, len(s.products))
	for _, p := range s.products {
		out = append(out, p)
	}
	return out
}

func (s *Store) Delete(id int64) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.products[id]; !ok {
		return false
	}
	delete(s.products, id)
	return true
}

func main() {
	store := NewStore()
	r := gin.Default()

	// RESTful 路由
	r.GET("/products", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"data": store.List()})
	})
	r.GET("/products/:id", func(c *gin.Context) {
		id, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
			return
		}
		p, ok := store.Get(id)
		if !ok {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		c.JSON(http.StatusOK, gin.H{"data": p})
	})
	r.POST("/products", func(c *gin.Context) {
		var p Product
		if err := c.ShouldBindJSON(&p); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		created := store.Create(&p)
		c.JSON(http.StatusCreated, gin.H{"data": created})
	})
	r.DELETE("/products/:id", func(c *gin.Context) {
		id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
		if !store.Delete(id) {
			c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
			return
		}
		c.Status(http.StatusNoContent)
	})

	log.Println("rest server on :8080")
	if err := r.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

2. 参数绑定与校验

Gin 内置了 validator 库,可以通过结构体 tag 进行参数校验:

go
package main

import (
	"net/http"

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

type CreateUserRequest struct {
	Username string `json:"username" binding:"required,min=3,max=32"`
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required,min=8"`
	Age      int    `json:"age" binding:"gte=0,lte=150"`
}

func main() {
	r := gin.Default()
	r.POST("/users", func(c *gin.Context) {
		var req CreateUserRequest
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{
				"error":  "invalid request",
				"detail": err.Error(),
			})
			return
		}
		c.JSON(http.StatusCreated, gin.H{"user": req.Username})
	})
	_ = r.Run(":8080")
}

常用校验 tag:

  • required:必填
  • min / max:长度或数值范围
  • email:邮箱格式
  • oneof=a b c:枚举
  • url:URL 格式
  • datetime=2006-01-02:日期格式
  • dive:对切片/Map元素递归校验

三、统一响应格式设计

1. 为什么要统一

前后端协作最大的痛点之一就是响应格式不统一。如果 /users 返回 {...}/orders 返回 [...],错误返回 {error: "..."},前端就要为每个接口写适配代码。统一格式让前端封装统一的请求库成为可能。

2. 推荐格式

json
{
  "code": 0,
  "message": "ok",
  "data": {...},
  "request_id": "abc-123"
}
  • code:0 表示成功,非 0 表示业务错误码。
  • message:人类可读的提示信息。
  • data:业务数据,失败时为 null
  • request_id:请求 ID,便于排查问题。

3. 实现

go
package main

import (
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

type Response struct {
	Code      int         `json:"code"`
	Message   string      `json:"message"`
	Data      interface{} `json:"data,omitempty"`
	RequestID string      `json:"request_id"`
}

func Success(c *gin.Context, data interface{}) {
	c.JSON(http.StatusOK, Response{
		Code:      0,
		Message:   "ok",
		Data:      data,
		RequestID: c.GetString("request_id"),
	})
}

func Fail(c *gin.Context, httpStatus, code int, msg string) {
	c.JSON(httpStatus, Response{
		Code:      code,
		Message:   msg,
		RequestID: c.GetString("request_id"),
	})
}

// RequestIDMiddleware 注入 request id
func RequestIDMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		rid := c.GetHeader("X-Request-ID")
		if rid == "" {
			rid = uuid.NewString()
		}
		c.Set("request_id", rid)
		c.Header("X-Request-ID", rid)
		c.Next()
	}
}

// LoggingMiddleware 简单访问日志
func LoggingMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()
		c.Logger().Printf("%s %s %d %v rid=%s",
			c.Request.Method, c.Request.URL.Path,
			c.Writer.Status(), time.Since(start), c.GetString("request_id"))
	}
}

func main() {
	r := gin.New()
	r.Use(RequestIDMiddleware(), LoggingMiddleware(), gin.Recovery())

	r.GET("/ping", func(c *gin.Context) {
		Success(c, gin.H{"message": "pong"})
	})
	r.GET("/error", func(c *gin.Context) {
		Fail(c, http.StatusBadRequest, 10001, "invalid parameter")
	})
	_ = r.Run(":8080")
}

4. 业务错误码体系

设计一套清晰的业务错误码体系很重要,建议分段:

  • 0:成功
  • 1xxxx:通用错误(参数、认证、限流)
  • 2xxxx:用户模块
  • 3xxxx:订单模块
  • 4xxxx:支付模块
  • 5xxxx:商品模块
go
package main

import (
	"fmt"
	"net/http"

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

type BizError struct {
	HTTPStatus int
	Code       int
	Message    string
}

func (e *BizError) Error() string {
	return fmt.Sprintf("biz error %d: %s", e.Code, e.Message)
}

var (
	ErrInvalidParam = &BizError{http.StatusBadRequest, 10001, "参数错误"}
	ErrUnauthorized = &BizError{http.StatusUnauthorized, 10002, "未认证"}
	ErrRateLimited  = &BizError{http.StatusTooManyRequests, 10003, "请求过于频繁"}
	ErrUserNotFound = &BizError{http.StatusNotFound, 20001, "用户不存在"}
	ErrUserExists   = &BizError{http.StatusConflict, 20002, "用户已存在"}
)

// BizErrorHandler 统一业务错误处理中间件
func BizErrorHandler() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()
		if len(c.Errors) == 0 {
			return
		}
		err := c.Errors.Last().Err
		if bizErr, ok := err.(*BizError); ok {
			c.JSON(bizErr.HTTPStatus, Response{
				Code:      bizErr.Code,
				Message:   bizErr.Message,
				RequestID: c.GetString("request_id"),
			})
			return
		}
		c.JSON(http.StatusInternalServerError, Response{
			Code:      50000,
			Message:   "internal error",
			RequestID: c.GetString("request_id"),
		})
	}
}

func main() {
	r := gin.New()
	r.Use(RequestIDMiddleware(), BizErrorHandler(), gin.Recovery())

	r.GET("/users/:id", func(c *gin.Context) {
		// 模拟查不到用户
		_ = c.Error(ErrUserNotFound)
	})
	_ = r.Run(":8080")
}

四、API 版本管理策略

API 一旦上线就会有多版本并存的需求。常见策略:

1. URI 版本(最常用)

GET /v1/users
GET /v2/users

优点:清晰、易于调试、CDN 友好。缺点:URI 不再纯粹表达资源。

2. Header 版本

GET /users
API-Version: 2

优点:URI 干净。缺点:不直观、难调试。

3. Accept Header 版本

GET /users
Accept: application/vnd.example.v2+json

优点:符合 REST 规范。缺点:复杂、不易调试。

4. Gin 实现版本分组

go
package main

import (
	"net/http"

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

func v1Routes(r *gin.RouterGroup) {
	r.GET("/users", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"version": "v1", "users": []string{"alice"}})
	})
}

func v2Routes(r *gin.RouterGroup) {
	r.GET("/users", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"version": "v2", "users": []string{"alice", "bob"}})
	})
}

func main() {
	r := gin.Default()
	v1 := r.Group("/v1")
	v1Routes(v1)
	v2 := r.Group("/v2")
	v2Routes(v2)
	_ = r.Run(":8080")
}

版本演进建议:v2 必须兼容 v1 至少一个发布周期,给客户端充足时间迁移;废弃 v1 时通过响应头 SunsetDeprecation 提示。

五、Swagger / OpenAPI 集成

1. OpenAPI 简介

OpenAPI(前身 Swagger)是描述 RESTful API 的标准规范,一个 YAML/JSON 文件描述了 API 的所有路径、参数、响应。围绕它有完整的工具链:

  • Swagger UI:可视化 API 文档
  • 代码生成:从 OpenAPI 生成多语言客户端
  • Mock 服务:基于规范生成 mock 数据
  • API 网关:基于规范做校验和路由

2. swaggo/swag

swaggo/swag 通过解析 Go 代码注释生成 OpenAPI 文档,并集成 Swagger UI。安装:

bash
go install github.com/swaggo/swag/cmd/swag@latest

3. 注释规范

go
package main

import (
	"net/http"

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

// @Summary 创建用户
// @Description 创建一个新用户
// @Tags 用户
// @Accept json
// @Produce json
// @Param body body CreateUserRequest true "用户信息"
// @Success 201 {object} User
// @Failure 400 {object} Response
// @Router /v1/users [post]
func createUser(c *gin.Context) {}

// @Summary 获取用户
// @Description 根据 ID 获取用户详情
// @Tags 用户
// @Produce json
// @Param id path int true "用户 ID"
// @Success 200 {object} User
// @Failure 404 {object} Response
// @Router /v1/users/{id} [get]
func getUser(c *gin.Context) {}

type User struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

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

type Response struct {
	Code int    `json:"code"`
	Msg  string `json:"msg"`
}

// @title 用户服务 API
// @version 1.0
// @description 示例用户微服务
// @host localhost:8080
// @BasePath /v1
func main() {
	r := gin.Default()
	v1 := r.Group("/v1")
	v1.POST("/users", createUser)
	v1.GET("/users/:id", getUser)
	_ = r.Run(":8080")
}

4. 生成并集成文档

执行:

bash
swag init -g cmd/server/main.go -o api/docs

然后在代码里集成 Swagger UI:

go
package main

import (
	_ "github.com/example/user-service/api/docs" // 引入生成的 docs
	swaggerFiles "github.com/swaggo/files"
	ginSwagger "github.com/swaggo/gin-swagger"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()
	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
	_ = r.Run(":8080")
}

访问 http://localhost:8080/swagger/index.html 即可看到可视化文档。

六、Gin + gRPC 双端口服务

实际项目最常见的架构:一个进程同时提供 gRPC(内部调用)和 RESTful(对外)两种接口,业务逻辑共享。

go
package main

import (
	"context"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

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

// 业务逻辑(共享给 gRPC 和 REST)
type UserService struct {
	users map[int64]*User
}

type User struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

func (s *UserService) Create(name string) *User {
	id := int64(len(s.users) + 1)
	u := &User{ID: id, Name: name}
	s.users[id] = u
	return u
}

func (s *UserService) Get(id int64) (*User, bool) {
	u, ok := s.users[id]
	return u, ok
}

// startREST 启动 REST 端口
func startREST(svc *UserService) *http.Server {
	r := gin.Default()
	r.POST("/users", func(c *gin.Context) {
		var req struct {
			Name string `json:"name" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
		u := svc.Create(req.Name)
		c.JSON(http.StatusCreated, u)
	})
	r.GET("/users/:id", func(c *gin.Context) {
		// 简化处理
		c.JSON(http.StatusOK, gin.H{"id": 1, "name": "alice"})
	})
	srv := &http.Server{Addr: ":8080", Handler: r}
	go func() {
		log.Printf("rest server on :8080")
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("rest: %v", err)
		}
	}()
	return srv
}

// startGRPC 启动 gRPC 端口(这里用 net.Listen 模拟)
func startGRPC(svc *UserService) net.Listener {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	go func() {
		log.Printf("grpc server on :50051")
		// 真实场景:grpc.NewServer() + Register + Serve(lis)
		for {
			conn, err := lis.Accept()
			if err != nil {
				return
			}
			go func(c net.Conn) {
				defer c.Close()
				_ = c
			}(conn)
		}
	}()
	return lis
}

func main() {
	svc := &UserService{users: map[int64]*User{}}
	svc.Create("alice")
	svc.Create("bob")

	restSrv := startREST(svc)
	grpcLis := startGRPC(svc)

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("shutting down")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = restSrv.Shutdown(ctx)
	_ = grpcLis.Close()
	log.Println("server exited")
}

这种模式的好处:内部调用走 gRPC 享受高性能和强类型,对外走 REST 兼容前端和第三方。

七、完整示例:电商商品服务

下面把本篇内容整合为一个更完整的电商商品服务示例:

go
package main

import (
	"log"
	"net/http"
	"strconv"
	"sync"
	"sync/atomic"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
)

// === 数据模型 ===

type Product struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Price     float64   `json:"price"`
	Stock     int       `json:"stock"`
	Category  string    `json:"category"`
	CreatedAt time.Time `json:"created_at"`
}

// === 存储层 ===

type ProductRepo struct {
	mu       sync.RWMutex
	products map[int64]*Product
	nextID   int64
}

func NewProductRepo() *ProductRepo {
	return &ProductRepo{products: make(map[int64]*Product)}
}

func (r *ProductRepo) Create(p *Product) *Product {
	p.ID = atomic.AddInt64(&r.nextID, 1)
	p.CreatedAt = time.Now()
	r.mu.Lock()
	r.products[p.ID] = p
	r.mu.Unlock()
	return p
}

func (r *ProductRepo) Get(id int64) (*Product, bool) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	p, ok := r.products[id]
	return p, ok
}

func (r *ProductRepo) List(page, pageSize int) ([]*Product, int) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	total := len(r.products)
	out := make([]*Product, 0, pageSize)
	for _, p := range r.products {
		out = append(out, p)
	}
	start := (page - 1) * pageSize
	if start >= len(out) {
		return nil, total
	}
	end := start + pageSize
	if end > len(out) {
		end = len(out)
	}
	return out[start:end], total
}

func (r *ProductRepo) Update(id int64, patch map[string]interface{}) (*Product, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	p, ok := r.products[id]
	if !ok {
		return nil, false
	}
	if name, ok := patch["name"].(string); ok {
		p.Name = name
	}
	if price, ok := patch["price"].(float64); ok {
		p.Price = price
	}
	if stock, ok := patch["stock"].(float64); ok {
		p.Stock = int(stock)
	}
	if cat, ok := patch["category"].(string); ok {
		p.Category = cat
	}
	return p, true
}

func (r *ProductRepo) Delete(id int64) bool {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.products[id]; !ok {
		return false
	}
	delete(r.products, id)
	return true
}

// === 统一响应 ===

type Response struct {
	Code      int         `json:"code"`
	Message   string      `json:"message"`
	Data      interface{} `json:"data,omitempty"`
	RequestID string      `json:"request_id"`
}

func main() {
	repo := NewProductRepo()
	// 预置数据
	repo.Create(&Product{Name: "iPhone 16", Price: 7999, Stock: 100, Category: "phone"})
	repo.Create(&Product{Name: "MacBook Pro", Price: 19999, Stock: 50, Category: "laptop"})

	r := gin.New()
	r.Use(func() gin.HandlerFunc {
		return func(c *gin.Context) {
			rid := c.GetHeader("X-Request-ID")
			if rid == "" {
				rid = uuid.NewString()
			}
			c.Set("request_id", rid)
			c.Header("X-Request-ID", rid)
			c.Next()
		}
	}(), gin.Recovery(), gin.Logger())

	v1 := r.Group("/v1")

	// 创建商品
	v1.POST("/products", func(c *gin.Context) {
		var p Product
		if err := c.ShouldBindJSON(&p); err != nil {
			c.JSON(http.StatusBadRequest, Response{Code: 10001, Message: err.Error(), RequestID: c.GetString("request_id")})
			return
		}
		if p.Name == "" || p.Price <= 0 {
			c.JSON(http.StatusBadRequest, Response{Code: 10002, Message: "name and price required", RequestID: c.GetString("request_id")})
			return
		}
		created := repo.Create(&p)
		c.JSON(http.StatusCreated, Response{Code: 0, Message: "ok", Data: created, RequestID: c.GetString("request_id")})
	})

	// 获取单个商品
	v1.GET("/products/:id", func(c *gin.Context) {
		id, err := strconv.ParseInt(c.Param("id"), 10, 64)
		if err != nil {
			c.JSON(http.StatusBadRequest, Response{Code: 10001, Message: "invalid id", RequestID: c.GetString("request_id")})
			return
		}
		p, ok := repo.Get(id)
		if !ok {
			c.JSON(http.StatusNotFound, Response{Code: 30001, Message: "not found", RequestID: c.GetString("request_id")})
			return
		}
		c.JSON(http.StatusOK, Response{Code: 0, Message: "ok", Data: p, RequestID: c.GetString("request_id")})
	})

	// 列表(分页)
	v1.GET("/products", func(c *gin.Context) {
		page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
		pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
		if page <= 0 {
			page = 1
		}
		if pageSize <= 0 || pageSize > 100 {
			pageSize = 10
		}
		list, total := repo.List(page, pageSize)
		c.JSON(http.StatusOK, Response{
			Code: 0, Message: "ok", RequestID: c.GetString("request_id"),
			Data: gin.H{"items": list, "total": total, "page": page, "page_size": pageSize},
		})
	})

	// 部分更新
	v1.PATCH("/products/:id", func(c *gin.Context) {
		id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
		var patch map[string]interface{}
		if err := c.ShouldBindJSON(&patch); err != nil {
			c.JSON(http.StatusBadRequest, Response{Code: 10001, Message: err.Error(), RequestID: c.GetString("request_id")})
			return
		}
		p, ok := repo.Update(id, patch)
		if !ok {
			c.JSON(http.StatusNotFound, Response{Code: 30001, Message: "not found", RequestID: c.GetString("request_id")})
			return
		}
		c.JSON(http.StatusOK, Response{Code: 0, Message: "ok", Data: p, RequestID: c.GetString("request_id")})
	})

	// 删除
	v1.DELETE("/products/:id", func(c *gin.Context) {
		id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
		if !repo.Delete(id) {
			c.JSON(http.StatusNotFound, Response{Code: 30001, Message: "not found", RequestID: c.GetString("request_id")})
			return
		}
		c.Status(http.StatusNoContent)
	})

	// 健康检查
	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	log.Println("product service on :8080")
	if err := r.Run(":8080"); err != nil {
		log.Fatal(err)
	}
}

启动后可以用 curl 测试完整 CRUD 流程:

bash
# 创建
curl -X POST http://localhost:8080/v1/products \
  -H "Content-Type: application/json" \
  -d '{"name":"iPad Pro","price":6999,"stock":30,"category":"tablet"}'

# 列表
curl http://localhost:8080/v1/products

# 获取单个
curl http://localhost:8080/v1/products/1

# 部分更新
curl -X PATCH http://localhost:8080/v1/products/1 \
  -H "Content-Type: application/json" \
  -d '{"price":7499}'

# 删除
curl -X DELETE http://localhost:8080/v1/products/1

八、小结

本篇我们学习了用 Gin 构建 RESTful 微服务的核心知识:

  • REST 设计规范:用名词复数表达资源、用 HTTP 方法表达操作、用状态码表达结果。
  • REST vs gRPC:对外 REST,内部 gRPC,网关做转换。
  • 统一响应格式code/message/data/request_id 四件套,便于前端封装统一请求库。
  • 业务错误码体系:按模块分段,避免与 HTTP 状态码混淆。
  • API 版本管理:URI 版本(/v1)最实用,Gin 用 RouterGroup 自然支持。
  • Swagger 文档:用 swaggo/swag 解析注释生成 OpenAPI + Swagger UI。
  • 双端口服务:一个进程同时跑 gRPC 和 REST,业务逻辑共享。

下一篇我们会进入 API 网关模式,学习如何用 Gin + httputil.ReverseProxy 实现一个统一的微服务入口,承担路由、鉴权、限流、协议转换等职责。

延伸阅读