StreamBox/docs/07-实战-StreamBox项目解析.md

6.8 KiB
Raw Blame History

第 7 章 实战:StreamBox 项目解析

学完本章你将:彻底看懂本项目分层架构,能独立新增一个 CRUD 模块,并理解配置/日志/优雅关闭等生产要素。

7.1 项目结构总览

StreamBox/
├── cmd/server/main.go      # 入口:配置→日志→路由→启动→优雅关闭
├── config/
│   ├── config.go           # Viper 加载逻辑
│   └── config.yaml         # 默认配置
├── internal/
│   ├── handler/            # HTTP 层:参数绑定、调用 service、返回 response
│   ├── service/            # 业务层:核心逻辑,可被多 handler 复用
│   ├── repo/               # 数据层:DB/缓存/外部接口(当前为内存 demo)
│   ├── model/              # 数据模型
│   ├── middleware/         # Recovery/ZapLogger/Cors
│   └── router/router.go    # 路由组装与依赖注入
├── pkg/
│   ├── logger/             # Zap 封装
│   └── response/           # 统一 Result
├── api/                    # Swagger/API 文档(待扩展)
└── Makefile                # run/build/tidy/fmt

分层原则:handler → service → repo 单向依赖,pkg 为可复用基础库,internal 禁止外部 import。

7.2 入口:cmd/server/main.go 逐行解析

cfg, err := config.Load()                 // 1. 加载配置
logger.Init(cfg.Log.Level, cfg.Log.Encoding) // 2. 初始化日志
r := router.New(cfg)                      // 3. 构建 Gin 引擎
srv := &http.Server{Addr: addr, Handler: r} // 4. 用标准 http.Server 包装
go srv.ListenAndServe()                   // 5. goroutine 启动
<-quit                                   // 6. 阻塞等待 SIGINT/SIGTERM
srv.Shutdown(ctx)                         // 7. 10 秒优雅关闭

为什么不用 r.Run():r.Run() 内部直接 ListenAndServe 且无法捕获信号做 Shutdown,会丢失正在处理的请求。生产务必用 http.Server + Shutdown。

7.3 配置:config/config.go + viper

config.yaml 示例:

server:
  port: 8080
  mode: debug
  read_timeout: 10s
  write_timeout: 10s
log:
  level: debug
  encoding: console
cors:
  allow_origins: ["*"]

config.Load() 逻辑:

  1. viper.SetConfigFile("config/config.yaml") + ReadInConfig
  2. viper.AutomaticEnv() 环境变量覆盖(SERVER_PORT=9090 可覆盖 server.port)
  3. Unmarshal 到 Config 结构体

新增配置:结构体加字段 → yaml 加默认值 → 代码中 cfg.NewField 使用。

7.4 日志:pkg/logger

// 初始化
logger.Init(cfg.Log.Level, cfg.Log.Encoding)
// 使用
logger.Log.Info("request", zap.String("path", path))
logger.Sugar.Infow("server starting", "addr", addr)
  • console 适合开发,json 适合生产(便于 ELK 收集)
  • level 动态控制:debug/info/warn/error
  • defer logger.Sync() 刷盘,避免日志丢失

7.5 路由与依赖注入:internal/router/router.go

func New(cfg *config.Config) *gin.Engine {
    gin.SetMode(cfg.Server.Mode)
    r := gin.New()
    r.Use(middleware.Recovery())
    r.Use(middleware.ZapLogger())
    r.Use(middleware.Cors(cfg.Cors))

    health := handler.NewHealthHandler()
    r.GET("/health", health.Check)

    pingRepo := repo.NewPingRepo()
    pingSvc := service.NewPingService(pingRepo)
    pingHandler := handler.NewPingHandler(pingSvc)
    v1 := r.Group("/api/v1")
    { v1.GET("/ping", pingHandler.Ping) }
    return r
}

新增模块时复制这三行:repo → service → handler,然后挂到分组。

7.6 Handler/Service/Repo 职责

  • handler(internal/handler/ping.go):只做 HTTP 相关——取参、校验、调 service、组响应。不写业务逻辑。
  • service(internal/service/ping.go):业务逻辑,可组合多个 repo,返回 error 由 handler 转为响应。
  • repo(internal/repo/ping.go):数据访问,当前为内存实现,未来可替换为 GORM/SQLite。

示例 PingService:

type PingService struct { repo *repo.PingRepo }
func (s *PingService) Ping() string { return s.repo.Ping() }

7.7 完整 CRUD 实战(以 Book 为例)

按分层新增一个资源,需 4 个文件 + 1 处路由:

1. model internal/model/book.go

package model

type Book struct {
    ID     uint   `json:"id" gorm:"primaryKey"`
    Title  string `json:"title" binding:"required"`
    Author string `json:"author" binding:"required"`
}

2. repo internal/repo/book.go

package repo

import "streambox/internal/model"

type BookRepo struct { db map[uint]*model.Book; nextID uint }
func NewBookRepo() *BookRepo { return &BookRepo{db: make(map[uint]*model.Book), nextID: 1} }
func (r *BookRepo) Create(b *model.Book) *model.Book { b.ID = r.nextID; r.nextID++; r.db[b.ID] = b; return b }
func (r *BookRepo) Get(id uint) (*model.Book, bool) { b, ok := r.db[id]; return b, ok }
func (r *BookRepo) List() []*model.Book { // ...
    return nil
}

3. service internal/service/book.go

package service

import "streambox/internal/model"

type BookService struct { repo *repo.BookRepo }
func NewBookService(repo *repo.BookRepo) *BookService { return &BookService{repo: repo} }
func (s *BookService) Create(b *model.Book) *model.Book { return s.repo.Create(b) }

4. handler internal/handler/book.go

type BookHandler struct { svc *service.BookService }
func NewBookHandler(svc *service.BookService) *BookHandler { return &BookHandler{svc: svc} }

func (h *BookHandler) Create(c *gin.Context) {
    var req model.Book
    if err := c.ShouldBindJSON(&req); err != nil {
        response.BadRequest(c, err.Error()); return
    }
    book := h.svc.Create(&req)
    response.Success(c, book)
}
func (h *BookHandler) Get(c *gin.Context) {
    id, _ := strconv.Atoi(c.Param("id"))
    if b, ok := h.svc.Get(uint(id)); ok {
        response.Success(c, b)
    } else {
        response.Fail(c, 404, 404, "book not found")
    }
}

5. router internal/router/router.go 追加

bookRepo := repo.NewBookRepo()
bookSvc := service.NewBookService(bookRepo)
bookHandler := handler.NewBookHandler(bookSvc)
v1.POST("/books", bookHandler.Create)
v1.GET("/books/:id", bookHandler.Get)
v1.GET("/books", bookHandler.List)

重启 make run,curl -X POST localhost:8080/api/v1/books -H 'Content-Type: application/json' -d '{"title":"Go","author":"A"}' 验证。

替换为真实数据库时,只需将 BookRepo 内部改为 *gorm.DB,service/handler 无需改动,这就是分层的价值。

7.8 统一响应与错误

所有 handler 均通过 pkg/response 返回,避免 c.JSON 散落各处,便于前端统一处理。

7.9 动手练习

  1. 按上述步骤完整实现 Book 的 List/Update/Delete。
  2. 将 BookRepo 替换为 GORM + SQLite(项目已依赖 glebarez/sqlite)。
  3. 为 Book 补充 go test 单测。