Go Web 项目实践
一套可演进的 Go Web 工程范式:目录分层、GORM、Redis、日志、依赖注入、Docker 与生产坑。
一、工程结构:可演进的分层单体
1.1 标准目录布局
Go 社区对业务项目推荐"cmd + internal + pkg"布局(参考 GitHub 的 golang-standards/project-layout,注意它仅是社区约定、非官方规范):
demo/
├── cmd/
│ └── server/
│ └── main.go # 入口:组装依赖 + 启动
├── internal/ # 私有代码,外部包无法 import(编译器强制)
│ ├── config/ # 配置加载
│ ├── router/ # 路由与中间件装配
│ ├── handler/ # HTTP 层:绑定参数、翻译响应
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层(DB / 缓存)
│ ├── model/ # 数据模型与 DTO
│ ├── middleware/ # 自定义中间件
│ └── pkg/ # 本服务内的通用小件(响应、错误码)
├── pkg/ # 可被外部复用的公共库(可选)
├── configs/ # 配置文件(yaml)
├── migrations/ # SQL 迁移脚本(可选)
├── Dockerfile
├── go.mod
└── Makefiledemo/
├── cmd/
│ └── server/
│ └── main.go # 入口:组装依赖 + 启动
├── internal/ # 私有代码,外部包无法 import(编译器强制)
│ ├── config/ # 配置加载
│ ├── router/ # 路由与中间件装配
│ ├── handler/ # HTTP 层:绑定参数、翻译响应
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层(DB / 缓存)
│ ├── model/ # 数据模型与 DTO
│ ├── middleware/ # 自定义中间件
│ └── pkg/ # 本服务内的通用小件(响应、错误码)
├── pkg/ # 可被外部复用的公共库(可选)
├── configs/ # 配置文件(yaml)
├── migrations/ # SQL 迁移脚本(可选)
├── Dockerfile
├── go.mod
└── Makefile1.2 internal 的强制封装
internal 是 Go 编译器层面的可见性约束:只有该目录的父目录内的代码可以 import 它。以仓库 github.com/me/demo 为例,github.com/me/demo/internal/... 只能被 demo 仓库内的代码引用——外部项目 import 会直接编译失败。这让"分层不跨层"不止是代码评审约定,而是编译期纪律。
1.3 依赖方向与演进路线
依赖只允许从上往下:handler -> service -> repository -> model,禁止反向或跳层:
router -> handler -> service -> repository -> model(零依赖)
| |
+---> pkg(响应封装 / 错误码)router -> handler -> service -> repository -> model(零依赖)
| |
+---> pkg(响应封装 / 错误码)演进到微服务时按"业务边界竖切":每个模块(用户 / 订单)的服务包可单独拆出,分层边界保持不变。Go 微服务生态(gRPC + protobuf)与单体共用同样的 internal 纪律,迁移成本主要在协议层而非架构思想。
二、配置管理
2.1 配置来源与优先级
实践顺序(低到高覆盖):默认值 < 配置文件 < 环境变量 < 命令行 flag。推荐 spf13/viper:一个库同时支持文件、env、远程配置中心。
configs/config.yaml:
app:
name: demo
env: dev # dev / test / prod
port: 8080
mysql:
dsn: "root:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"
max_open_conns: 100
max_idle_conns: 10
redis:
addr: "127.0.0.1:6379"
password: ""
db: 0
jwt:
secret: "please-change-me"
expire_hours: 2app:
name: demo
env: dev # dev / test / prod
port: 8080
mysql:
dsn: "root:password@tcp(127.0.0.1:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"
max_open_conns: 100
max_idle_conns: 10
redis:
addr: "127.0.0.1:6379"
password: ""
db: 0
jwt:
secret: "please-change-me"
expire_hours: 22.2 viper 加载代码
package config
import (
"strings"
"github.com/spf13/viper"
)
type Config struct {
App AppConfig `mapstructure:"app"`
MySQL MySQLConfig `mapstructure:"mysql"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
}
type MySQLConfig struct {
DSN string `mapstructure:"dsn"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetEnvPrefix("DEMO") // 环境变量前缀:DEMO_PORT
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // app.port -> DEMO_APP_PORT
v.AutomaticEnv() // 环境变量优先于文件
if err := v.ReadInConfig(); err != nil {
return nil, err
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil { // mapstructure 标签对应 yaml 键
return nil, err
}
return &cfg, nil
}package config
import (
"strings"
"github.com/spf13/viper"
)
type Config struct {
App AppConfig `mapstructure:"app"`
MySQL MySQLConfig `mapstructure:"mysql"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
}
type MySQLConfig struct {
DSN string `mapstructure:"dsn"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetEnvPrefix("DEMO") // 环境变量前缀:DEMO_PORT
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // app.port -> DEMO_APP_PORT
v.AutomaticEnv() // 环境变量优先于文件
if err := v.ReadInConfig(); err != nil {
return nil, err
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil { // mapstructure 标签对应 yaml 键
return nil, err
}
return &cfg, nil
}2.3 敏感信息管理
- 密钥(DB 密码、JWT secret)不要写进仓库的 yaml;用环境变量 / K8s Secret / 密钥管理服务(Vault、云 KMS)注入。
.gitignore必须包含configs/*.local.yaml、.env。- 结构体里 secret 字段加注释"不要打印",避免日志把配置整块 dump 出去。
三、GORM 操作 MySQL
3.1 连接与连接池
package repository
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func NewDB(dsn string, maxOpen, maxIdle int) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), // 生产只打印慢 SQL 与错误
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB() // 取底层 database/sql 连接池
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(maxOpen) // 最大打开连接
sqlDB.SetMaxIdleConns(maxIdle) // 最大空闲连接
sqlDB.SetConnMaxLifetime(time.Hour) // 防 MySQL 8 小时断连 / 老连接回收
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
return db, nil
}package repository
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func NewDB(dsn string, maxOpen, maxIdle int) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), // 生产只打印慢 SQL 与错误
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB() // 取底层 database/sql 连接池
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(maxOpen) // 最大打开连接
sqlDB.SetMaxIdleConns(maxIdle) // 最大空闲连接
sqlDB.SetConnMaxLifetime(time.Hour) // 防 MySQL 8 小时断连 / 老连接回收
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
return db, nil
}DSN 里的 parseTime=True 把 DATETIME 扫成 time.Time,loc=Local 决定时区解析(见 10.1 时区坑);charset=utf8mb4 是中文与 emoji 的底线。
3.2 模型定义与迁移
package model
import "gorm.io/gorm"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
Email string `gorm:"size:128" json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除
}package model
import "gorm.io/gorm"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
Email string `gorm:"size:128" json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除
}自动迁移只适合开发期;生产用显式 SQL 迁移(golang-migrate / goose),避免 AutoMigrate 误删字段或难以回滚:
// 开发/测试环境快速建表
_ = db.AutoMigrate(&User{}, &Order{})// 开发/测试环境快速建表
_ = db.AutoMigrate(&User{}, &Order{})3.3 CRUD 与查询链
// 创建
u := User{Name: "zhang", Email: "z@example.com"}
result := db.Create(&u)
if result.Error != nil { return result.Error }
// 查询链:条件拼接
var user User
err := db.WithContext(ctx).
Where("name = ? AND age >= ?", name, 18). // ? 占位防注入,勿拼字符串
First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) { // First 查不到返回该哨兵错误
return nil, ErrUserNotFound
}
// 列表 + 分页
var users []User
db.WithContext(ctx).
Where("deleted_at IS NULL"). // 软删除字段会自动加条件(如果用了 gorm.DeletedAt)
Order("id DESC").
Limit(20).Offset(0).
Find(&users)
// 更新:结构体零值字段不会更新
db.Model(&User{}).Where("id = ?", id).Updates(map[string]any{
"age": 30, // map 方式可更新零值
})
// 删除(软删除:实际执行 UPDATE deleted_at)
db.Delete(&User{}, id)// 创建
u := User{Name: "zhang", Email: "z@example.com"}
result := db.Create(&u)
if result.Error != nil { return result.Error }
// 查询链:条件拼接
var user User
err := db.WithContext(ctx).
Where("name = ? AND age >= ?", name, 18). // ? 占位防注入,勿拼字符串
First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) { // First 查不到返回该哨兵错误
return nil, ErrUserNotFound
}
// 列表 + 分页
var users []User
db.WithContext(ctx).
Where("deleted_at IS NULL"). // 软删除字段会自动加条件(如果用了 gorm.DeletedAt)
Order("id DESC").
Limit(20).Offset(0).
Find(&users)
// 更新:结构体零值字段不会更新
db.Model(&User{}).Where("id = ?", id).Updates(map[string]any{
"age": 30, // map 方式可更新零值
})
// 删除(软删除:实际执行 UPDATE deleted_at)
db.Delete(&User{}, id)3.4 事务与钩子
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err // 返回 error 即回滚整个事务
}
if err := tx.Model(&Product{}).
Where("id = ? AND stock >= ?", pid, n).
Update("stock", gorm.Expr("stock - ?", n)).Error; err != nil {
return err // 乐观扣减:影响行数为 0 时自行检查
}
return nil // 全部成功才提交
})err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err // 返回 error 即回滚整个事务
}
if err := tx.Model(&Product{}).
Where("id = ? AND stock >= ?", pid, n).
Update("stock", gorm.Expr("stock - ?", n)).Error; err != nil {
return err // 乐观扣减:影响行数为 0 时自行检查
}
return nil // 全部成功才提交
})钩子(Hook)在模型生命周期触发:BeforeCreate / AfterFind / BeforeUpdate,用于自动填充字段、加密、审计。
3.5 GORM 高频坑
| 坑 | 现象 | 对策 |
|---|---|---|
| 表名复数化 | User -> users | 默认即期望复数表名;自定义用 TableName() |
| 结构体 Update 零值不生效 | age=0 没更新 | 用 Select / map / Save |
| 忘传 ctx | 请求取消无法中断 SQL | 全部用 WithContext(ctx) |
| N+1 查询 | 列表后逐行查关联 | Preload("Orders") 一次性 JOIN/IN |
| First 查不到当错误 | 404 逻辑误判 500 | errors.Is(err, gorm.ErrRecordNotFound) |
| 软删除忘过滤 | 统计多算已删数据 | 关联查询用 Unscoped() 时三思 |
| 复数外键约束 | 迁移卡住 | 生产用迁移工具管理 schema |
四、Redis 缓存
4.1 go-redis 客户端初始化
package repository
import (
"github.com/redis/go-redis/v9"
)
func NewRedis(addr, password string, db int) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
PoolSize: 20, // 连接池大小,按 QPS 与 RT 估算
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, err
}
return rdb, nil
}
// 用法
val, err := rdb.Get(ctx, "user:1").Result()
if errors.Is(err, redis.Nil) { // 键不存在是 redis.Nil 而不是普通 error
// 走 DB
}package repository
import (
"github.com/redis/go-redis/v9"
)
func NewRedis(addr, password string, db int) (*redis.Client, error) {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
PoolSize: 20, // 连接池大小,按 QPS 与 RT 估算
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := rdb.Ping(ctx).Err(); err != nil {
return nil, err
}
return rdb, nil
}
// 用法
val, err := rdb.Get(ctx, "user:1").Result()
if errors.Is(err, redis.Nil) { // 键不存在是 redis.Nil 而不是普通 error
// 走 DB
}4.2 Cache Aside 与三大缓存问题
**Cache Aside(旁路缓存)**是最常用模式:读先查缓存,未命中查库并回填;写先更新库再删缓存(删而非更新,避免并发写覆盖旧值)。
func (r *userRepo) GetUser(ctx context.Context, id int64) (*model.User, error) {
key := fmt.Sprintf("user:%d", id)
// 1. 读缓存
var u model.User
if err := r.rdb.Get(ctx, key).Scan(&u); err == nil {
return &u, nil
} else if !errors.Is(err, redis.Nil) {
return nil, err // 缓存故障不要拖垮主流程,可降级直查 DB
}
// 2. 查库
u, err := r.findFromDB(ctx, id)
if err != nil { return nil, err }
// 3. 回填
r.rdb.Set(ctx, key, u, 10*time.Minute)
return &u, nil
}func (r *userRepo) GetUser(ctx context.Context, id int64) (*model.User, error) {
key := fmt.Sprintf("user:%d", id)
// 1. 读缓存
var u model.User
if err := r.rdb.Get(ctx, key).Scan(&u); err == nil {
return &u, nil
} else if !errors.Is(err, redis.Nil) {
return nil, err // 缓存故障不要拖垮主流程,可降级直查 DB
}
// 2. 查库
u, err := r.findFromDB(ctx, id)
if err != nil { return nil, err }
// 3. 回填
r.rdb.Set(ctx, key, u, 10*time.Minute)
return &u, nil
}| 问题 | 描述 | 对策 |
|---|---|---|
| 穿透 | 查不存在的 key,缓存永远空 | 空值也缓存 1 分钟;布隆过滤器前置 |
| 击穿 | 热点 key 过期瞬间并发全打 DB | singleflight 合并请求;逻辑过期 |
| 雪崩 | 大量 key 同时过期 | TTL 加随机抖动;多级缓存 |
热点防击穿(golang.org/x/sync/singleflight):
var sf singleflight.Group
func (r *userRepo) GetUser(ctx context.Context, id int64) (*model.User, error) {
key := fmt.Sprintf("user:%d", id)
v, err, _ := sf.Do(key, func() (any, error) { // 同 key 并发只执行一次
return r.loadAndCache(ctx, id)
})
if err != nil { return nil, err }
return v.(*model.User), nil
}var sf singleflight.Group
func (r *userRepo) GetUser(ctx context.Context, id int64) (*model.User, error) {
key := fmt.Sprintf("user:%d", id)
v, err, _ := sf.Do(key, func() (any, error) { // 同 key 并发只执行一次
return r.loadAndCache(ctx, id)
})
if err != nil { return nil, err }
return v.(*model.User), nil
}4.3 分布式锁
// 加锁:SET NX EX 原子完成
ok, err := rdb.SetNX(ctx, "lock:order:1001", token, 10*time.Second).Result()
if err != nil || !ok {
return errors.New("获取锁失败")
}
defer unlock(ctx, rdb, "lock:order:1001", token) // 必须带 token 防误删
// 解锁:Lua 校验 token 一致才 DEL(防把别人的锁删了)
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`)
func unlock(ctx context.Context, rdb *redis.Client, key, token string) {
unlockScript.Run(ctx, rdb, []string{key}, token)
}// 加锁:SET NX EX 原子完成
ok, err := rdb.SetNX(ctx, "lock:order:1001", token, 10*time.Second).Result()
if err != nil || !ok {
return errors.New("获取锁失败")
}
defer unlock(ctx, rdb, "lock:order:1001", token) // 必须带 token 防误删
// 解锁:Lua 校验 token 一致才 DEL(防把别人的锁删了)
var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`)
func unlock(ctx context.Context, rdb *redis.Client, key, token string) {
unlockScript.Run(ctx, rdb, []string{key}, token)
}要点:value 用随机 token 标识持有者;锁必须有过期时间(防持有者宕机死锁);Redis 主从切换下的锁失效问题需要 Redlock 或 etcd,一般业务用 SETNX 足够。
五、日志:zap
5.1 初始化生产级 logger
Uber 的 zap 以"零分配"著称,是 Go 结构化日志的事实标准:
package pkg
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger(env string) (*zap.Logger, error) {
var cfg zap.Config
if env == "prod" {
cfg = zap.NewProductionConfig() // JSON 输出,适合采集
} else {
cfg = zap.NewDevelopmentConfig() // 彩色可读
}
cfg.EncoderConfig.TimeKey = "time"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 人类可读时间
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return cfg.Build()
}
// 使用
logger, _ := NewLogger("prod")
logger.Info("订单创建成功",
zap.Int64("order_id", 1001),
zap.String("user", "zhang"),
zap.Duration("cost", 23*time.Millisecond),
)package pkg
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func NewLogger(env string) (*zap.Logger, error) {
var cfg zap.Config
if env == "prod" {
cfg = zap.NewProductionConfig() // JSON 输出,适合采集
} else {
cfg = zap.NewDevelopmentConfig() // 彩色可读
}
cfg.EncoderConfig.TimeKey = "time"
cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 人类可读时间
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
return cfg.Build()
}
// 使用
logger, _ := NewLogger("prod")
logger.Info("订单创建成功",
zap.Int64("order_id", 1001),
zap.String("user", "zhang"),
zap.Duration("cost", 23*time.Millisecond),
)规则:结构化字段,不拼字符串。字符串拼接日志无法被采集系统按字段检索与聚合,JSON 结构才适合 Loki / ELK。
5.2 请求 ID 贯穿
func RequestID(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-Id")
if rid == "" {
rid = uuid.NewString() // 生成后写回响应头,便于前端关联排查
}
c.Header("X-Request-Id", rid)
c.Set("requestId", rid)
// 注入带 trace 字段的 logger
l := logger.With(zap.String("request_id", rid))
c.Set("logger", l)
c.Next()
}
}func RequestID(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
rid := c.GetHeader("X-Request-Id")
if rid == "" {
rid = uuid.NewString() // 生成后写回响应头,便于前端关联排查
}
c.Header("X-Request-Id", rid)
c.Set("requestId", rid)
// 注入带 trace 字段的 logger
l := logger.With(zap.String("request_id", rid))
c.Set("logger", l)
c.Next()
}
}handler 内取用:l := c.MustGet("logger").(*zap.Logger),错误日志自动带 request_id,线上"一个请求全链路日志"一条命令查完。
5.3 错误日志分级纪律
| 级别 | 场景 | 例子 |
|---|---|---|
| Info | 关键业务事件 | 下单成功、登录成功 |
| Warn | 可降级但不致命的异常 | 缓存故障走 DB、重试命中 |
| Error | 确实出错且影响结果 | DB 连接失败、第三方调用失败 |
| Debug | 开发期细节 | 生产关闭该级别 |
注意:4xx 客户端错误不算 Error(参数错误打 Warn 或 Info),否则告警系统会被刷爆;只有 5xx 与下游故障才 Error。
六、依赖注入风格
6.1 手动组装 vs 框架
Go 没有 Spring 那种运行时注解容器,主流的三种依赖注入方案:
| 方案 | 原理 | 适用 |
|---|---|---|
| 构造函数手写组装 | main 里 new 全部依赖,一层层传入 | 中小项目(推荐起步) |
wire(Google) | 编译期代码生成,依赖图错误编译即暴露 | 依赖多、团队规范强 |
uber fx / dig | 反射容器,运行时解析 | 需要生命周期管理的大团队 |
最朴素的手写组装最符合 Go 精神——依赖关系一目了然、可静态追踪、零魔法:
func main() {
cfg := config.MustLoad("configs/config.yaml")
db, err := repository.NewDB(cfg.MySQL.DSN, cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
if err != nil { log.Fatal(err) }
rdb, err := repository.NewRedis(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
if err != nil { log.Fatal(err) }
userRepo := repository.NewUserRepo(db, rdb) // repository 依赖 db
userSvc := service.NewUserService(userRepo) // service 依赖 repository 接口
userH := handler.NewUserHandler(userSvc) // handler 依赖 service
engine := router.New(userH, logger) // 路由组装
server.Run(engine, cfg.App.Port)
}func main() {
cfg := config.MustLoad("configs/config.yaml")
db, err := repository.NewDB(cfg.MySQL.DSN, cfg.MySQL.MaxOpenConns, cfg.MySQL.MaxIdleConns)
if err != nil { log.Fatal(err) }
rdb, err := repository.NewRedis(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
if err != nil { log.Fatal(err) }
userRepo := repository.NewUserRepo(db, rdb) // repository 依赖 db
userSvc := service.NewUserService(userRepo) // service 依赖 repository 接口
userH := handler.NewUserHandler(userSvc) // handler 依赖 service
engine := router.New(userH, logger) // 路由组装
server.Run(engine, cfg.App.Port)
}6.2 面向接口的 repository
service 依赖接口而非具体实现,测试时替换成内存版 / mock:
// service 定义自己需要的存储接口(小接口原则)
type UserRepo interface {
GetByID(ctx context.Context, id int64) (*model.User, error)
Save(ctx context.Context, u *model.User) error
}
type UserService struct {
repo UserRepo
}
func NewUserService(repo UserRepo) *UserService { return &UserService{repo: repo} }
// 编译期断言:GORM 实现确实满足接口
var _ UserRepo = (*repository.UserRepo)(nil)// service 定义自己需要的存储接口(小接口原则)
type UserRepo interface {
GetByID(ctx context.Context, id int64) (*model.User, error)
Save(ctx context.Context, u *model.User) error
}
type UserService struct {
repo UserRepo
}
func NewUserService(repo UserRepo) *UserService { return &UserService{repo: repo} }
// 编译期断言:GORM 实现确实满足接口
var _ UserRepo = (*repository.UserRepo)(nil)收益:单测时 repo = &fakeUserRepo{...} 即可,不必起 MySQL;将来换存储实现也不动 service 一行。
七、接口文档
7.1 Swaggo 生成 OpenAPI
用 swaggo/swag + swaggo/gin-swagger 从注释生成 Swagger 文档(类比 Springfox / springdoc):
go install github.com/swaggo/swag/cmd/swag@latest
swag init -g cmd/server/main.go -o docs # 生成 docs/ 目录go install github.com/swaggo/swag/cmd/swag@latest
swag init -g cmd/server/main.go -o docs # 生成 docs/ 目录main.go 顶部写全局信息注解,handler 注释声明接口:
// @title Demo API
// @version 1.0
// @description 分层单体示例服务
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization// @title Demo API
// @version 1.0
// @description 分层单体示例服务
// @host localhost:8080
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization7.2 handler 注释规范
// GetUser 获取用户详情
// @Summary 获取用户详情
// @Tags 用户
// @Produce json
// @Param id path int true "用户 ID"
// @Success 200 {object} pkg.Response{data=model.User}
// @Failure 404 {object} pkg.Response
// @Router /users/{id} [get]
func (h *UserHandler) GetUser(c *gin.Context) { ... }// GetUser 获取用户详情
// @Summary 获取用户详情
// @Tags 用户
// @Produce json
// @Param id path int true "用户 ID"
// @Success 200 {object} pkg.Response{data=model.User}
// @Failure 404 {object} pkg.Response
// @Router /users/{id} [get]
func (h *UserHandler) GetUser(c *gin.Context) { ... }路由注册处挂上文档:
import (
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/me/demo/docs" // 引入生成的 docs 包
)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))import (
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
_ "github.com/me/demo/docs" // 引入生成的 docs 包
)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))文档页建议只在内网 / 测试环境开放(与 pprof 同理),生产隐藏。
八、测试策略
8.1 测试金字塔落点
| 层 | 手段 | 覆盖目标 |
|---|---|---|
| handler | httptest + gin test mode | 绑定、状态码、响应壳 |
| service | 内存版 repository(fake) | 业务规则、错误分支 |
| repository | sqlmock / miniredis | SQL 正确性、Redis 命令 |
| 集成 | 真实 MySQL / Redis(testcontainers) | 迁移、事务、方言 |
8.2 repository 用 sqlmock 单测
func TestFindByID(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
gdb, _ := gorm.Open(mysql.New(mysql.Config{
Conn: db, SkipInitializeWithVersion: true,
}), &gorm.Config{})
mock.ExpectQuery(`SELECT \* FROM .users. WHERE id = .* AND .users.deleted_at IS NULL ORDER BY .users.id LIMIT 1`).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "zhang"))
repo := NewUserRepo(gdb, nil)
u, err := repo.GetByID(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, "zhang", u.Name)
}func TestFindByID(t *testing.T) {
db, mock, _ := sqlmock.New()
defer db.Close()
gdb, _ := gorm.Open(mysql.New(mysql.Config{
Conn: db, SkipInitializeWithVersion: true,
}), &gorm.Config{})
mock.ExpectQuery(`SELECT \* FROM .users. WHERE id = .* AND .users.deleted_at IS NULL ORDER BY .users.id LIMIT 1`).
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "zhang"))
repo := NewUserRepo(gdb, nil)
u, err := repo.GetByID(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, "zhang", u.Name)
}8.3 集成测试用 Testcontainers
func TestUserRepoIntegration(t *testing.T) {
if os.Getenv("RUN_INTEGRATION") == "" {
t.Skip("设置 RUN_INTEGRATION=1 才跑")
}
// 起真实 MySQL 容器(Testcontainers for Go),等待就绪后建表、跑真实 CRUD
// 参考 github.com/testcontainers/testcontainers-go
}func TestUserRepoIntegration(t *testing.T) {
if os.Getenv("RUN_INTEGRATION") == "" {
t.Skip("设置 RUN_INTEGRATION=1 才跑")
}
// 起真实 MySQL 容器(Testcontainers for Go),等待就绪后建表、跑真实 CRUD
// 参考 github.com/testcontainers/testcontainers-go
}经验:repository 的 SQL 方言细节(软删除条件、时间戳)只有真实库才验得到;CI 里固定跑 integration job,本地可跳过。
九、Docker 化与多阶段构建
9.1 多阶段 Dockerfile
# ---- 构建阶段 ----
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 先单独拷贝 go.mod/go.sum 以复用依赖层缓存
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 产出纯静态二进制;-s -w 去符号表减体积
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server
# ---- 运行阶段 ----
FROM alpine:3.20
# ca-certificates 提供 HTTPS 信任链;tzdata 提供时区数据(见时区坑)
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app
WORKDIR /app
COPY --from=builder /out/server .
COPY configs/ ./configs/
USER app # 非 root 运行,安全基线
EXPOSE 8080
ENTRYPOINT ["/app/server", "-config", "/app/configs/config.yaml"]# ---- 构建阶段 ----
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 先单独拷贝 go.mod/go.sum 以复用依赖层缓存
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 产出纯静态二进制;-s -w 去符号表减体积
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server
# ---- 运行阶段 ----
FROM alpine:3.20
# ca-certificates 提供 HTTPS 信任链;tzdata 提供时区数据(见时区坑)
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 10001 app
WORKDIR /app
COPY --from=builder /out/server .
COPY configs/ ./configs/
USER app # 非 root 运行,安全基线
EXPOSE 8080
ENTRYPOINT ["/app/server", "-config", "/app/configs/config.yaml"]9.2 构建与验证
docker build -t demo:latest .
docker run --rm -p 8080:8080 -e DEMO_MYSQL_DSN='...' demo:latest
# 健康检查(K8s 配探针同思路)
# 服务里提供 /healthz 返回 200;容器加 HEALTHCHECK 或交给编排层docker build -t demo:latest .
docker run --rm -p 8080:8080 -e DEMO_MYSQL_DSN='...' demo:latest
# 健康检查(K8s 配探针同思路)
# 服务里提供 /healthz 返回 200;容器加 HEALTHCHECK 或交给编排层.dockerignore 至少要忽略 .git、bin、*.log,避免把本地垃圾与大文件打进构建上下文。
9.3 生产镜像对比
| 运行时基镜像 | 体积 | 特点 |
|---|---|---|
scratch | 约 10MB | 最干净;无 shell、无证书,需自带 CA 与 tzdata |
distroless | 约 20MB | Google 维护,含 CA,无包管理器 |
alpine | 约 15MB | 有 apk,排查方便(能进容器装工具) |
推荐 distroless 或 alpine;国内网络注意基础镜像源替换。另可配合 ko 或 buildpacks 免写 Dockerfile。
十、常见生产坑
10.1 时区:八年老坑
Go 的 time.Now() 返回本地时区,但容器默认 UTC,且 time.LoadLocation("Asia/Shanghai") 在没有 tzdata 的 scratch/distroless 镜像里直接报错。三件事一起做:
- 存储与传输统一 UTC:DB 连接
loc=UTC或time.Now().UTC()。 - 输出到用户侧(响应 JSON、日志)再转业务时区。
- Docker 运行阶段装 tzdata,并设置环境变量:yaml
# docker-compose.yml environment: - TZ=Asia/Shanghai# docker-compose.yml environment: - TZ=Asia/Shanghai
另一个高频坑:MySQL DSN loc=Local 与服务时区不一致会导致 time.Time 读出偏移 8 小时。
10.2 JSON 大小写与字段名
encoding/json 解码对 interface{} / map[string]any 时数字统一变 float64,10001 会变 10001.0;与前端数字精度(如雪花 ID 超 2^53)对接时必须用 json.Number:
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber() // map 里数字变成 json.Numberdec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber() // map 里数字变成 json.Number结构体字段未写 tag 时用字段名原文序列化(大驼峰);前后端约定不一致是联调第一吵架点,DTO 一律显式写 tag。
10.3 空切片、空结构体与 null
| 场景 | 结果 | 对策 |
|---|---|---|
| nil slice 序列化 | null | 返回 []model.X{} 或封装层兜底转 [] |
| 空 struct 无 omitempty | "field":{} | 指针字段 + omitempty |
| 值为 0 的 int | 不省略 | omitempty 对 0 无效,用 *int |
| time.Time 零值 | "0001-01-01T00:00:00Z" | 指针 / 自定义类型 / omitempty |
原则:给前端的响应模型单独定义 DTO,与 DB model 分离,宁多写几个字段结构,不把 orm 结构直接吐出去。
10.4 连接池参数缺失
不设 SetMaxOpenConns 等于无上限,慢 SQL 一多就打满 DB 连接导致雪崩;设太小又会在尖峰期排队。起步建议:max_open_conns = 核数 x 10 左右,配合慢 SQL 日志与 P99 监控再调;ConnMaxLifetime 必须小于 MySQL wait_timeout。
10.5 其它值得记住的坑
| 坑 | 说明 |
|---|---|
| 循环里 defer | 文件/连接资源在循环内打开必须包函数,否则攒到函数结束才关 |
| 错误里带敏感信息 | fmt.Errorf 拼接 SQL / token 会进日志,脱敏 |
| 大 JSON 整读内存 | 用 json.Decoder 流式或限制 body 大小 |
| go.mod 依赖漂移 | CI 锁 go.sum,升级用 PR 单独评审 |
| 热更新配置 | viper WatchConfig 可热载,但 DB 连接池等要重建才生效,谨慎 |
忽略 gofmt 之外的 lint | CI 加 golangci-lint(errcheck、ineffassign、govet)防低级错 |
| 没有 /healthz | K8s 探针与负载均衡健康检查都靠它,务必提供且不依赖 DB |
| 进程被 SIGKILL | 优雅关停(Gin 篇第八节)配合容器 stop_grace_period |
从基础语法到并发、框架再到工程落地,Go 后端系列告一段落。实践中坚持三个习惯即可稳定产出高质量服务:接口小、分层清、race 与 pprof 常跑。
Cao Note