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

211 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 第 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 逐行解析
```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` 示例:
```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
```go
// 初始化
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
```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`:
```go
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`
```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`
```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`
```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`
```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` 追加
```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` 单测。