211 lines
6.8 KiB
Markdown
211 lines
6.8 KiB
Markdown
# 第 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` 单测。
|