本章导读
前面我们已经把 Gin 本身的路由、中间件、参数绑定这些"语言级"用法过了一遍,但一个能上线的服务远不止这些:配置从哪来、日志往哪打、依赖怎么装配、出错时对客户端说什么、进程怎么体面地退出——这些问题在框架教程里通常一笔带过,却是生产事故的高发地。本章我们把视角从"会用框架"切换到"会搭工程":从一个空文件夹开始,把配置加载、日志、数据层、依赖注入、中间件管道、业务分层、优雅关闭这七块基础设施按依赖顺序亲手搭一遍,并用一个用户模块验证整套骨架真的能跑。学完你会发现,业务代码反而是全文最按部就班的部分——因为脏活累活都已经被脚手架接走了,这正是本章想传达的工程价值观:地基的质量决定了上层业务的书写速度。本章搭出的脚手架也是后续章节(分布式限流、刷新令牌、读写分离)持续生长的宿主,后面所有新能力都以"新增 provider + 注册路由"的方式接入,不再动骨架。
学习目标
读完这篇教程(并且跟着敲一遍),你应该能够:
- 说清楚一个生产级 Gin 项目的目录为什么这样分(
cmd/internal/pkg各自装什么,internal内部为什么按"层"分包); - 独立实现配置加载:Viper 读 YAML、
APP_前缀环境变量覆盖敏感信息、启动期强校验(fail fast); - 独立实现结构化日志体系:zap 双通道输出(控制台人读 + 文件 JSON)、info/error 分级落盘、按天归档、异步写入不阻塞业务、gin 输出统一接管、带 request_id 的子日志实例随 context 流动;
- 用 Wire 在编译期装配依赖:说清依赖链怎么声明、cleanup 如何自动逆序执行、失败如何回滚;
- 按四层写业务:handler → service → repository → data,包括 DTO 解耦、哨兵错误翻译、透明事务、cache-aside 缓存、JWT 登录鉴权。
前置知识(不会的话也没关系,遇到时会顺带解释):
- 装好了 Go(1.22+ 均可)和 Docker(用来一键起 MySQL 和 Redis);
- 会打开终端、能进入目录、会执行命令;
- 看得懂最基本的 Go 语法:变量、函数、结构体(struct)、接口(interface)。不需要精通,语法点遇到就讲。
本章你会动手做的事(建议真做一遍,别只看):
- 从一个空文件夹开始,敲出完整的目录结构和全部基础设施代码;
- 用
make compose-up一键启动 MySQL + Redis + 应用,走通"登录拿 JWT → 带令牌调用受保护接口"的完整链路; - 用
Ctrl+C优雅关闭服务,在日志里亲眼看到"排空流量 → 关 MySQL/Redis → 刷日志"的逆序过程。
先看一张总览图,对整个脚手架的骨架有个印象(现在看不懂没关系,每章会拆开细讲):
一、开工:初始化项目与目录设计
1.1 先建立直觉:盖房子先搭脚手架
1.2 动手:初始化项目并安装依赖
打开终端,依次执行(# 后面是注释,不用敲):
# 步骤 1:创建项目目录并进入
mkdir -p gin-scaffold && cd gin-scaffold
# 步骤 2:初始化 Go module。
# 本项目直接以项目名作为 module 路径;要发布到 GitHub 的话,
# 习惯写成 github.com/你的用户名/gin-scaffold
go mod init gin-scaffold
# 步骤 3:安装本篇会用到的依赖(一次装齐)
go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get gorm.io/driver/mysql
go get github.com/redis/go-redis/v9
go get go.uber.org/zap
go get gopkg.in/natefinch/lumberjack.v2
go get github.com/spf13/viper
go get github.com/go-viper/mapstructure/v2
go get github.com/golang-jwt/jwt/v5
go get github.com/google/uuid
go get github.com/google/wire
go get golang.org/x/crypto
go get golang.org/x/time
go get github.com/swaggo/files
go get github.com/swaggo/gin-swagger
执行完 go mod init 后目录里会出现一个 go.mod 文件——它记录了模块名和全部依赖的版本,相当于这个项目的"身份证 + 采购清单"。
这些依赖各管什么,先混个眼熟(后面每章都会细讲):
| 依赖 | 干什么用 |
|---|---|
gin | HTTP 框架本体:路由、中间件、参数绑定 |
gorm + gorm.io/driver/mysql | ORM 与 MySQL 驱动,数据库读写 |
go-redis/v9 | Redis 客户端,做缓存 |
zap + lumberjack | 结构化日志 + 日志文件按大小切割 |
viper + mapstructure | 配置加载:YAML 文件 + 环境变量覆盖 |
golang-jwt/jwt/v5 | 签发与校验登录令牌 |
google/wire | 编译期依赖注入(第八章细讲) |
x/crypto | bcrypt 密码哈希 |
x/time | 令牌桶限流算法 |
swaggo/files + swaggo/gin-swagger | 在线 API 文档 |
1.3 目录设计:三个顶级目录各管一摊
继续创建目录结构:
mkdir -p cmd/server configs \
internal/config internal/data internal/handler/dto internal/middleware \
internal/model internal/repository internal/router internal/server internal/service \
pkg/errcode pkg/jwt pkg/logger pkg/response
最终的目录树长这样(省略了测试与生成文件):
gin-scaffold/
├── cmd/
│ └── server/
│ ├── main.go # 程序入口:只有"启动流程",没有业务
│ ├── wire.go # Wire 依赖声明(写给人看)
│ ├── wire_gen.go # Wire 生成的装配代码(写给机器跑)
│ └── providers.go # 配置结构转换的小适配函数
├── configs/
│ ├── config.yaml # 开发环境配置
│ └── config.prod.yaml # 生产环境配置(敏感项走环境变量)
├── internal/ # 私有代码,外部模块无法 import(编译器强制)
│ ├── config/ # 配置结构体定义、加载与校验
│ ├── data/ # 数据层:MySQL/Redis 客户端、连接池、事务管理器
│ ├── model/ # GORM 数据模型(表结构)
│ ├── repository/ # 数据访问层:只管读写库与缓存
│ ├── service/ # 业务层:业务规则、事务边界、错误翻译
│ ├── handler/ # 接口层:参数绑定校验、调用 service、渲染响应
│ │ └── dto/ # 请求/响应的数据传输对象
│ ├── middleware/ # 中间件:Recovery/RequestID/AccessLog/CORS/RateLimit/JWT
│ ├── router/ # 路由组装:中间件 + 分组 + 各 handler
│ └── server/ # HTTP Server 封装与生命周期管理
├── pkg/ # 可复用公共库(与业务无关,可被任意项目抄走)
│ ├── errcode/ # 统一错误码定义
│ ├── jwt/ # JWT 签发与解析
│ ├── logger/ # zap 日志封装
│ └── response/ # 统一响应格式
├── Makefile # 常用命令集合(run/build/test/wire/swagger…)
├── docker-compose.yml # 一键启动 MySQL + Redis + 应用
├── Dockerfile # 多阶段构建生产镜像
├── go.mod
└── go.sum
这三个顶级目录是 Go 社区多年沉淀下来的通用约定,不是随便拍的:
| 目录 | 装什么 | 谁能引用它 |
|---|---|---|
cmd/ | 程序入口(main 函数),一个子目录一个可执行程序 | 编译入口,没人引用它 |
internal/ | 私有代码:业务各层、路由、配置 | 只有本模块能引用(编译器强制) |
pkg/ | 可复用的公共库:错误码、日志、响应这类与业务无关的基础设施 | 本模块引用;将来别的项目也可以抄走 |
internal 是 Go 编译器的"魔法目录"。你把代码放进 internal/,Go 编译器就禁止任何外部模块 import 它——别人哪怕写 import "gin-scaffold/internal/service",编译直接报错。这是 Go 在语言层面帮你保护私有实现。而 pkg/ 下的代码没有这层保护,所以约定俗成:确定不想被外部引用的,放 internal;刻意做成通用工具的,才放 pkg。注意看 pkg/logger 的设计——它故意不 import internal/config,而是定义自己的 logger.Config,由 cmd/server/providers.go 做转换。这正是"pkg 不反向依赖 internal"的自我修养。1.4 internal 内部:按"层"分包,包内按"模块"分文件
注意 internal/ 下面的组织方式:我们没有建 internal/user/、internal/order/ 这种按业务模块划分的目录,而是按技术分层分包:handler/、service/、repository/、model/。然后在每个包内部,再以模块名做文件前缀:handler/user_handler.go、service/user_service.go、repository/user_repo.go。
对比一下两种组织方式:
按模块分包(另一种常见做法) 按层分包 + 模块文件前缀(本项目)
├── internal/ ├── internal/
│ ├── user/ │ ├── handler/
│ │ ├── model.go │ │ ├── user_handler.go
│ │ ├── repository.go │ │ └── dto/
│ │ ├── service.go │ ├── service/
│ │ └── handler.go │ │ └── user_service.go
│ └── order/ │ ├── repository/
│ └── ... │ │ └── user_repo.go
│ └── model/
│ └── user.go
两种做法没有绝对对错,关键看依赖方向:
- 本项目的依赖链是严格的单向直线:
handler → service → repository → data。按层分包后,包级依赖图就是这条直线本身,handler包物理上无法被repository引用,编译器帮你守住了分层纪律。加新模块(比如 order)只是在每一层各加一个文件,各包不会出现循环依赖。 - 按模块分包更适合"模块之间需要互相调用"的复杂系统(比如订单模块要调用户模块),它把内聚性放在模块维度。但作为脚手架,分层纪律比模块内聚更重要——脚手架的第一职责是让新人一眼看清"请求从哪进、数据从哪出"。
记住这个判断标准:防的是"层与层乱调",就按层分包;防的是"模块与模块乱调",就按模块分包。脚手架阶段,前者是主要矛盾。
二、配置加载:让程序"读得懂"环境
2.1 为什么需要配置文件
对应到工程里就是:开发连本机 MySQL、生产连集群 MySQL,代码一行不改,只换
configs/config.yaml 和 configs/config.prod.yaml。而密码这类敏感信息更进一步——连生产配置文件都不写,由部署平台以环境变量注入。2.2 定义配置结构体
新建 internal/config/config.go,先定义"配置长什么样":
// Config 应用全量配置根结构。
type Config struct {
App App `mapstructure:"app"`
HTTP HTTP `mapstructure:"http"`
MySQL MySQL `mapstructure:"mysql"`
Redis Redis `mapstructure:"redis"`
JWT JWT `mapstructure:"jwt"`
Log Log `mapstructure:"log"`
RateLimit RateLimit `mapstructure:"rate_limit"`
CORS CORS `mapstructure:"cors"`
}
// HTTP HTTP Server 配置,所有超时必须显式配置,防止慢连接拖垮服务。
type HTTP struct {
Addr string `mapstructure:"addr"`
ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
IdleTimeout time.Duration `mapstructure:"idle_timeout"`
// 可信代理 CIDR 列表。空列表 = 不信任任何代理,ClientIP 取直连地址;
// 若不显式设置,gin 默认信任所有代理头,X-Forwarded-For 可被伪造,
// 按 IP 的限流、风控将形同虚设
TrustedProxies []string `mapstructure:"trusted_proxies"`
}
// MySQL 数据库配置。
type MySQL struct {
DSN string `mapstructure:"dsn"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
}
// Redis 缓存配置。
type Redis struct {
Addr string `mapstructure:"addr"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
DialTimeout time.Duration `mapstructure:"dial_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// JWT 认证配置。
type JWT struct {
Secret string `mapstructure:"secret"`
Issuer string `mapstructure:"issuer"`
Expire time.Duration `mapstructure:"expire"`
}
// Log / RateLimit / CORS 结构同理,字段见 config.yaml,此处略
给零基础的同学解释两个语法点:
- 反引号里的是什么?
mapstructure:"addr"叫 struct tag(结构体标签)。它是写给配置解析库看的"映射说明":yaml 里的addr:这个 key 的值,填到Addr字段里。本项目用 Viper 解析配置,它底层走 mapstructure 库,所以 tag 前缀是mapstructure而不是yaml。 time.Duration是什么类型? 它本质上是int64,单位是纳秒。配置里写5s、24h这种人类可读的字符串,要靠一个"解码钩子"翻译成 Duration——下一步就会看到,这是新手必踩的坑。
2.3 加载:Viper 读文件 + 环境变量覆盖
继续在同一个文件里写加载函数:
// Load 从指定路径读取配置文件并完成环境变量覆盖与校验。
// 任何必填项缺失都会导致启动失败,错误信息中列出全部缺失字段。
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
// 步骤 1:环境变量覆盖规则。
// 前缀 APP_ + 配置路径大写、点号转下划线:
// mysql.dsn -> APP_MYSQL_DSN,jwt.secret -> APP_JWT_SECRET
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// 步骤 2:读取并解析配置文件
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("读取配置文件 %s 失败: %w", path, err)
}
var cfg Config
// 步骤 3:关键!注册 decode hook,
// 把 "5s" / "24h" 这类字符串正确映射为 time.Duration
if err := v.Unmarshal(&cfg, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc())); err != nil {
return nil, fmt.Errorf("解析配置失败: %w", err)
}
// 步骤 4:语义校验,任何必填项缺失立即返回错误
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
优先级顺序是:环境变量 > 配置文件。这意味着生产环境的 yaml 里密码栏可以写占位符甚至留空,真实密码由部署平台(K8s Secret、docker-compose 的 environment)注入环境变量,git 仓库里永远没有真实密码。
5s 解析不进去。time.Duration 底层是 int64 纳秒,Viper 默认不知道怎么把字符串 "5s" 变成 5000000000。不加 mapstructure.StringToTimeDurationHookFunc() 这个钩子,Unmarshal 直接报错 'read_header_timeout': invalid type。更要命的写法是"干脆把字段改成 int 按秒存"——单位约定靠注释口口相传,三个月后必然有人把秒当毫秒用。让类型系统替你记住单位,是 Go 配置设计的基本功。2.4 校验:让错误配置死在启动时
“fail fast(尽早失败)“是这篇教程反复出现的词。配置错误的程序不应该跑起来之后半路报错——那时候它可能已经接了流量。
// validate 启动期强校验:缺配置直接 fail fast,而不是带病运行到第一次请求才报错。
func (c *Config) validate() error {
// 步骤 1:收集所有缺失的必填项,一次性报出来
// (而不是缺一个报一个,逼用户反复重启试错)
var missing []string
if c.HTTP.Addr == "" {
missing = append(missing, "http.addr")
}
if c.MySQL.DSN == "" {
missing = append(missing, "mysql.dsn")
}
if c.Redis.Addr == "" {
missing = append(missing, "redis.addr")
}
if c.JWT.Secret == "" {
missing = append(missing, "jwt.secret")
}
if len(c.JWT.Secret) < 32 {
// HS256 密钥低于 32 字节可被暴力破解:启动期拦截
missing = append(missing, "jwt.secret(长度须不少于 32 字节)")
}
if c.JWT.Issuer == "" {
missing = append(missing, "jwt.issuer")
}
if c.JWT.Expire <= 0 {
missing = append(missing, "jwt.expire")
}
if len(missing) > 0 {
return fmt.Errorf("配置缺失必填项: %s", strings.Join(missing, ", "))
}
// 步骤 2:可信代理段在启动期完成 CIDR 解析校验,
// 非法值 fail fast,避免运行期才发现 ClientIP 失真
for _, cidr := range c.HTTP.TrustedProxies {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("http.trusted_proxies 包含非法 CIDR %q: %w", cidr, err)
}
}
return nil
}
注意 jwt.expire <= 0 这条:用户把 expire 写成 -1h 或者忘配(零值),令牌一签发就过期,线上表现为"所有人登录后立刻掉线”。这种问题在启动第一秒拦下,比上线后被客服电话叫醒体面得多。
2.5 写一份开发配置
新建 configs/config.yaml:
# 本地开发环境配置。
# 敏感项(数据库密码、JWT 密钥)支持环境变量覆盖:
# mysql.dsn -> APP_MYSQL_DSN,jwt.secret -> APP_JWT_SECRET
app:
name: gin-scaffold
version: 1.0.0
debug_port: 6060 # pprof 调试端口(只监听 127.0.0.1),0 表示关闭
http:
addr: ":8080"
read_header_timeout: 5s
read_timeout: 10s
write_timeout: 10s
idle_timeout: 120s
trusted_proxies: [] # 空列表 = 不信任任何代理,防止 X-Forwarded-For 伪造
mysql:
dsn: "root:123456@tcp(127.0.0.1:3306)/gin_scaffold?charset=utf8mb4&parseTime=True&loc=Local"
max_open_conns: 100
max_idle_conns: 10
conn_max_lifetime: 1h
redis:
addr: "127.0.0.1:6379"
password: ""
db: 0
pool_size: 100
dial_timeout: 3s
read_timeout: 1s
write_timeout: 1s
jwt:
secret: "dev-only-secret-change-me-32bytes-ok!" # 生产必须走 APP_JWT_SECRET 注入
issuer: "gin-scaffold"
expire: 24h
log:
level: info # 开发期可改 debug,SQL 日志会跟着打印
dir: ./logs # 留空表示只输出控制台(容器场景推荐)
prefix: app # -> app-2026-09-09.info.log / app-2026-09-09.error.log
max_size: 100 # 单文件最大 MB,超过由 lumberjack 二次切割
max_backups: 7 # 保留的归档天数
max_age: 30 # 归档保留天数
compress: true # 历史日志 gzip 压缩
console: true # 控制台输出(zap 统一接管,含 gin 自身输出)
console_async: false # 控制台保持同步,顺序与实时性最好
async: true # 文件异步写入:业务只投递不落盘
queue_size: 8192 # 异步队列容量
batch_size: 256 # 攒够多少条批量写盘
flush_interval: 1s # 定时刷盘窗口
enqueue_timeout: 200ms # 队列满时最长等待,超时丢弃并计数自报
caller: true # 打印调用点
stacktrace: true # error 及以上自动带堆栈
slow_threshold: 500ms # 访问日志:慢请求阈值,超过提升为 Warn 并打 slow 标记
skip_paths: # 探针路径不打访问日志,避免被健康检查刷爆
- /healthz
- /readyz
rate_limit:
rps: 100 # 每秒每 IP 允许的请求数,0 表示关闭限流
burst: 200
cors:
allow_origins:
- "http://localhost:3000"
环境变量覆盖的效果,看一眼 docker-compose.yml 里的 app 服务就懂了——同一个二进制,容器里用环境变量把 MySQL/Redis 地址指向容器网络:
app:
build: .
environment:
APP_MYSQL_DSN: "root:123456@tcp(mysql:3306)/gin_scaffold?charset=utf8mb4&parseTime=True&loc=Local"
APP_REDIS_ADDR: "redis:6379"
APP_JWT_SECRET: "compose-only-secret-32bytes-change!"
到这里可以写个临时 main 验证一下(后面会替换成完整版):
go run ./cmd/server --config configs/config.yaml
如果把 yaml 里的 jwt.expire 改成 -1h,你会看到程序启动第一秒就报 配置缺失必填项: jwt.expire 退出——这就是 fail fast 的体感。
三、日志:服务的"黑匣子”
3.1 为什么不用 fmt.Println
fmt.Println)——喊完就没了,没人记得住、没法翻旧账。而病历本(结构化日志)上写"2026-09-05 15:00 | 床号:3 | 体温:36.5 | 医生:张三"——任何时候都能翻出来查。对应到工程里就是:生产环境日志会被采集进日志平台(ELK、Loki),检索时你要查"request_id=abc 的所有日志"、“最近 1 小时 error 级别日志”。
fmt.Println 输出的一行行纯文本没法结构化检索;zap 输出的 JSON 每个字段都能当查询条件。3.2 实现:zap 三路输出 + 按天归档 + 异步落盘
新建 pkg/logger/logger.go。四个设计目标:JSON 结构化(文件给 ELK/Loki 采集)、双通道(控制台给人看 + 文件给机器读)、info/error 分级落盘(排查故障时直奔 error 文件,不用在十万行 info 里捞)、异步写文件(日志 I/O 绝不拖慢请求链路)。
先看配置结构。pkg 是基础库,不能反向依赖 internal/config,所以它自带一份独立的 Config,由启动层的 provider 做转换注入——基础库保持自治,才能被下一个项目原样复用:
// Config 日志配置。pkg 层包不引用 internal/config,
// 由 cmd/server 的 provider 负责把应用配置转换为本结构。
type Config struct {
Level string // debug / info / warn / error,默认 info
Dir string // 日志目录,为空表示只输出控制台(容器场景推荐)
Prefix string // 文件名前缀 -> app-2026-09-09.info.log
MaxSizeMB int // 单文件最大 MB,超过由 lumberjack 二次切割
MaxBackups int // 保留的归档天数
MaxAgeDays int // 归档保留天数
Compress bool // 历史日志 gzip 压缩
Console bool // 是否输出控制台
ConsoleAsync bool // 控制台是否异步(容器采集建议 false)
Async bool // 文件是否异步写入
QueueSize int // 异步队列容量,默认 8192
BatchSize int // 攒够多少条批量写盘,默认 256
FlushInterval time.Duration // 定时刷盘窗口,默认 1s
EnqueueTimeout time.Duration // 队列满时最长等待,超时丢弃,默认 200ms
Caller bool // 打印调用点
Stacktrace bool // error 及以上自动带堆栈
}
布尔开关(console/async/caller)有个新手容易忽略的配置陷阱:默认值为 true 的布尔项不能靠"没配就是 false"碰运气。Viper 的 Unmarshal 对未配置的布尔字段给零值 false,“忘了配"和"显式关闭"就区分不了——所以加载层要用 viper.SetDefault 把默认 true 显式声明出来,配置文件里的显式 false 依然优先生效。
主体构建函数分三步走。步骤 1:三个 core,靠 Tee 汇成一颗日志树——控制台用 ConsoleEncoder(人读,彩色级别),文件用 JSONEncoder(机器读);info 文件的级别闸门是"低于 Error 放行”,error 文件是"Error 及以上放行",两个 zap.LevelEnablerFunc 就是分流的全部机关:
// 分级闸门:靠 LevelEnabler 过滤,而不是「写两份再靠文件名区分」,
// 保证 error 不会重复出现在 info 文件里。
infoLevel := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= level && l < zapcore.ErrorLevel
})
errorLevel := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= level && l >= zapcore.ErrorLevel
})
cores = append(cores,
zapcore.NewCore(fileEncoder, infoWS, infoLevel),
zapcore.NewCore(fileEncoder, errorWS, errorLevel),
)
log := zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
步骤 2:文件名的日期由谁来定? lumberjack 只会"按大小切割",文件名里没有日期概念。所以按天归档要自己包一层 WriteSyncer:内部持着当天的 *lumberjack.Logger,每次写入时检查日期翻篇没有——翻篇就关旧的、按新日期建新的。文件名最终是 logs/app-2026-09-09.info.log 与 logs/app-2026-09-09.error.log 两族。这里有个真实踩过的坑值得写进注释:lumberjack 的历史文件清理按"文件名去扩展名 + -“做前缀匹配,而按天归档的文件名每天都在变——旧日期的文件跨天后没人认领,MaxBackups/MaxAge 形同虚设,磁盘会无限涨。所以清理逻辑也要自己来:跨天切换时扫一遍目录,按日期分组,超期的整组删掉。
步骤 3:文件写入异步化。 同步写文件时,每次 log.Info 都要等一次磁盘 I/O——日志一多,请求耗时就被日志拖着走。异步的做法是给文件 core 外面包一层"队列 writer”:业务 goroutine 只把编码好的字节投进有界 channel 就返回,一个后台消费者攒批写盘。三个安全设计缺一不可:
- 有界队列 + 超时丢弃:channel 容量 8192,满了最多等 200ms,超时就丢并计数——日志是旁路设施,宁可丢日志也不能把业务 goroutine 拖死;
- 丢弃要自报:消费者周期性检查丢弃计数,变化了就写一条 warn 出来,否则背压发生了都没人知道;
- 关停排空:进程退出时先关投递入口、等后台把队列里残留的日志全部写完、Sync 刷盘,最后才关文件——顺序反了就丢最后一批日志。
// 关停顺序不能反:先停投递并排空异步队列,最后刷盘,否则会丢一批日志
cleanup := func() {
for _, c := range closers {
_ = c.Close()
}
_ = log.Sync()
}
return log, cleanup, nil
实测效果给个量级概念:写一万条日志,同步阻塞调用方约 50ms,异步约 7ms——高并发下这笔账会被放大成可感知的 RT 差异。代价也要说清楚:进程被 kill -9 时,队列里还没落盘的日志会丢,所以"关键 error 建议同步旁路、采集侧依赖 stdout"这两条兜底要写进部署文档。
另外一个容易漏的接管点:gin 自己的输出(启动期的 [GIN-debug] 路由表、[WARNING] 告警)默认走它自己的 writer,和控制台里 zap 的格式两轨并行。做法是在构建 gin 引擎之前把 gin.DefaultWriter/DefaultErrorWriter 指到一个适配器上,按行转发给 zap、按行首标记映射级别([WARNING] → warn、[ERROR] → error)。时机必须早于 gin.New(),否则启动期的路由表打印就抓不到了。
New 的整体骨架(省略号处是上面讲过的各段装配逻辑):
// New 构建日志实例。返回的 cleanup 负责关停异步队列并刷盘,
// 由 wire 编排为整个依赖清理链的一环,保证最后才关日志。
func New(cfg Config) (*zap.Logger, func(), error) {
// 级别解析失败直接报错——启动即失败,
// 杜绝"以为开了 debug 实际一条没打"的静默降级
...
// 控制台 core + info/error 两个文件 core,Tee 汇成一颗日志树
...
// 全局归口:标准库 log 与第三方库的输出也走 zap,避免日志分裂
zap.ReplaceGlobals(log)
zap.RedirectStdLog(log)
cleanup := ...
return log, cleanup, nil
}
用法长这样(后面各章都会用到):
log.Info("MySQL 连接就绪", zap.String("addr", "127.0.0.1:3306"))
log.Error("创建用户失败", zap.Error(err))
输出长什么样?同一个 log.Info,两条通道两种脸——控制台给人读(对齐 + 彩色级别),文件给机器读(JSON):
2026-09-09 13:01:02.512 INFO data/data.go:75 MySQL 连接就绪 {"addr": "127.0.0.1:3306"}
{"level":"info","time":"2026-09-09T13:01:02.512+0800","caller":"data/data.go:75","msg":"MySQL 连接就绪","addr":"127.0.0.1:3306"}
级别怎么选? 记一条经验法则:
| 级别 | 什么时候用 | 生产环境会不会打印 |
|---|---|---|
| debug | 排查细节(每条 SQL、参数值) | 一般关掉 |
| info | 关键路径节点(启动、关闭、建连成功)、访问日志 | 开 |
| warn | 可自动恢复的异常(慢 SQL、依赖短暂不可用) | 开 |
| error | 需要人介入的故障(数据库挂了、panic) | 开 + 告警 |
3.3 让日志实例随请求流动:context 里的子日志
这是本项目和一般教程拉开差距的设计。先建立直觉:
对应到工程里就是:请求进来时,request_id 中间件造一个"带 request_id 字段的子日志实例"(房卡),塞进 context。之后访问日志、业务日志、SQL 日志全部从 context 里取这个实例来打日志——每条日志自动带 request_id,业务代码一行都不用改。
type loggerKey struct{} // 空结构体做 context 键,全宇宙不撞车
// WithLogger 将日志实例(通常是带 request_id 字段的子实例)放入 context。
func WithLogger(ctx context.Context, log *zap.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, log)
}
// FromContext 取出 context 中的日志实例;未注入时返回 Nop 实例,
// 保证任意代码路径下调用都不 panic。
func FromContext(ctx context.Context) *zap.Logger {
if log, ok := ctx.Value(loggerKey{}).(*zap.Logger); ok {
return log
}
return zap.NewNop()
}
// FromContextOrDefault 未注入时返回 fallback,
// 供 data 层等无法保证 context 一定携带日志的基础设施代码使用。
func FromContextOrDefault(ctx context.Context, fallback *zap.Logger) *zap.Logger {
if log, ok := ctx.Value(loggerKey{}).(*zap.Logger); ok {
return log
}
return fallback
}
注入动作发生在 request_id 中间件里(第六章细讲),这里先看整条链路长什么样:
flowchart LR
A[请求进入] --> B[RequestID 中间件
log.With request_id
注入 context]
B --> C[AccessLog 中间件
FromContext 取日志]
B --> D[Service 业务日志
FromContext 取日志]
B --> E[GORM SQL 日志
FromContextOrDefault 取日志]
C --> F[(日志平台
按 request_id 串起全链路)]
D --> F
E --> F三个设计细节值得记住:
zap.NewNop()兜底:FromContext取不到时返回一个"什么都不做"的日志器,而不是 nil。这样任何代码路径(包括单测里没有注入日志的场景)调log.Info(...)都不会 panic。type loggerKey struct{}空结构体做键:context 的键是any类型,用字符串做键的话,任何第三方包用同名字符串就撞车了(互相覆盖)。自定义空结构体类型,全宇宙不会有第二个包跟它撞——空结构体还不占内存。- 子日志实例是"不可变追加":
log.With(zap.String("request_id", id))返回的是一个新 logger,原 logger 不受影响。所以每个请求的子实例互不干扰,天然并发安全。
四、数据层:MySQL + Redis 与透明事务
配置和日志就绪后,下一个基础设施是存储:业务数据总要落进 MySQL、热点数据总要缓存在 Redis。这一章要解决三件事——连接池怎么配才不出事、两大客户端怎么在一个地方统一初始化与销毁、以及事务边界怎么划定才能既不污染方法签名、又不失控。
4.1 先理解连接池
MaxOpenConns),空闲时也保留几个随时待命(MaxIdleConns),窗口每半年检修轮换一次(ConnMaxLifetime,防止连接被中间设备悄悄掐断后还在用)。对应到工程里就是:
database/sql 的连接池三参数,忘配或配错,要么高并发下连接爆掉,要么每次请求都重新建连接、延迟翻好几倍。Redis 客户端同理,pool_size 就是它自己的连接池。4.2 data.New:一个函数初始化全部存储
新建 internal/data/data.go。这个包是整个数据层的资源持有者——MySQL、Redis 客户端都在这里初始化、在这里关闭:
// Data 聚合 MySQL 与 Redis 客户端,由 wire 构造为单例注入 repository。
type Data struct {
db *gorm.DB
rdb redis.UniversalClient
}
// New 初始化数据层资源。
// 返回的 cleanup 由 wire 编排:应用关停时先关连接,再执行上游(日志)的清理。
func New(cfg *config.Config, log *zap.Logger) (*Data, func(), error) {
// 步骤 1:打开 MySQL(gorm.Open 是惰性建连,此时还没真正拨号)
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN), &gorm.Config{
// 把驱动层错误翻译为 gorm 通用错误:
// MySQL 1062 -> gorm.ErrDuplicatedKey,上层不再感知具体数据库
TranslateError: true,
Logger: newGormLogger(log), // SQL 日志桥接到 zap,见 4.4
PrepareStmt: true, // 预编译语句缓存,高频查询省一轮往返
})
if err != nil {
return nil, nil, fmt.Errorf("连接 MySQL 失败: %w", err)
}
// 步骤 2:设置连接池参数(GORM 是壳,这里拿到底层的 *sql.DB)
sqlDB, err := db.DB()
if err != nil {
return nil, nil, fmt.Errorf("获取底层 SQL 连接池失败: %w", err)
}
sqlDB.SetMaxOpenConns(cfg.MySQL.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MySQL.MaxIdleConns)
sqlDB.SetConnMaxLifetime(cfg.MySQL.ConnMaxLifetime)
// 步骤 3:初始化 Redis 客户端
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
})
// 步骤 4:启动期对 Redis 做连通性探测,fail fast 而不是运行期才暴露。
// 失败时顺手把已建好的 MySQL 连接池关掉,不留半开的资源
pingCtx, cancel := context.WithTimeout(context.Background(), cfg.Redis.DialTimeout)
defer cancel()
if err := rdb.Ping(pingCtx).Err(); err != nil {
_ = sqlDB.Close()
return nil, nil, fmt.Errorf("连接 Redis 失败: %w", err)
}
// 步骤 5:返回清理函数,关停时逆序释放(第八章看 wire 怎么编排它)
cleanup := func() {
_ = sqlDB.Close()
_ = rdb.Close()
}
return &Data{db: db, rdb: rdb}, cleanup, nil
}
// DB 返回 GORM 会话入口。
func (d *Data) DB() *gorm.DB { return d.db }
// RDB 返回 Redis 客户端。
func (d *Data) RDB() redis.UniversalClient { return d.rdb }
两个细节值得圈出来:
TranslateError: true是少写百行 if 的关键。不开它,MySQL 唯一索引冲突返回的是驱动层*MySQLError{Number: 1062},你得 import MySQL 驱动包去判型号;开了它,GORM 统一翻译成gorm.ErrDuplicatedKey,上层用errors.Is判断即可——将来换 PostgreSQL,业务代码一行不动。- 返回值
(*Data, func(), error)三个值是什么套路? 这是 Wire 依赖注入的标准 provider 签名:资源本体、清理函数、错误。func()就是 cleanup——第八章会看到 Wire 自动把所有 cleanup 按初始化逆序串起来。
4.3 透明事务:TxManager + DBFrom
这是全项目最精妙的一个设计,先看它解决什么问题。假设业务要求"创建用户 + 初始化账户 + 发放新人积分"三步要么全成、要么全败——也就是一个数据库事务。常规写法是 service 层开事务、把 tx 对象逐个传给 repository 的每个方法:
// 反面教材:事务连接污染了所有方法签名
func (s *Service) Register(ctx context.Context, ...) error {
tx := s.db.Begin()
s.repo.Create(ctx, tx, user) // 每个方法都多一个 tx 参数
s.accountRepo.Init(ctx, tx, ...) // 不用事务的方法也得带个 nil
...
}
方法签名被 tx 污染,不用事务的调用方也得传个 nil,丑且容易错。本项目的解法:把事务连接藏进 context。
// txKey 事务连接在 context 中的存取键(包内私有,避免 key 冲突与误用)。
type txKey struct{}
// TxManager 事务管理器接口,service 层只依赖本接口开启事务,
// 不感知 GORM 类型,保持业务层与数据层的解耦。
type TxManager interface {
// InTransaction 在 fn 执行期间把事务连接注入 ctx:
// fn 内经由 DBFrom 获取连接的 repository 调用自动进入同一事务;
// fn 返回 error 时整体回滚,返回 nil 时提交。
InTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
// InTransaction 实现 TxManager。事务边界一律在 service 层开启与提交,
// repository 不得自行管理事务。
func (d *Data) InTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(context.WithValue(ctx, txKey{}, tx))
})
}
// DBFrom 返回当前上下文中应使用的数据库连接:
// 处于事务中(ctx 由 InTransaction 注入)时返回事务连接,否则返回主连接。
// repository 层所有读写必须经由本函数取连接,才能透明地加入事务。
func DBFrom(ctx context.Context, fallback *gorm.DB) *gorm.DB {
if tx, ok := ctx.Value(txKey{}).(*gorm.DB); ok && tx != nil {
return tx
}
return fallback
}
配合一张图看数据怎么流动:
flowchart TB
S[Service 层
s.txm.InTransaction ctx, fn] -->|开启事务| TX[Data 层
把 tx 藏进 ctx]
TX -->|fn 收到带 tx 的 ctx| R1[repo.Create
DBFrom 取到 tx]
TX -->|fn 收到带 tx 的 ctx| R2[repo.其他写操作
DBFrom 取到 tx]
R1 -->|同一事务| DB[(MySQL)]
R2 -->|同一事务| DB
TX -->|fn 返回 error 回滚
返回 nil 提交| DB
style TX fill:#fff3cd,stroke:#e17055于是 service 写业务时完全不用关心连接怎么传:
// service 层:fn 里的所有 repo 调用自动进入同一事务
err := s.txm.InTransaction(ctx, func(ctx context.Context) error {
if err := s.repo.Create(ctx, user); err != nil {
return err // 返回 error,整体回滚
}
// 真实场景继续调用其他 repo:初始化账户、发放积分……
return nil // 返回 nil,整体提交
})
r.db,跳出事务还不自知。DBFrom(ctx, r.db) 的语义是"ctx 里有事务就用事务连接,没有才用主连接 r.db"。如果某个 repository 方法图省事直接 r.db.Create(...),它在事务里照样直连主库——提交回滚对它完全无效。这种 bug 单测发现不了(单条 SQL 照样成功),只有在多步写入中途失败、发现数据"回滚了一半"时才暴露。纪律只有一条:repository 取连接必须经由 data.DBFrom。4.4 把 GORM 的 SQL 日志桥接到 zap
GORM 默认的 logger 往 stdout 写自有格式,和我们的 zap 日志是互不相通的两套。实现 GORM 的 logger.Interface,SQL 日志就和业务日志汇进同一条河(还能带上 request_id):
// slowSQLThreshold 慢 SQL 阈值,超过则打 WARN 日志。
const slowSQLThreshold = 200 * time.Millisecond
// newGormLogger 把 GORM 的日志桥接到 zap,级别随应用日志级别联动:
// debug -> 逐条记录 SQL;info -> 只记慢 SQL 和错误;再高 -> 只记错误
func newGormLogger(log *zap.Logger) gormlogger.Interface {
lvl := gormlogger.Warn
switch zapcore.Level(log.Level()) {
case zapcore.DebugLevel:
lvl = gormlogger.Info
case zapcore.InfoLevel:
lvl = gormlogger.Warn
default:
lvl = gormlogger.Error
}
return &gormLogger{log: log, level: lvl}
}
// Trace 是 GORM 每条 SQL 执行完的回调(Info/Warn/Error/LogMode 是
// 接口要求的样板方法,略)。执行出错 -> error、慢查询 -> warn。
func (g *gormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
if g.level <= gormlogger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := fc()
fields := []zap.Field{
zap.Duration("elapsed", elapsed),
zap.Int64("rows", rows),
zap.String("sql", sql),
}
// 关键一行:从 context 取出带 request_id 的子日志实例,
// SQL 日志因此和访问日志、业务日志共用同一串 ID
log := logger.FromContextOrDefault(ctx, g.log)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
log.Error("sql 执行失败", append(fields, zap.Error(err))...)
case elapsed > slowSQLThreshold:
log.Warn("慢 SQL", fields...)
case g.level >= gormlogger.Info:
log.Debug("sql", fields...)
}
}
零基础的同学注意 Trace 方法里的 switch { case ... } 语法——不带判断对象的 switch,等价于一串 if / else if,但每个分支条件独立写、更整齐,Go 代码里非常常见。
ErrRecordNotFound 当 error 处理。“查不到记录"对数据库来说不是故障,对业务来说也不是——它就是要返回给客户端"用户不存在"的正常分支。注意 Trace 的第一个分支把 gorm.ErrRecordNotFound 排除在外:如果你把它按 error 级别打日志再接告警,一个爬虫扫一遍你的接口,值班同学的手机就炸了。日志级别反映的是"需不需要人管”,不是"代码有没有走错分支"。五、统一响应与错误码:对外只说一种话
地基再稳,对外说话混乱也是白搭。这一章把"服务对客户端说什么、怎么说"统一起来——一个信封格式、一套错误码分段,让前端只学一次、让网关告警不再误伤。
5.1 为什么需要统一格式
假设每个接口自己决定响应格式——登录返回 {"code":0},查用户返回 {"success":true}——前端同学要为每个接口写一套解析逻辑。统一成一个信封(Envelope),所有人只学一次:
成功:{"code": 0, "msg": "success", "data": {...}}
失败:{"code": 200002, "msg": "用户名已存在", "data": null}
这里有一个和许多教程不同的流派选择,值得单独讲清楚:本项目所有业务响应(包括失败)都返回 HTTP 200,业务成败完全由 code 字段承载。另一种流派是业务错误映射到对应的 HTTP 状态码(404、409……)。两条路线的取舍:
| HTTP 200 + 业务码(本项目) | HTTP 状态码语义化 | |
|---|---|---|
| 网关/限流器/监控 | 不会被业务 4xx 误伤(业务失败不算网关异常) | 业务 404 可能和"路由不存在"混在一起告警 |
| 前端处理 | 只认 code,一套逻辑 | 要同时处理 HTTP 层和业务层两套错误 |
| 探针/健康检查 | 例外:readyz 失败必须返回 503(第九章讲) | 天然语义化 |
一句话:HTTP 状态码只表达"传输层和网关层的问题",业务语义全部下沉到 code。选哪条流派都有道理,重要的是全项目只用一种。
5.2 统一响应:pkg/response
// Body 统一响应体。
type Body struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
// PageData 分页响应的 data 结构。
type PageData struct {
List any `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// OK 返回成功响应。
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, Body{
Code: errcode.Success.Code(),
Msg: errcode.Success.Msg(),
Data: data,
})
}
// Page 返回分页成功响应。
func Page(c *gin.Context, list any, total int64, page, pageSize int) {
OK(c, PageData{List: list, Total: total, Page: page, PageSize: pageSize})
}
// Fail 返回失败响应。只接受错误码与消息,
// 避免调用方把内部错误细节(堆栈、SQL 等)直接透出。
func Fail(c *gin.Context, code int, msg string) {
c.JSON(http.StatusOK, Body{Code: code, Msg: msg})
}
Data any 里的 any 是 Go 1.18+ 的"任意类型"(等价于 interface{})——响应数据可能是用户对象、列表、null,类型没法提前定死,所以用 any。
5.3 错误码:六位数字,按模块分段
新建 pkg/errcode/errcode.go。错误码要分段,防止不同模块撞车:
// ErrCode 业务错误类型,实现了 error 接口,
// 可通过 errors.Is / errors.As 与哨兵错误比较。
type ErrCode struct {
code int
msg string
}
// New 创建一个错误码定义,仅允许在本包内集中声明,禁止散落在业务代码中。
func New(code int, msg string) *ErrCode { return &ErrCode{code: code, msg: msg} }
func (e *ErrCode) Code() int { return e.code }
func (e *ErrCode) Msg() string { return e.msg }
func (e *ErrCode) Error() string {
return fmt.Sprintf("code: %d, msg: %s", e.code, e.msg)
}
// 通用错误段:100000-199999
var (
Success = New(0, "success")
ErrServer = New(100001, "服务器内部错误") // 细节只写日志,不暴露给调用方
ErrInvalidParams = New(100002, "参数错误")
ErrNotFound = New(100003, "资源不存在")
ErrUnauthorized = New(100004, "未授权或登录已过期")
ErrTokenGenerate = New(100005, "令牌生成失败")
ErrTooManyRequests = New(100006, "请求过于频繁,请稍后再试")
)
// 用户模块错误段:200000-299999
var (
ErrUserNotFound = New(200001, "用户不存在")
ErrUserDuplicate = New(200002, "用户名已存在")
// 用户不存在与密码错误共用此码,不区分是哪个,防止账号枚举探测
ErrPasswordWrong = New(200003, "用户名或密码错误")
ErrUserDisabled = New(200004, "用户已被禁用")
)
分段表一眼看清归属:
| 段 | 归属 | 示例 |
|---|---|---|
| 0 | 成功 | — |
| 100000-199999 | 通用(参数/授权/限流/服务器) | 100002 参数错误 |
| 200000-299999 | 用户模块 | 200002 用户名已存在 |
| 300000-399999 | 后续新模块依次递增 | — |
注意 New 的注释——“仅允许在本包内集中声明"是一条纪律:错误码散落在各个业务文件里,三个月后必然撞号或语义漂移;集中在 errcode 包里,加新码时扫一眼就知道该用哪段。
5.4 Error:错误的"前台接待”
整个错误体系最巧妙的一个函数:
// Error 把任意 error 翻译为统一响应:
// 业务错误(*errcode.ErrCode)原样透出;其余一律归为服务器内部错误。
func Error(c *gin.Context, err error) {
var ec *errcode.ErrCode
if errors.As(err, &ec) {
Fail(c, ec.Code(), ec.Msg())
return
}
Fail(c, errcode.ErrServer.Code(), errcode.ErrServer.Msg())
}
*ErrCode,业务定义过的错误)——“张总在 3 楼,请上”(原样透出 code/msg)。不认识的人(任何其他 error——数据库断连、空指针、网络超时)——“请留下联系方式,会有专人联系您”(统一回复 100001 服务器内部错误)。前台绝不告诉访客"张总其实在住院"(内部细节)。对应到工程里就是:如果 handler 图省事直接把
err.Error() 返回给客户端,SQL 片段、表名、内部路径就全泄露了——这是 OWASP 榜上有名的信息泄露漏洞。原则一句话:细节走日志,客户端只见门面。errors.As(err, &ec) 的意思是"沿着错误包装链一层层剥,看里面有没有 *ErrCode"。配合 %w 包装,错误即使被包了三层五层,As 也能剥到最里层——这就是 Go 错误处理的标准姿势。
5.5 错误的三级翻译:从数据库到客户端
到这里,错误的完整旅程可以画出来了——这是理解整个错误体系的一张图:
flowchart LR
DB[(MySQL
1062 唯一键冲突)] -->|TranslateError| G[gorm.ErrDuplicatedKey
驱动无关]
G -->|repository 翻译| S[ErrDuplicateUser
数据层哨兵错误]
S -->|service 翻译| E[errcode.ErrUserDuplicate
业务错误码 200002]
E -->|response.Error| C[客户端
code 200002 msg 用户名已存在]
DB -.->|意外错误| X[未知 error] -.->|service 记日志后翻译| ES[errcode.ErrServer 100001]
style S fill:#fff3cd,stroke:#e17055
style E fill:#e6f9f0,stroke:#00b894为什么要翻三次?因为每一层只说自己的语言:repository 不知道"用户名已存在"该对应哪个对外的码(那是产品语义),service 不知道 MySQL 的 1062 是什么(那是存储细节),handler 不知道错误该不该让客户看到细节(那是安全策略)。每层只翻译一步,职责刚好闭合。第七章写业务代码时,你会看到这三级翻译的实际样子。
六、中间件管道:每个请求的"安检流水线"
6.1 什么是中间件
对应到工程里就是:日志、鉴权、限流、panic 恢复这类"每个接口都需要但不属于任何业务"的功能,叫横切关注点(cross-cutting concerns)。写成中间件,注册一次全局生效,业务代码零感知。
本项目全局挂载五个中间件,顺序是精心安排的(JWT 鉴权例外,它按路由组挂载,第九章讲):
flowchart LR
A[HTTP 请求] --> B[Recovery
panic 兜底]
B --> C[RequestID
生成 ID + 注入子日志]
C --> D[AccessLog
记状态码/耗时]
D --> E[CORS
跨域头处理]
E --> F[RateLimit
按 IP 令牌桶]
F --> G[业务路由
JWT 鉴权组]
G --> H[Handler]顺序为什么是这样?三条理由:
- Recovery 在最外层:它要兜住后面所有环节(包括其他中间件)的 panic。放里面的话,外层中间件 panic 就直接打挂进程了。
- RequestID 紧随 Recovery:它负责生成 ID 并把"带 ID 的子日志实例"注入 context,后面的 AccessLog、业务日志、SQL 日志全靠它。
- AccessLog 在 RequestID 之后:访问日志里要带 request_id,必须先有人把 ID 造出来。
6.2 Recovery:panic 兜底
handler 里一个空指针,没有兜底的话整个进程直接崩——一个 bug 打挂全部在线接口。gin 自带 Recovery(),但它把堆栈写到 gin 默认的 stderr,和 zap 日志两轨并行、没法统一检索,所以自己实现一份。新建 internal/middleware/recovery.go:
// NewRecovery panic 恢复中间件:兜住所有 handler 里未捕获的 panic,
// 记录堆栈日志后返回统一错误响应,避免单个请求的 panic 打挂整个进程。
func NewRecovery(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if rec := recover(); rec != nil && rec != http.ErrAbortHandler {
// 优先用 context 里带 request_id 的子日志实例,
// 取不到才回退到传入的基础实例(比如 panic 发生在 RequestID 之前)
logger.FromContextOrDefault(c.Request.Context(), log).Error("panic 已恢复",
zap.Any("panic", rec),
zap.ByteString("stack", debug.Stack()),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
)
// HTTP 500 而非业务码包 200:网关、LB 与监控的状态码
// 告警只认 HTTP 状态码,panic 全走 200 会变成监控盲区
//(与 readyz 的非 2xx 原则一致)。响应体仍保持统一格式。
c.JSON(http.StatusInternalServerError, response.Body{
Code: errcode.ErrServer.Code(),
Msg: errcode.ErrServer.Msg(),
})
c.Abort()
}
}()
c.Next()
}
}
两个 Go 知识点:
defer func() { ... }():defer 把这个函数推迟到外层函数返回前执行。panic 发生时会逆序执行已注册的 defer——所以在 defer 里recover()就能"接住" panic,进程不崩。这是 Go 实现"兜底"的标准手段。http.ErrAbortHandler为什么要放行? 它是 net/http 内部的连接中止信号,不是业务 bug。把它当 panic 恢复掉会破坏框架行为,所以判断出来直接不处理(gin 会把它重新抛出)。
6.3 RequestID:给每个请求发"身份证"
对应到工程里就是:用户报障"我创建用户失败",你只有这一句话。但如果失败响应的
X-Request-ID 响应头还在,拿这串 ID 去日志平台一搜:访问日志(状态码、耗时)、业务日志(用户名已存在)、SQL 日志(INSERT … Duplicate entry)三段记录全出来,30 秒定位。新建 internal/middleware/request_id.go:
// HeaderXRequestID 请求追踪头,响应中原样透传。
const HeaderXRequestID = "X-Request-ID"
// NewRequestID 请求追踪中间件:
// 1. 优先透传上游网关传入的 X-Request-ID,没有则生成 UUID;
// 2. 写回响应头,调用方可据此排查问题;
// 3. 构造带 request_id 字段的子日志实例并注入 context,
// 后续所有日志(访问日志、SQL 日志、业务日志)自动携带该字段。
func NewRequestID(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
// 步骤 1:上游(网关/前端)带了 ID 就沿用——跨服务串联靠它
requestID := c.GetHeader(HeaderXRequestID)
if requestID == "" {
requestID = uuid.NewString()
}
// 步骤 2:写回响应头——客户端报障时报这串
c.Header(HeaderXRequestID, requestID)
// 步骤 3:造一个带 request_id 字段的子日志实例,注入 context。
// 之后所有 FromContext 取日志的地方都自动带上这个字段
ctx := logger.WithLogger(c.Request.Context(), log.With(zap.String("request_id", requestID)))
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
不上 ELK、不做分布式追踪,仅靠"每个请求一个 ID + 日志带 ID"就能获得 80% 的排障能力——这就是最小可观测性,也是脚手架必须内置它的原因。
FromContext 只能拿到 Nop 实例,日志里 request_id 字段整个消失。记住铁律:RequestID 必须在所有要打日志的环节之前——它给后面所有环节发"身份证"。6.4 AccessLog:每个请求一条结构化记录
新建 internal/middleware/access_log.go。相比"每个请求打一行",生产级访问日志要多回答三个问题:哪些请求不值得打(健康检查每秒探一次,全量记录会把日志刷爆,所以 /healthz、/readyz 直接跳过)、日志里能不能有敏感信息(query 里的 password、token 必须脱敏成 ***,否则日志系统本身就是泄密源)、什么请求要升级对待(5xx 记 error 让告警直接按 level 捕获、慢请求和 4xx 记 warn)。看实现里最关键的两段:
// NewAccessLog 结构化访问日志中间件:每个请求结束时输出一行日志,
// 携带 request_id、方法、路径、状态码、耗时、客户端 IP 等字段。
func NewAccessLog(cfg AccessLogConfig) gin.HandlerFunc {
...
return func(c *gin.Context) {
// 探针降噪:健康检查每秒都在打,全量记录会把日志刷爆
if _, ok := skip[c.Request.URL.Path]; ok {
c.Next()
return
}
start := time.Now()
c.Next() // 关键:先放行,让后面的中间件和 handler 执行
log := logger.FromContext(c.Request.Context()) // 自动带 request_id
fields := []zap.Field{
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("query", sanitizeQuery(c.Request.URL.RawQuery)),
// 路由模板(如 /users/:id)而非真实参数,便于聚合统计
zap.String("handler", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.Int64("latency_ms", time.Since(start).Milliseconds()),
zap.String("client_ip", c.ClientIP()),
zap.Int("body_size", c.Writer.Size()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("referer", c.Request.Referer()),
zap.Bool("slow", latency >= slow),
}
switch {
case status >= http.StatusInternalServerError:
log.Error("access", fields...) // 5xx 记 error,告警直接按 level 捕获
case status >= http.StatusBadRequest, isSlow:
log.Warn("access", fields...) // 4xx 与慢请求记 warn
default:
log.Info("access", fields...)
}
}
}
两个新字段值得停一下:
handler打的是路由模板(c.FullPath()返回/users/:id),不是真实参数(/users/42)。真实参数会把同一接口打散成千万条"不同路径",聚合统计没法做,还顺带把业务 ID 泄进日志;模板才能回答"哪个接口最慢"。query过了一道sanitizeQuery:解析 query string,把password、token、authorization等敏感 key 的值替换成***再落盘。解析失败(格式异常)时宁可整体打码——无法识别的内容不能进日志。
c.Next() 的执行模型是理解中间件的钥匙:调用它之前的代码在请求"下行"时执行,调用它之后的代码在响应"上行"时执行。所以 start := time.Now() 在最前面、time.Since(start) 在 c.Next() 之后——正好包住整个处理过程,耗时才准确。
6.5 CORS:跨域的门卫
前后端分离架构下,前端跑在 localhost:3000,后端在 :8080——浏览器会拦截跨源请求,除非后端明确说"我允许你"。新建 internal/middleware/cors.go:
// NewCORS 跨域中间件。允许的来源由配置注入,支持 "*" 与精确域名列表;
// 携带凭证时不允许回显 "*",因此对具体域名原样回显并设置 Vary: Origin。
func NewCORS(allowOrigins []string) gin.HandlerFunc {
allowAll := len(allowOrigins) == 1 && allowOrigins[0] == "*"
originSet := make(map[string]struct{}, len(allowOrigins))
for _, o := range allowOrigins {
originSet[o] = struct{}{}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
_, allowed := originSet[origin]
if allowAll || allowed {
// 回显具体 Origin 而不是 "*":带 Cookie/Authorization 的
// 跨域请求,浏览器不接受通配符
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin") // 告诉 CDN/缓存:不同 Origin 响应不同
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-ID")
c.Header("Access-Control-Max-Age", "86400") // 预检结果缓存一天
}
}
// 预检请求(OPTIONS)直接返回 204,不进入业务 handler
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
一句话背景:浏览器对"复杂跨域请求"(带自定义头、PUT/DELETE 等)会先发一个 OPTIONS 预检请求问后端"你允不允许我发真的?"——所以中间件遇到 OPTIONS 直接回 204,不放行到业务层。
6.6 RateLimit:按 IP 的令牌桶
不限流的接口等于把服务器命门交给网络上的每一台机器。新建 internal/middleware/ratelimit.go,用 golang.org/x/time/rate 的令牌桶算法按客户端 IP 限流:
// ipLimiter 按客户端 IP 维护独立的令牌桶。
// 说明:单机内存限流仅适用于单体服务;多实例部署时应在网关层
// 或使用 Redis + Lua 实现分布式限流。
type ipLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
stop chan struct{}
}
type visitor struct {
limiter *rate.Limiter
lastSeen time.Time
}
// NewRateLimit 按 IP 限流中间件。rps <= 0 时视为关闭限流(直接放行)。
//
// 返回值第二个为销毁函数:关闭限流器内部的后台清理 goroutine。
// 忘记调用不影响请求处理,但 goroutine 与 visitor map 会一直
// 驻留到进程退出——测试中反复构建 router 时尤其明显。
func NewRateLimit(rps float64, burst int) (gin.HandlerFunc, func()) {
if rps <= 0 {
return func(c *gin.Context) { c.Next() }, func() {}
}
l := newIPLimiter()
return func(c *gin.Context) {
ip := c.ClientIP()
if !l.allow(ip, rps, burst) {
response.Fail(c, errcode.ErrTooManyRequests.Code(), errcode.ErrTooManyRequests.Msg())
c.Abort()
return
}
c.Next()
}, l.Close // router 层把它编进应用关停链(见第九章)
}
// allow 判断该 IP 本次请求是否放行,并刷新其活跃时间。
func (l *ipLimiter) allow(ip string, rps float64, burst int) bool {
l.mu.Lock()
defer l.mu.Unlock()
v, ok := l.visitors[ip]
if !ok {
v = &visitor{limiter: rate.NewLimiter(rate.Limit(rps), burst)}
l.visitors[ip] = v
}
v.lastSeen = time.Now()
return v.limiter.Allow()
}
// cleanupLoop 每 5 分钟清理一次空闲超过 5 分钟的 IP 记录。
func (l *ipLimiter) cleanupLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
l.cleanup()
case <-l.stop:
return // Close 后退出,goroutine 不再泄漏
}
}
}
// Close 停止后台清理 goroutine;sync.Once 保证幂等,
// 重复调用不会因 channel 重复关闭而 panic。
func (l *ipLimiter) Close() {
l.stopOnce.Do(func() { close(l.stop) })
}
rps)往外吐通行证,攒下的通行证最多装一盒子(burst)。游客每次进场交一张证——平时人少随便进(证够),突然来一团人也能先进一批(盒子里有存货),但持续的大客流会被卡在机器发证的节奏上。对应到工程里就是:
rps: 100, burst: 200 表示"长期平均每秒最多 100 个请求,瞬时允许冲到 200"。平滑限流用固定窗口会切伤正常流量(窗口边界双倍通过问题),令牌桶天然支持突发,是限流的主流选择。两个工程细节:
cleanupLoop不可省,Close同样不可省:visitors这个 map 按 IP 增长,不清理的话,被爬虫扫几天,map 就把内存吃光了。每 5 分钟删掉 5 分钟没来的 IP——正常用户重新来时不过是新建一个满桶的限流器,无感知。而后台清理 goroutine 起了就要有停的通道:中间件返回一个销毁函数,router 层把它编进应用关停链。测试里反复构建 router 时这个细节尤其重要——没有销毁函数的话,每个测试用例都往进程里漏一个永不退出的 goroutine。- 代码注释里那句"多实例部署应在网关层限流"是诚实的边界声明:内存限流只管得住本进程,你部署 3 个副本,实际限流阈值就变成了 3 倍。脚手架给出单机正确实现 + 说明边界,比假装解决了分布式问题诚实得多。
七、业务四层:用户模块的完整实现
前面六章全是"脚手架",现在终于写业务了。但你会发现:业务代码反而是全文最按部就班的——因为脏活累活都被脚手架接走了。这正是好架构的检验标准。
7.1 餐厅分工类比
对应到工程里就是:handler 只做 HTTP 协议的翻译(JSON ↔ DTO),service 做业务判断与事务编排,repository 只做 MySQL/Redis 读写,model 定义表结构,data 持有连接。每个角色出错,锅是清楚的:返回格式不对找 handler,业务规则错了找 service,SQL 慢了找 repository,连接断了找 data。
flowchart TB
H[Handler 层
绑定校验 · 渲染响应] -->|调用接口| S[Service 层
业务规则 · 事务边界 · 错误翻译]
S -->|接口方法| R[Repository 层
MySQL 读写 + Redis 缓存]
R -->|经由 DBFrom 取连接| D[Data 层
连接池 · 事务管理器]
D --> M[(MySQL)]
D --> RD[(Redis)]
style H fill:#e8f4fd,stroke:#0984e3
style S fill:#fff3cd,stroke:#e17055
style R fill:#e6f9f0,stroke:#00b894注意箭头方向:上层只依赖下层暴露的接口,下层不知道上层的存在。handler 依赖 service.UserService 接口,service 依赖 repository.UserRepo 接口——接口全部定义在使用方所在层,实现可以被随时替换(比如单测里换成 mock)。
7.2 model:定义"用户长什么样"
新建 internal/model/user.go:
// BaseModel 所有模型的公共字段,软删除由 gorm.DeletedAt 实现。
type BaseModel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// 软删除:DELETE 实际是把 deleted_at 置为当前时间,
// 查询自动过滤已删除记录。json:"-" 表示永不序列化给客户端
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// User 用户表模型。
// json tag 主要服务于 Redis 缓存序列化;对外接口响应一律使用 handler 的 DTO,
// 模型永远不会被直接序列化给客户端。
type User struct {
BaseModel
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` // 唯一索引
Password string `gorm:"size:100;not null" json:"-"` // bcrypt 哈希,绝不外泄
Email string `gorm:"size:128" json:"email"`
Status int8 `gorm:"not null;default:1" json:"status"` // 1 启用 / 2 禁用
}
// TableName 显式指定表名,避免复数形式推断带来的歧义。
func (User) TableName() string { return "users" }
// 用户状态常量,禁止在业务代码中出现魔法数字。
const (
UserStatusEnabled int8 = 1
UserStatusDisabled int8 = 2
)
三个要点:
- 一个 struct 两套 tag:
gorm:管数据库(主键、唯一索引、默认值),json:管序列化。最值得注意的是Password的json:"-"——减号表示"序列化 JSON 时跳过这个字段",密码哈希永远不可能被意外返回给客户端。 - 软删除:
gorm.DeletedAt让DELETE变成"标记删除",数据还在表里但查询自动过滤。误删可恢复、审计可追溯,是生产系统的默认选择。 - 状态用常量不用魔法数字:代码里写
if user.Status == 2,三个月后没人记得 2 是什么;写if user.Status == model.UserStatusDisabled,语义自明。
7.3 dto:接口层的"报关单"
新建 internal/handler/dto/user.go。DTO(Data Transfer Object)是接口边界上的数据形状——它和 model 长得像,但职责完全不同:
// LoginRequest 登录请求。
type LoginRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
// Password bcrypt 有效输入上限为 72 字节
Password string `json:"password" binding:"required,min=6,max=72"`
}
// CreateUserRequest 创建用户请求。
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=6,max=72"`
Email string `json:"email" binding:"required,email,max=128"`
}
// UpdateUserRequest 更新用户请求(PUT 全量更新语义)。
type UpdateUserRequest struct {
Email string `json:"email" binding:"required,email,max=128"`
Status int8 `json:"status" binding:"required,oneof=1 2"`
}
// ListUsersRequest 分页查询请求。
type ListUsersRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
}
// UserResponse 用户信息响应——注意:根本没有 Password 字段。
type UserResponse struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status int8 `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
binding:"..." 是 gin 内置的 validator 标签,ShouldBindJSON 时自动执行校验。几个设计细节:
- 为什么
max=72? bcrypt 只处理前 72 字节,更长的密码也会被截断——超过 72 不是"更强",是"被截断",所以直接拒绝。 - 为什么
UserResponse不复用model.User? 这正是分层解耦的要害:model 变(加字段、改表结构)不影响 API 契约;API 变(响应里加个计算字段)不动表结构。而且 model 里有Password字段,哪天有人忘了json:"-"就是安全事故——DTO 从结构上消灭了这个可能。 oneof=1 2:状态只允许这两个值,非法值在绑定阶段就被拦下,业务层永远不用防御性判断。
7.4 repository:只管读写,外加一层缓存
新建 internal/repository/user_repo.go。先定义接口和本层的"方言"——哨兵错误:
// 查不到、唯一索引冲突等数据层事实以本包哨兵错误的形式向上抛出,
// 由 Service 层翻译为业务错误码,保证 Service 不依赖 GORM。
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateUser = errors.New("duplicate user")
)
const (
userCacheKeyPrefix = "scaffold:user:"
userCacheTTL = 10 * time.Minute
// 明确列出查询字段:避免 SELECT *,同时从结构上防止密码哈希外泄
userSafeFields = "id, username, email, status, created_at, updated_at"
)
// UserRepo 用户数据访问接口。
type UserRepo interface {
Create(ctx context.Context, user *model.User) error
// GetByID 按主键查询(cache-aside:先查 Redis,未命中回源 MySQL 并回填)
GetByID(ctx context.Context, id uint64) (*model.User, error)
// GetByUsername 按用户名查询完整字段(登录校验需要密码哈希与状态)
GetByUsername(ctx context.Context, username string) (*model.User, error)
ListByPage(ctx context.Context, offset, limit int) ([]*model.User, int64, error)
UpdateByID(ctx context.Context, id uint64, fields map[string]any) error
DeleteByID(ctx context.Context, id uint64) error
}
// userRepo UserRepo 的 GORM + Redis 实现。
// 事务约定:所有 GORM 调用一律经由 data.DBFrom(ctx, r.db) 取连接。
type userRepo struct {
db *gorm.DB
rdb redis.UniversalClient
}
// NewUserRepo 构造用户 repository,由 wire 注入 data.Data。
func NewUserRepo(data *data.Data) UserRepo {
return &userRepo{db: data.DB(), rdb: data.RDB()}
}
Go 社区经过多年实践得出的结论是 “accept interfaces, return structs”:接口表达的是消费者需要什么,而不是提供者有什么。
UserRepo 的方法集就是 service 需要的最小能力集,一个不多少一个不少。好处立竿见影:1. 单测零成本:service 依赖的是接口,测试里用 mock 实现注入(项目里
mock_user_repo.go 就是 mockgen 生成的),不需要连数据库;2. 接口不会膨胀:没有人会为"以防万一"多加方法;
3. 实现方构造函数返回接口类型(
NewUserRepo(...) UserRepo),调用方从头到尾摸不到 *userRepo 结构体。创建用户——唯一索引冲突翻译成哨兵错误:
// Create 写入新用户。
func (r *userRepo) Create(ctx context.Context, user *model.User) error {
if err := data.DBFrom(ctx, r.db).WithContext(ctx).Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { // TranslateError 的功劳,见 4.2
return ErrDuplicateUser
}
return err
}
return nil
}
查询用户详情——标准的 cache-aside(旁路缓存)模式。基础读路径三步走:先查缓存、未命中回源、回填。但只做这三步的话,两个经典攻击面没堵上:缓存穿透(恶意请求拿不存在的 ID 反复调用,缓存里永远不会命中,每次都打到 MySQL)和缓存击穿(某个热 key 过期的一瞬间,几百个并发请求同时未命中、同时回源)。生产级实现要把这两个洞一起补上:
// GetByID 按 ID 查询用户详情,标准的 cache-aside 缓存模式。
// 三个防护:空值占位防穿透、并发回源合并防击穿、缓存故障直接回源防雪崩放大。
func (r *userRepo) GetByID(ctx context.Context, id uint64) (*model.User, error) {
key := userCacheKey(id)
// 步骤 1:先查缓存;缓存挂了不阻断主流程,直接回源数据库
if val, err := r.rdb.Get(ctx, key).Result(); err == nil {
switch val {
case userNegativePlaceholder: // 命中空值占位:此 ID 近期确认不存在
return nil, ErrUserNotFound // 不把这类请求打到 MySQL(防穿透)
default:
var user model.User
if json.Unmarshal([]byte(val), &user) == nil {
return &user, nil
}
}
}
// 步骤 2:缓存未命中,回源 MySQL;同一 key 的并发请求只放一个进来(防击穿)
user, exists, err := r.loadWithSingleFlight(ctx, id)
if err != nil {
return nil, err
}
if !exists {
// 步骤 3a:查不到也缓存——写一个短 TTL 的空值占位。
// TTL 故意短(30s):记录真正创建后很快就能被读到
_ = r.rdb.Set(ctx, key, userNegativePlaceholder, userNegativeCacheTTL).Err()
return nil, ErrUserNotFound
}
// 步骤 3b:回填完整缓存;回填失败不影响本次请求结果
if payload, err := json.Marshal(user); err == nil {
_ = r.rdb.Set(ctx, key, payload, userCacheTTL).Err()
}
return user, nil
}
三个防护各自的原理:
- 空值占位防穿透:查不到的 ID 也往 Redis 写个占位值(一个不会与正常 JSON 混淆的控制字符
\x00),TTL 只给 30 秒——恶意流量在 30 秒内无论怎么刷都被缓存挡住,而记录真正创建后最多等 30 秒就能被读到。占位 TTL 和正常 TTL(10 分钟)刻意不同:占位要"短命",正常缓存要"长命"。 - 并发回源合并防击穿:
loadWithSingleFlight用sync.Map按 key 合并并发请求——第一个未命中的请求去回源,同一时刻的其他请求等它的结果共享(等待方还支持 context 取消,请求超时了就不再干等)。没有这一层,热 key 过期瞬间 MySQL 会吃到一波尖刺。 - 缓存故障降级防雪崩:注意步骤 1 的
err == nil才算命中——Redis 挂了、超时了,代码不报错、直接走回源。缓存是加速器不是依赖,它死了服务要活着,只是慢一点。
配一张时序图看读路径和写路径怎么配合:
sequenceDiagram
participant S as Service
participant R as Repository
participant RD as Redis
participant M as MySQL
Note over S,M: 读路径(GetByID)
S->>R: GetByID(ctx, 1)
R->>RD: GET scaffold:user:1
alt 缓存命中
RD-->>R: 用户 JSON
R-->>S: 直接返回(MySQL 零压力)
else 缓存未命中
R->>M: SELECT 安全字段 WHERE id=1
M-->>R: 记录
R->>RD: SET key JSON EX 600
R-->>S: 返回用户
end
Note over S,M: 写路径(UpdateByID / DeleteByID)
S->>R: UpdateByID(ctx, 1, fields)
R->>M: UPDATE users SET ...
R->>RD: DEL scaffold:user:1(写后删缓存)
R-->>S: 完成为什么写操作后"删缓存"而不是"更新缓存"? 这是缓存设计的经典问题。更新缓存有两个麻烦:并发写时两个请求互相覆盖出脏数据;而且你更新的是"缓存里的 JSON",如果还有别的字段也改了,这份 JSON 就是半成品。删缓存则简单且安全——下一次读自然回源拿最新值回填。“写后删缓存"是 cache-aside 的标准动作。
RowsAffected == 0 不能直接判"记录不存在"。看 UpdateByID 的实现:MySQL 默认语义下,UPDATE 的值和原值相同时 RowsAffected 是 0(DSN 没开 clientFoundRows)。如果直接拿它判断"记录不存在",幂等更新(提交相同值)就会被误报成 404。本项目的做法是二次确认:RowsAffected == 0 时再 COUNT 一次,真的查无此行才返回 ErrUserNotFound。// UpdateByID 更新指定用户的字段(key 为数据库列名)。
func (r *userRepo) UpdateByID(ctx context.Context, id uint64, fields map[string]any) error {
res := data.DBFrom(ctx, r.db).WithContext(ctx).Model(&model.User{}).
Where("id = ?", id).Updates(fields)
if res.Error != nil {
if errors.Is(res.Error, gorm.ErrDuplicatedKey) {
return ErrDuplicateUser
}
return res.Error
}
if res.RowsAffected == 0 {
// 值未变也会是 0,二次确认记录是否真的不存在
var count int64
if err := data.DBFrom(ctx, r.db).WithContext(ctx).Model(&model.User{}).
Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return ErrUserNotFound
}
}
// 写操作后删除缓存,下次读取时回源加载最新值
_ = r.rdb.Del(ctx, userCacheKey(id)).Err()
return nil
}
7.5 service:业务规则的唯一发生地
新建 internal/service/user_service.go。先看接口定义和结构:
// UserService 用户业务接口。
type UserService interface {
Login(ctx context.Context, username, password string) (*LoginResult, error)
CreateUser(ctx context.Context, params *CreateUserParams) (uint64, error)
GetUser(ctx context.Context, id uint64) (*UserInfo, error)
ListUsers(ctx context.Context, page, pageSize int) ([]*UserInfo, int64, error)
UpdateUser(ctx context.Context, id uint64, params *UpdateUserParams) error
DeleteUser(ctx context.Context, id uint64) error
}
// userService UserService 实现。
type userService struct {
repo repository.UserRepo
// txm 事务管理器:跨 repository 的多步写操作必须经 InTransaction 编排
txm data.TxManager
auth *jwt.Manager
log *zap.Logger
}
// NewUserService 构造用户 service,依赖均由 wire 注入。
func NewUserService(repo repository.UserRepo, txm data.TxManager, auth *jwt.Manager, log *zap.Logger) UserService {
return &userService{repo: repo, txm: txm, auth: auth, log: log}
}
注意 service 的出入参不用 handler 的 DTO,也不用 model——它有自己的一套结构(CreateUserParams、UserInfo)。三层各说各话,在边界处转换:DTO ↔ service 结构在 handler 层转换,service 结构 ↔ model 在 service 层转换。这就是"两层可独立演进"的含义。
登录——安全设计密度最高的一个方法:
// Login 校验用户名密码并签发令牌。
//
// 安全约定:用户不存在与密码错误返回同一个错误码(ErrPasswordWrong),
// 不向调用方泄露账号是否存在,防止账号枚举探测。
func (s *userService) Login(ctx context.Context, username, password string) (*LoginResult, error) {
user, err := s.repo.GetByUsername(ctx, username)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
// 与存在用户的路径跑同样的 bcrypt 开销,保证恒时——
// 否则响应耗时会泄露"用户名是否存在"(时序侧信道)
_ = bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
return nil, errcode.ErrPasswordWrong // 用户不存在也报"密码错误"
}
return nil, s.internalError(ctx, "查询用户失败", err)
}
if user.Status == model.UserStatusDisabled {
return nil, errcode.ErrUserDisabled
}
// bcrypt 比对:恒定时间比较,时序攻击也探不出密码对错
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
return nil, errcode.ErrPasswordWrong
}
token, err := s.auth.GenerateToken(user.ID, user.Username)
if err != nil {
s.log.Error("签发令牌失败", zap.Uint64("user_id", user.ID), zap.Error(err))
return nil, errcode.ErrTokenGenerate
}
return &LoginResult{Token: token, ExpiresIn: int64(s.auth.Expire().Seconds())}, nil
}
“用户不存在"和"密码错误"为什么必须返回同一个错误码? 如果分开返回,攻击者拿一个字典挨个试用户名,看响应就知道哪些账号存在——然后只对这些账号爆破密码。这叫账号枚举(account enumeration),是 OWASP 认证类漏洞的常客。对外只有一个答案"用户名或密码错误”,枚举就失效了。
创建用户——密码哈希 + 事务编排 + 错误翻译,三件事一次看全:
// CreateUser 创建用户:密码 bcrypt 哈希后落库,禁止明文存储。
//
// 事务演示:单个 INSERT 本身不需要事务,这里用 InTransaction 包一层,
// 演示跨 repository 多步写入的标准编排方式——真实场景
// (如「创建用户 + 初始化账户 + 发放新人积分」)把多个 repo 调用
// 依次放进 fn 即可,任一失败整体回滚。
func (s *userService) CreateUser(ctx context.Context, params *CreateUserParams) (uint64, error) {
// bcrypt 单向摘要 + 自带盐;DefaultCost=10,可按机器性能上调
hashed, err := bcrypt.GenerateFromPassword([]byte(params.Password), bcrypt.DefaultCost)
if err != nil {
return 0, errcode.ErrServer
}
user := &model.User{
Username: params.Username,
Password: string(hashed),
Email: params.Email,
Status: model.UserStatusEnabled,
}
// fn 内的 repo 调用经由 data.DBFrom 取到事务连接,自动进入同一事务
err = s.txm.InTransaction(ctx, func(ctx context.Context) error {
return s.repo.Create(ctx, user)
})
if err != nil {
if errors.Is(err, repository.ErrDuplicateUser) {
return 0, errcode.ErrUserDuplicate // 哨兵错误 -> 业务错误码
}
return 0, s.internalError(ctx, "创建用户失败", err)
}
return user.ID, nil
}
密码用 bcrypt 而不是 MD5/SHA256,一句话说清:MD5/SHA 是快速哈希——快是它的优点也是致命伤,攻击者拿到库后每秒能暴力试几十亿次;bcrypt 故意设计得慢(还有内置盐),同样的暴力尝试慢几个数量级。存密码,行业唯一正确答案是 bcrypt/argon2 这类"密码学慢哈希”。
最后是 service 层错误的统一出口:
// internalError 统一处理未预期的内部错误:
// 完整细节写入带 request_id 的链路日志,对外只暴露通用错误码。
func (s *userService) internalError(ctx context.Context, msg string, err error) error {
logger.FromContextOrDefault(ctx, s.log).Error(msg, zap.Error(err))
return errcode.ErrServer
}
回头看 5.5 那张"错误三级翻译"图——internalError 就是意外错误的翻译点:细节进日志(带 request_id),门面给客户端。service 对 handler 的契约因此变得极其干净:返回的 error 一定是 *errcode.ErrCode。
7.6 handler:HTTP 协议的翻译官
新建 internal/handler/user_handler.go。看两个代表性方法就够——其余 CRUD 是同样的模子:
// UserHandler 用户模块 HTTP 接口。
type UserHandler struct {
svc service.UserService // 依赖接口,不是具体实现
}
// NewUserHandler 构造 UserHandler,依赖 service 接口(面向接口编程)。
func NewUserHandler(svc service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// Create 创建用户
//
// @Summary 创建用户
// @Description 创建新用户,密码在服务端 bcrypt 哈希后落库
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateUserRequest true "创建用户请求"
// @Success 200 {object} response.Body{data=dto.CreateUserResponse}
// @Router /users [post]
func (h *UserHandler) Create(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
// 校验失败:把 validator 的英文错误翻译成用户友好文案
response.Fail(c, errcode.ErrInvalidParams.Code(), translateBindingError(err))
return
}
id, err := h.svc.CreateUser(c.Request.Context(), &service.CreateUserParams{
Username: req.Username,
Password: req.Password,
Email: req.Email,
})
if err != nil {
response.Error(c, err) // service 保证只回 *ErrCode,直接渲染
return
}
response.OK(c, dto.CreateUserResponse{ID: id})
}
// Get 用户详情
//
// @Summary 查询用户详情
// @Description 按用户 ID 查询详情(服务端 cache-aside 缓存)
// @Tags users
// @Security BearerAuth
// @Param id path int true "用户 ID" minimum(1)
// @Success 200 {object} response.Body{data=dto.UserResponse}
// @Router /users/{id} [get]
func (h *UserHandler) Get(c *gin.Context) {
id, ok := parseUintParam(c, "id")
if !ok {
return
}
info, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.OK(c, toUserResponse(info))
}
注意 handler 有多薄:绑定参数 → 调 service → 渲染响应。“handler 薄"不是偷懒,是分层的胜利——试想在 handler 里直接写 SQL 的版本:加一个接口要复制粘贴一遍连接管理;换 ORM 要改所有接口;想测"用户名重复返回 200002"这条规则,还得先起个 MySQL。
方法头上那串 // @Summary ... 注释不是普通注释——它们是 swaggo 注解,make swagger 会把它们扫描生成在线 API 文档(第九章讲)。@Security BearerAuth 表示这个接口要带 JWT。
还有两个辅助函数值得看:
// translateBindingError 把 validator 的错误翻译成对用户友好的提示,
// 例如 "username 长度或取值过小"。
func translateBindingError(err error) string {
var valErrs validator.ValidationErrors
if !errors.As(err, &valErrs) || len(valErrs) == 0 {
return errcode.ErrInvalidParams.Msg()
}
fe := valErrs[0] // 只报第一个错误,一次改一个,体验更好
var reason string
switch fe.Tag() {
case "required":
reason = "不能为空"
case "min":
reason = "长度或取值过小"
case "max":
reason = "长度或取值过大"
case "email":
reason = "格式必须为合法邮箱"
case "oneof":
reason = "取值必须为 " + fe.Param()
default:
reason = "不符合规则(" + fe.Tag() + ")"
}
return strings.ToLower(fe.Field()) + " " + reason
}
// toUserResponse service 出参转接口 DTO,保持两层结构解耦。
func toUserResponse(info *service.UserInfo) *dto.UserResponse {
return &dto.UserResponse{
ID: info.ID,
Username: info.Username,
Email: info.Email,
Status: info.Status,
CreatedAt: info.CreatedAt,
UpdatedAt: info.UpdatedAt,
}
}
为什么不把 validator 的原始错误直接返回? 原始错误形如 Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag——暴露内部结构体名和字段名,而且对终端用户完全是天书。翻译一遍,安全和体验都拿到了。
八、Wire:编译期的依赖装配车间
8.1 手工装配的问题
前面写了这么多组件,它们之间的依赖关系是:logger 依赖 config;data 依赖 config + logger;repository 依赖 data;service 依赖 repository + data + jwt;handler 依赖 service;router 依赖一切……main 函数里手工 new 一遍?:
// 反面教材:手工装配。组件少时还行,多了以后——
// 顺序错了编译不报错(运行才炸),加组件要人肉找插入点,
// 关闭顺序要自己维护一份和初始化完全相反的清单
log, cleanupLog, _ := logger.New(...)
d, cleanupData, _ := data.New(cfg, log)
auth := jwt.NewManager(...)
repo := repository.NewUserRepo(d)
svc := service.NewUserService(repo, d, auth, log)
h := handler.NewUserHandler(svc)
r := router.New(cfg, log, d, auth, h)
srv := server.New(cfg, r, log)
对应到工程里就是:你只声明"有哪些构造函数(provider)",Wire 在编译期分析类型依赖,生成
wire_gen.go——顺序自动拓扑排序,依赖缺失直接编译失败,cleanup 自动按初始化逆序串好。8.2 wire.go:只写"清单”,不写"顺序"
新建 cmd/server/wire.go:
//go:build wireinject
// +build wireinject
package main
// wireApp 依赖注入入口:wire 在编译期生成 wire_gen.go 中的真实实现。
// 依赖链:Config -> Logger/JWT -> Data -> UserRepo -> UserService
// -> UserHandler -> Router(Engine) -> Server
func wireApp(cfg *config.Config) (*server.Server, func(), error) {
panic(wire.Build(
newLogger, // providers.go 里:config -> zap.Logger
newJWTManager, // providers.go 里:config -> jwt.Manager
data.New, // config + logger -> Data(带 cleanup)
repository.NewUserRepo, // Data -> UserRepo
service.NewUserService, // repo + TxManager + jwt + logger -> UserService
handler.NewUserHandler, // UserService -> UserHandler
router.New, // 一切 -> gin.Engine
server.New, // engine + config + logger -> Server
// 两个"类型绑定":告诉 wire 具体类型满足哪个接口
wire.Bind(new(http.Handler), new(*gin.Engine)), // gin.Engine 当 http.Handler 用
wire.Bind(new(data.TxManager), new(*data.Data)), // Data 当 TxManager 用
))
}
这个函数永远不会被执行(注意 panic 和 wireinject 构建标签)——它只是写给 Wire 工具看的声明。wire.Bind 那两行值得解释:service.NewUserService 的第二个参数要的是 data.TxManager 接口,而你提供的 provider data.New 返回的是 *data.Data 具体类型——wire.Bind 告诉 Wire:"*data.Data 实现了 data.TxManager,可以直接用"。
providers.go 里的两个小适配函数也看一眼(它们的存在是为了守住"pkg 不依赖 internal"的边界):
// newLogger 把应用配置转换为 pkg/logger 的独立配置并构建日志实例。
// pkg 包不反向依赖 internal/config,保持基础库的可复用性。
func newLogger(cfg *config.Config) (*zap.Logger, func(), error) {
return logger.New(logger.Config{
Level: cfg.Log.Level,
Dir: cfg.Log.Dir,
Filename: cfg.Log.Filename,
MaxSizeMB: cfg.Log.MaxSize,
MaxBackups: cfg.Log.MaxBackups,
MaxAgeDays: cfg.Log.MaxAge,
Compress: cfg.Log.Compress,
})
}
// newJWTManager 从配置构建 JWT 管理器。
func newJWTManager(cfg *config.Config) *jwt.Manager {
return jwt.NewManager(cfg.JWT.Secret, cfg.JWT.Issuer, cfg.JWT.Expire)
}
8.3 wire_gen.go:生成的装配代码长什么样
执行 make wire(等价于 go tool wire ./cmd/server),Wire 生成 cmd/server/wire_gen.go:
// Code generated by Wire. DO NOT EDIT.
//go:build !wireinject
package main
func wireApp(cfg *config.Config) (*server.Server, func(), error) {
logger, cleanup, err := newLogger(cfg)
if err != nil {
return nil, nil, err
}
dataData, cleanup2, err := data.New(cfg, logger)
if err != nil {
cleanup() // 失败回滚:data 起不来,把已启动的 logger 清掉
return nil, nil, err
}
manager, err := newJWTManager(cfg)
if err != nil {
cleanup2() // jwt 构造失败(如密钥过短)也要逆序回滚前两个
cleanup()
return nil, nil, err
}
userRepo := repository.NewUserRepo(dataData)
userService := service.NewUserService(userRepo, dataData, manager, logger)
userHandler := handler.NewUserHandler(userService)
engine, cleanup3 := router.New(cfg, logger, dataData, manager, userHandler)
serverServer := server.New(cfg, engine, logger)
return serverServer, func() {
cleanup3() // 先关 router 层后台资源(限流清理 goroutine)
cleanup2() // 再关数据层(MySQL + Redis)
cleanup() // 最后刷日志——日志必须活到最后一刻
}, nil
}
读这段生成代码,Wire 的三个价值全部可见:
- 顺序自动拓扑排序:你只给了清单,它算出了
logger → data → ... → server的顺序。组件加到 20 个,这行代码也不用你操心。 - 失败自动回滚:
data.New失败时先调cleanup()(关掉 logger)再返回——不留半开的资源。 - cleanup 自动逆序:返回的闭包里
cleanup2(数据层)先于cleanup(日志)执行。日志活到最后一刻,因为前面所有关闭动作的日志都靠它落盘。
画一张依赖全景图收束本章:
flowchart LR
CFG[config.Config] --> LOG[zap.Logger
cleanup: Sync]
CFG --> JWT[jwt.Manager]
CFG --> DATA[data.Data
cleanup: 关 MySQL/Redis]
LOG --> DATA
DATA --> REPO[UserRepo]
REPO --> SVC[UserService]
DATA -.->|wire.Bind
TxManager| SVC
JWT --> SVC
LOG --> SVC
SVC --> HDL[UserHandler]
HDL --> ROUTER[gin.Engine]
CFG --> ROUTER
ROUTER -->|wire.Bind
http.Handler| SRV[server.Server]
CFG --> SRV
LOG --> SRV
style DATA fill:#fff3cd,stroke:#e17055
style SRV fill:#e6f9f0,stroke:#00b894go build,用的还是旧的 wire_gen.go——要么编译报"缺少 provider",要么更隐蔽:编译过了但装配的还是旧逻辑。纪律:动过 wire.go 或任何 provider 签名,立刻 make wire。CI 里可以加一步 wire diff 或重新生成后 git diff --exit-code 检查来兜底。九、路由、JWT 与健康检查
9.1 router.New:把所有零件拼成一台整机
新建 internal/router/router.go,它是"总装车间":
// New 构建 gin 引擎并完成全部路由注册。依赖均由 wire 注入。
// 返回的销毁函数用于释放中间件持有的后台资源(如限流器的清理 goroutine)。
func New(cfg *config.Config, log *zap.Logger, data *data.Data, auth *jwt.Manager,
userHandler *handler.UserHandler) (*gin.Engine, func()) {
// 接管 gin 自身输出([GIN-debug] 路由表、[WARNING] 等)统一交给 zap,
// 必须在 gin.New() 之前调用,否则抓不到启动期的路由表打印
logger.RedirectGinLog(log)
// 生产默认 ReleaseMode;显式设置 GIN_MODE 时尊重环境变量
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New() // 注意是 New 不是 Default:Default 自带 gin.Logger/Recovery,我们用自己的
// 可信代理:gin 默认信任所有代理头,X-Forwarded-For 可被客户端伪造,
// 导致按 IP 的限流被轻易绕过。空列表表示不信任任何代理,
// ClientIP 回退为 TCP 直连地址。CIDR 合法性已在配置加载时校验(见 2.4)。
if err := r.SetTrustedProxies(cfg.HTTP.TrustedProxies); err != nil {
panic(fmt.Sprintf("设置可信代理失败: %v", err)) // 理论上不可达
}
// 中间件顺序即第六章讲的那条流水线
rateLimit, closeRateLimit := middleware.NewRateLimit(cfg.RateLimit.RPS, cfg.RateLimit.Burst)
r.Use(
middleware.NewRecovery(log),
middleware.NewRequestID(log),
middleware.NewAccessLog(middleware.AccessLogConfig{
SlowThreshold: cfg.Log.SlowThreshold,
SkipPaths: cfg.Log.SkipPaths,
}),
middleware.NewCORS(cfg.CORS.AllowOrigins),
rateLimit,
)
registerHealth(r, data)
registerAPIs(r, auth, userHandler)
// Swagger 文档:/swagger/index.html,建议生产环境按需关闭
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
return r, closeRateLimit
}
开头那行 logger.RedirectGinLog(log) 是日志章讲过的 gin 输出接管在这里落地:必须在 gin.New() 之前调用——gin 在构建引擎的过程中就会通过 gin.DefaultWriter 打印路由表和告警,接管晚了启动期的输出就抓不到了。函数返回值也从单个引擎变成了 (engine, cleanup) 二元组——限流器的后台 goroutine 有了正规的销毁出口,第八章会看到 wire 自动把它编进关停链。
SetTrustedProxies 为什么必须显式设? gin 判断客户端 IP 的逻辑:如果请求来自"信任的代理",才去读 X-Forwarded-For 头。默认信任所有代理意味着:攻击者直接连你的服务,伪造头 X-Forwarded-For: 8.8.8.8,c.ClientIP() 返回的就是 8.8.8.8——按 IP 的限流、风控、审计全部失真(我们第六章刚装的令牌桶当场报废)。所以默认一个都不信(ClientIP 用 TCP 对端地址,伪造不了),确有可信 LB 时才在配置里放开。
9.2 健康检查:healthz 和 readyz 为什么是两个
// registerHealth 注册健康检查探针:
// /healthz 存活探针(进程活着即返回成功),
// /readyz 就绪探针(依赖的 MySQL / Redis 均可用才返回成功)。
func registerHealth(r *gin.Engine, data *data.Data) {
r.GET("/healthz", func(c *gin.Context) {
response.OK(c, gin.H{"status": "ok"})
})
r.GET("/readyz", func(c *gin.Context) {
if err := data.Ping(c.Request.Context()); err != nil {
// 就绪探针失败必须返回非 2xx:K8s 等编排系统只认 HTTP 状态码,
// 若沿用统一响应格式(200 + 业务码),依赖挂掉时探针仍判定 ready,
// 滚动发布会把流量打向坏实例
c.JSON(http.StatusServiceUnavailable, response.Body{
Code: errcode.ErrServer.Code(),
Msg: "依赖不可用",
})
return
}
response.OK(c, gin.H{"status": "ready"})
})
}
Kubernetes 里两者语义完全不同:healthz(存活探针)失败 → 容器重启;readyz(就绪探针)失败 → 只是把 Pod 从 Service 摘除、不重启。数据库挂了,进程本身没坏,重启解决不了任何问题——正确动作是"先别给我流量,等我缓过来"。如果只用一个探针且挂在 DB 上,DB 抖 10 秒,所有副本被轮流重启一遍,雪上加霜。
这就是第五章留下的那个"例外"的兑现时刻:readyz 失败必须返回 HTTP 503,不能走"200 + 业务码"——K8s 只认 HTTP 状态码,不会解析你的响应体。统一响应格式服务的是"业务调用方",探针服务的是"编排系统",两套受众两套契约。
9.3 JWT:签发与验签
先看令牌工具本体,pkg/jwt/jwt.go:
// Claims 自定义载荷,业务侧只透出必要的身份字段。
type Claims struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
// Manager 令牌管理器,由依赖注入构造,全应用共享一个实例。
type Manager struct {
secret []byte
issuer string
expire time.Duration
}
// NewManager 构造 Manager。secret 长度不少于 32 字节(生产环境要求),
// 不满足时返回 ErrSecretTooShort,由配置校验层在启动期拦截——
// 弱密钥是"带病上线",宁可启动失败也不能靠注释约束。
func NewManager(secret, issuer string, expire time.Duration) (*Manager, error) {
if len(secret) < 32 {
return nil, ErrSecretTooShort
}
return &Manager{secret: []byte(secret), issuer: issuer, expire: expire}, nil
}
// GenerateToken 为指定用户签发 access token。
func (m *Manager) GenerateToken(userID uint64, username string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: m.issuer,
Subject: username,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(m.expire)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
}
// ParseToken 校验并解析令牌,返回业务载荷。
func (m *Manager) ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
// 锁死签名算法,防止算法替换攻击(如把 HS256 的 token 当 RS256 验证)
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrTokenInvalid
}
return m.secret, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrTokenInvalid
}
if !token.Valid {
return nil, ErrTokenInvalid
}
return claims, nil
}
t.Method? 这是防算法混淆攻击(algorithm confusion)。历史上真实的漏洞:服务端用 RS256(非对称),攻击者把 token 头的算法改成 HS256,然后用服务器的公钥(公开的!)当 HMAC 密钥伪造签名——服务端不校验算法的话照单全收。锁死"只接受 HMAC 家族",这条路就断了。验签代码里写死预期算法,是 JWT 使用的铁律。9.4 鉴权中间件与路由分组
internal/middleware/jwt.go——把令牌校验做成中间件,按路由组挂载:
// NewAuth JWT 鉴权中间件:校验 Authorization: Bearer <token>,
// 解析通过后把用户身份写入 gin.Context,供 handler 取用。
func NewAuth(mgr *jwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
response.Fail(c, errcode.ErrUnauthorized.Code(), errcode.ErrUnauthorized.Msg())
c.Abort() // 关键:不放行,后面的 handler 不会执行
return
}
claims, err := mgr.ParseToken(strings.TrimPrefix(authHeader, "Bearer "))
if err != nil {
// 令牌过期与非法统一返回未授权,不向调用方暴露具体校验细节
response.Fail(c, errcode.ErrUnauthorized.Code(), errcode.ErrUnauthorized.Msg())
c.Abort()
return
}
c.Set("context_user_id", claims.UserID) // 身份落进 gin.Context
c.Set("context_username", claims.Username)
c.Next()
}
}
最后在 router 里把公开路由和受保护路由分开(registerAPIs):
func registerAPIs(r *gin.Engine, auth *jwt.Manager, userHandler *handler.UserHandler) {
apiV1 := r.Group("/api/v1")
// 公开路由:登录(不登录怎么拿令牌呢)
authGroup := apiV1.Group("/auth")
{
authGroup.POST("/login", userHandler.Login)
}
// 受保护路由:用户 CRUD,整组挂载 JWT 鉴权
users := apiV1.Group("/users")
users.Use(middleware.NewAuth(auth))
{
users.POST("", userHandler.Create)
users.GET("", userHandler.List)
users.GET("/:id", userHandler.Get)
users.PUT("/:id", userHandler.Update)
users.DELETE("/:id", userHandler.Delete)
}
}
users.Use(...) 的作用域是这个写法的精髓:gin 的 Use 只影响该分组的路由——/api/v1/auth/login 保持公开,/api/v1/users/* 全部要令牌。新加受保护接口时往 users 组里扔就行,鉴权自动生效,想漏都漏不掉。
c.Abort() 和 return 的区别也值得记:return 只是退出当前中间件函数,请求还会继续流向后续 handler;c.Abort() 才是掐断整条链。鉴权失败只 return 不 Abort,等于门卫拦了人又放行。
9.5 Swagger:注解即文档
handler 头上那些 @Summary 注解,跑一条命令就变成在线文档:
make swagger # 等价于 go tool swag init -g cmd/server/main.go -o api/swagger
启动后访问 http://localhost:8080/swagger/index.html,所有接口的参数、响应、鉴权要求都在页面上,还能直接点"Try it out"发请求调试。文档与代码同步腐化是绝症,注解把文档钉在代码旁边,改接口顺手改注解,腐化速度至少慢一个数量级。生产环境建议用 nginx 鉴权或干脆不注册这条路由。
十、启动与优雅关闭:最后一公里
10.1 启动顺序 = 依赖顺序
对应到工程里就是:启动按依赖序(Wire 已经帮我们算好了),关闭严格逆序(LIFO)。顺序反了会怎样?在途请求还握着数据库连接,你先关数据库,它们全部报错——用户看到的就是一波 100001。
flowchart TB
subgraph 启动["启动 · 按依赖顺序(wire_gen.go)"]
A1[Config 配置] --> A2[Logger 日志]
A2 --> A3[Data:MySQL + Redis]
A3 --> A4[HTTP Server 开门迎客]
end
subgraph 关闭["关闭 · 严格逆序"]
B4[Server.Stop
排空在途请求 30s 预算] --> B3[cleanup2
关 MySQL/Redis]
B3 --> B2[cleanup
Logger Sync 刷盘]
end
A4 -.->|收到 SIGINT/SIGTERM| B4
style A4 fill:#e6f9f0,stroke:#00b894
style B4 fill:#e6f9f0,stroke:#00b89410.2 server.go:HTTP 服务的封装
新建 internal/server/http.go。把 http.Server 包一层,是为了把"四个超时"和"pprof 调试口"这两件容易忘的事固化进结构里:
// Server 业务 HTTP 服务器。
type Server struct {
httpSrv *http.Server
debugSrv *http.Server // pprof 调试服务,可选
log *zap.Logger
}
// New 构建服务器。所有超时参数显式来自配置,杜绝慢连接资源耗尽。
func New(cfg *config.Config, engine http.Handler, log *zap.Logger) *Server {
s := &Server{
log: log,
httpSrv: &http.Server{
Addr: cfg.HTTP.Addr,
Handler: engine,
ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout, // 只读 header 的上限,最先生效
ReadTimeout: cfg.HTTP.ReadTimeout, // 读完整请求(含 body)的上限
WriteTimeout: cfg.HTTP.WriteTimeout, // 写响应上限
IdleTimeout: cfg.HTTP.IdleTimeout, // Keep-Alive 空闲连接上限
},
}
// pprof 独立端口且只监听回环地址,避免在生产环境对外暴露
if cfg.App.DebugPort > 0 {
s.debugSrv = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", cfg.App.DebugPort),
Handler: http.DefaultServeMux,
}
}
return s
}
四个超时挡的是慢连接攻击(slowloris 家族):攻击者每秒发一个字节慢慢拖,不设超时的话每个慢连接都占住一个协程,几千个并发就能把服务拖死。ReadHeaderTimeout 最先生效也最关键——握手完就该赶紧把 header 交出来。
pprof 为什么独立端口且只听 127.0.0.1? pprof 是性能剖析接口(CPU/堆/goroutine 火焰图全靠它),但它会暴露进程内部状态,挂到业务端口等于把体检报告贴大街上。独立端口 + 回环地址,运维排查时 ssh 上去 go tool pprof http://127.0.0.1:6060/debug/pprof/heap 即可。
Run 和 Stop 是一对:
// Run 启动服务并阻塞监听。
// debug 服务异常只记日志不影响主服务;主服务异常通过 error 返回。
func (s *Server) Run() error {
if s.debugSrv != nil {
go func() {
s.log.Info("pprof 调试服务已启动", zap.String("addr", s.debugSrv.Addr))
if err := s.debugSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("pprof 调试服务异常退出", zap.Error(err))
}
}()
}
s.log.Info("HTTP 服务已启动", zap.String("addr", s.httpSrv.Addr))
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("HTTP 服务异常退出: %w", err)
}
return nil
}
// Stop 优雅关停:停止接收新请求,等待存量请求处理完成(超时由调用方控制),
// 之后由 main 中的 cleanup 链依次关闭 MySQL、Redis 与日志。
func (s *Server) Stop(ctx context.Context) error {
if s.debugSrv != nil {
if err := s.debugSrv.Shutdown(ctx); err != nil {
s.log.Warn("pprof 调试服务关停异常", zap.Error(err))
}
}
if err := s.httpSrv.Shutdown(ctx); err != nil {
return fmt.Errorf("HTTP 服务优雅关停失败: %w", err)
}
s.log.Info("HTTP 服务已优雅关停")
return nil
}
ErrServerClosed 为什么不算错误? Shutdown 主动关停后,ListenAndServe 的返回值就是这个哨兵错误——它是"正常下班"的信号,不是故障。判断一下把它排除,否则每次优雅关闭都会在日志里留一条吓人的 error。
10.3 main.go:信号处理与优雅关闭
最后一块拼图,cmd/server/main.go:
// gracefulShutdownTimeout 优雅关停最长等待时间:
// 超过后强制断开存量请求,避免关停流程被单个慢请求卡死。
const gracefulShutdownTimeout = 30 * time.Second
func main() {
// 步骤 1:解析启动参数
var configPath string
flag.StringVar(&configPath, "config", "configs/config.yaml", "配置文件路径")
flag.Parse()
// 步骤 2:加载配置。此时日志还没初始化,失败只能打 stderr
cfg, err := config.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
// 步骤 3:Wire 装配全部依赖(生成的代码在 wire_gen.go)
srv, cleanup, err := wireApp(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "初始化依赖失败: %v\n", err)
os.Exit(1)
}
// 步骤 4:监听退出信号。SIGINT=Ctrl+C,SIGTERM=kill/K8s 滚动更新
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 步骤 5:服务在独立协程里跑,结果通过 channel 回传
runErr := make(chan error, 1)
go func() {
runErr <- srv.Run()
}()
// 步骤 6:阻塞在 select 上——服务异常退出和收到信号,哪个先到来处理哪个
select {
case err := <-runErr:
if err != nil {
fmt.Fprintf(os.Stderr, "服务异常退出: %v\n", err)
cleanup()
os.Exit(1)
}
return // ErrServerClosed 正常退出,直接走 cleanup
case <-ctx.Done():
// 收到退出信号,继续往下走优雅关闭
}
// 步骤 7:给优雅关闭一个总预算(30s)。
// Shutdown 会:停止接新请求 + 等在途请求处理完。
// 超时说明仍有请求没处理完;此后 cleanup 关掉连接,
// 那批请求会拿到错误——"宁可截断不可挂死",主流取舍
shutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownTimeout)
defer cancel()
if err := srv.Stop(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "优雅关停异常: %v\n", err)
}
cleanup() // 最后逆序关闭:先关 MySQL/Redis,再 Sync 日志
}
几个零基础容易懵的点:
signal.NotifyContext是什么? 它在收到指定信号时自动取消返回的 ctx(ctx.Done()关闭)。比起自己写 channel +signal.Notify,它把"注册信号监听"和"context 取消"合成了一步,defer stop()还顺手处理了反注册。select里为什么有两个 case? 进程退出有两条路径:正常(收到信号,走优雅关闭)和异常(端口被占,ListenAndServe立刻报错返回)。select同时等两个消息源,哪条先触发走哪条——异常路径也要cleanup(),不然日志缓冲区里最后的错误就丢了。runErr的缓冲为什么是 1?make(chan error, 1)带缓冲,srv.Run()返回时发送不会阻塞——即使主协程此刻正等在信号分支上,goroutine 也能写完就退出,不泄漏。
kill -9 / 不处理信号。没有优雅关闭时,Ctrl+C 一按进程立刻死:在途请求全部断连(用户看到连接重置)、数据库连接不释放(MySQL 侧堆积到 wait_timeout 才清)、日志 buffer 里最后几条记录直接丢。K8s 滚动更新时每次发版都制造一波失败。优雅关闭是生产服务的底线要求,不是锦上添花。另外记住 kill -9(SIGKILL)谁都拦不住——操作系统直接收尸,所以容器编排系统总是先发 SIGTERM、等一段时间才 SIGKILL,你的优雅关闭代码就活在这个窗口里。十一、跑起来:从依赖到联调
所有零件都造完了,现在把它们装上电、点火、试跑。这一章走完"一键启动 → 全链路调用 → 优雅关闭"的最后一程,也是对前十章成果的现场验收。
11.1 一键启动
项目带齐了 Makefile 和 docker-compose,依赖(MySQL 8.4 + Redis 7)和应用一条命令全起:
# 方式一:全套容器化(MySQL + Redis + 应用)
make compose-up
# 方式二:依赖用容器,应用本地跑(开发调试推荐)
docker compose up -d mysql redis
make run # 等价于 go run ./cmd/server
docker-compose 里有个细节值得学:app 服务通过 depends_on + condition: service_healthy 等 MySQL/Redis 健康检查通过后才启动——不是"容器起了"就算,是"mysqladmin ping 通了"才算。配合 APP_MYSQL_DSN 等环境变量把地址指向容器网络,整套就是第二章配置覆盖机制的实战演示。
Makefile 里的常用命令:
| 命令 | 作用 |
|---|---|
make run | 本地启动(默认读 configs/config.yaml) |
make build | 编译二进制到 bin/(-ldflags "-w -s" 去符号表瘦身) |
make test | 跑全部单测(含 -race 竞态检测) |
make wire | 改完 provider 后重新生成 wire_gen.go |
make swagger | 改完注解后重新生成 API 文档 |
make mock | 用 mockgen 重新生成 repository 的 mock |
make compose-up / compose-down | 一键起/停全套容器 |
11.2 走一遍完整链路
服务起来后,另开一个终端,把整条"登录 → 鉴权 → CRUD"链路走通:
# 步骤 1:登录拿令牌(初始账号密码见 scripts/init.sql 的种子数据)
TOKEN=$(curl -s -X POST localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' | jq -r .data.access_token)
# → {"code":0,"msg":"success","data":{"access_token":"eyJhbGci...","expires_in":86400}}
# 步骤 2:带令牌创建用户
curl -X POST localhost:8080/api/v1/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123","email":"alice@example.com"}'
# → {"code":0,"msg":"success","data":{"id":2}}
# 步骤 3:查详情(第一遍回源 MySQL,第二遍命中 Redis——看日志里有没有 SQL 就知道)
curl localhost:8080/api/v1/users/2 -H "Authorization: Bearer $TOKEN"
# → {"code":0,"msg":"success","data":{"id":2,"username":"alice","email":"alice@example.com",...}}
# 注意:没有 password 字段——DTO 从结构上消灭了泄漏可能
# 步骤 4:不带令牌访问受保护接口(被 JWT 中间件拦下)
curl localhost:8080/api/v1/users/2
# → {"code":100004,"msg":"未授权或登录已过期","data":null}
# 步骤 5:重复创建同名用户(唯一索引兜底 + 三级错误翻译)
curl -X POST localhost:8080/api/v1/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"password123","email":"alice@example.com"}'
# → {"code":200002,"msg":"用户名已存在","data":null}
# 步骤 6:探针与文档
curl localhost:8080/healthz # {"code":0,"msg":"success","data":{"status":"ok"}}
curl localhost:8080/readyz # 依赖正常时 {"code":0,...,"data":{"status":"ready"}}
# 浏览器打开 http://localhost:8080/swagger/index.html
最后按 Ctrl+C,观察日志里的逆序关闭:
HTTP 服务已优雅关停 <- 排空在途请求(最多等 30s)
(MySQL/Redis 连接关闭) <- cleanup2
(日志 Sync 刷盘,进程退出)<- cleanup
十二、自测题与动手练习
动手练习(建议真做一遍):
- 加一个"修改密码"接口:
PUT /api/v1/users/:id/password,请求体带旧密码和新密码。完整走一遍四层:dto 定义校验规则 → handler 绑定并调用 → service 校验旧密码(bcrypt.Compare)+ 哈希新密码 → repository 更新。提示:改完密码记得删 Redis 缓存;新密码同样受 bcrypt 的 72 字节上限约束。 - 观察缓存三防护的实际效果:恶意请求拿不存在的 ID 反复调用
GET /users/:id,对比日志——第一发回源 MySQL,之后 30 秒内的重复请求全被空值占位挡住(缓存穿透防护);再开 50 个并发同时打同一个刚过期的用户 ID,观察日志里回源 SQL 只出现一次(singleflight 合并)。然后停掉 Redis 容器再调接口,确认服务不挂、只是每次回源(缓存降级)。 - 破坏性实验(加深理解用):把
config.yaml的jwt.expire改成-1h,观察启动第一秒就报错(fail fast);把jwt.secret改成 20 字节,观察 NewManager 拒绝构造(弱密钥拦截);把 router 里NewRequestID和NewAccessLog的顺序对调,观察访问日志里request_id消失。
本章小结
- 这个脚手架的全部设计可以压缩成一句话:让每一类问题只有一个发生地、只有一个解决地——配置错误死在启动时(fail fast)、错误翻译只发生在 service、响应格式只从 response 出、事务边界只在 service 开、依赖装配只在 wire.go 声明。
- 零基础最容易忽略的四个"看不见的工程点":配置强校验(缺项必须报错)、唯一索引兜底(应用层检查防不住并发)、缓存降级与三防护(穿透/击穿/故障各有一层对策)、优雅关闭(不处理信号的服务在生产上等于随时断连)。
- Wire + cleanup 逆序是"组件会越来越多"这个必然变化的答案:加 Kafka 也好、加 ES 也好,永远是"写一个 provider + 声明进 wire.go"两步,装配顺序、失败回滚、关闭逆序全自动。
- 下一篇我们可以在这个骨架上继续长肉:分布式限流(Redis + Lua)、刷新令牌与令牌黑名单、读写分离——它们全部以"新增 provider + 注册路由"的方式接入,不动任何已有代码。