上一篇《Go Web 实战》里,我带你从零搭起了一个能跑通 CRUD 的 Todo 服务。
但「能跑」和「能上线」,中间隔着一道鸿沟。上一版的服务,其实浑身都是「demo 味」:
- 数据库账号密码写死在代码里,换个环境就得改代码、重新编译;
- 日志全靠
fmt.Println,一出问题不知道是谁、什么时候、在哪台机器出的;- 出错直接
panic或者返回一堆裸英文,接口返回格式五花八门,前端想接都接不了;- handler 里
panic一下,整个服务直接挂掉,还看不到堆栈。这三个问题,恰好对应生产级服务的三块「地基」:
配置(怎么灵活部署)→ 日志(怎么可观测)→ 错误处理中间件(怎么不崩、怎么统一)
这一篇,我们给上一篇的服务「穿上铠甲」:接入 viper 配置 、zap 日志 、统一的错误处理中间件。做完,你的服务就从一个「玩具」进化成了一个「能扛事的工程」。
老规矩,先上图,再讲理。
一、配置:用 viper 让服务「可部署」
1.1 为什么不能写死
上一篇里,配置是这么干的:
go
func Load() *Config {
return &Config{
MySQLDSN: "user:pass@tcp(127.0.0.1:3306)/todo?...", // ❌ 写死在代码里
Port: ":8080",
}
}
这样写的后果:开发环境、测试环境、生产环境的配置都不一样(数据库地址、端口、日志级别全不同),你没法「一份代码到处跑」------每换个环境就得改代码重新编译,这是生产大忌。
正确的做法是**「配置与代码分离」**:把会变的东西抽出来,放到配置文件或环境变量里,程序启动时读进来。
1.2 viper:Go 最主流的配置库
viper(spf13 出品,就是写 cobra 的那位)是 Go 里最流行的配置库,它支持:
- 读 JSON / YAML / TOML / 环境变量 / 远程配置中心(如 etcd、Consul);
- 配置热加载(文件改了自动重新读);
- 读取时按层级覆盖(默认值 < 配置文件 < 环境变量)。
一个典型的 config.yaml:
yaml
server:
port: 8080
mode: release # debug / release
mysql:
dsn: "user:pass@tcp(127.0.0.1:3306)/todo?charset=utf8mb4&parseTime=True"
log:
level: info # debug / info / warn / error
file: "./logs/app.log"
用 viper 读它:
go
// config/config.go
package config
import (
"github.com/spf13/viper"
)
type Config struct {
Server struct {
Port string
Mode string
}
MySQL struct {
DSN string
}
Log struct {
Level string
File string
}
}
var Cfg *Config
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path) // 指定配置文件
v.SetConfigType("yaml")
// 也支持读环境变量:例如 MYSQL_DSN 会覆盖配置文件里的 mysql.dsn
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil { // 读文件
return nil, err
}
var c Config
if err := v.Unmarshal(&c); err != nil { // 反序列化到结构体
return nil, err
}
Cfg = &c
return &c, nil
}
🎯 一句话:配置的核心是**「代码只管逻辑,会变的东西全丢到配置文件/环境变量里」**。viper 帮你读文件、读环境变量、还能热加载,是 Go 生态里配置这块的「标准答案」。
1.3 典型实践:按环境区分配置
真实项目常用「多配置文件 + 环境变量切换」:
text
config/
├── config.yaml # 公共配置
├── config.dev.yaml # 开发环境
├── config.test.yaml # 测试环境
└── config.prod.yaml # 生产环境
启动时通过环境变量指定用哪份:
bash
APP_ENV=prod go run main.go # 读 config.prod.yaml
这样,开发、测试、生产三个环境,代码完全一样,只换配置文件。
二、日志:用 zap 让服务「可观测」
2.1 为什么 fmt.Println 不够
出问题排查时,你需要的日志不是「你好呀」,而是结构化的、能被机器检索的信息:
- 谁(用户 ID、traceID)
- 什么时候(精确时间戳)
- 在哪(哪个文件、哪一行)
- 干了什么(日志级别、消息)
- 结果如何(错误信息、耗时)
fmt.Println 输出的是纯文本 ,没法按字段检索、没法统计、高并发下还容易串行阻塞 、性能差。生产环境的主流选择是结构化日志------zap。
2.2 zap:高性能结构化日志
zap (Uber 出品)以性能著称,特点是「零内存分配、结构化字段」。它有两种模式:
- zap.Logger :高性能,但必须手动
defer logger.Sync()刷缓冲; - zap.SugaredLogger :语法糖更甜(支持
Infof格式化),性能略降但大多数场景够用。
一个初始化:
go
// logger/logger.go
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Log *zap.Logger
func Init(level, file string) {
// ① 配置日志级别
lvl := zapcore.InfoLevel
_ = lvl.UnmarshalText([]byte(level)) // 把 "debug"/"info" 转成级别
// ② 编码器:生产用 JSON,开发用 Console(人类可读)
encoderConfig := zap.NewProductionEncoderConfig()
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder // 时间格式
encoder := zapcore.NewJSONEncoder(encoderConfig)
// ③ 输出:同时写文件 + 控制台
// (这里简化为只写 stdout,真实项目可用 zapcore.NewMultiWriteSyncer 多路输出)
core := zapcore.NewCore(encoder, zapcore.AddSync(fileWriter), lvl)
Log = zap.New(core, zap.AddCaller()) // AddCaller 记录文件名+行号
defer Log.Sync() // 退出前刷缓冲
}
用起来长这样:
go
logger.Log.Info("创建 todo 成功",
zap.Int64("todo_id", t.ID),
zap.String("title", t.Title),
)
logger.Log.Error("数据库查询失败",
zap.Error(err),
zap.String("sql", "SELECT * FROM todos WHERE id = ?"),
)
输出的 JSON 长这样(结构化、可检索):
json
{
"level": "info",
"ts": "2026-08-31T10:30:00.123+0800",
"caller": "handler/todo_handler.go:42",
"msg": "创建 todo 成功",
"todo_id": 1,
"title": "写博客"
}
🎯 一句话 :
fmt.Println是「给人看」的,zap 是「给人和机器一起看」的------JSON 结构化、带级别、带文件行号、高性能零分配。生产环境,日志一定要结构化,否则海量日志里你根本捞不到那条关键线索。
2.3 日志中间件:每个请求都记一笔
光在业务里打日志不够,还要有**「请求级日志」------每个 HTTP 请求进来,自动记下「方法、路径、状态码、耗时」。这靠中间件(middleware)**实现:
go
// middleware/logger.go
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Logger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next() // 先执行后面的 handler
// 请求结束后,记录本次请求
log.Info("请求日志",
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.Int("status", c.Writer.Status()),
zap.Duration("cost", time.Since(start)), // 耗时
)
}
}
Handler 日志中间件 客户端 Handler 日志中间件 客户端 #mermaid-svg-c3jCZJAEk4SGhWKN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c3jCZJAEk4SGhWKN .error-icon{fill:#552222;}#mermaid-svg-c3jCZJAEk4SGhWKN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c3jCZJAEk4SGhWKN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c3jCZJAEk4SGhWKN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c3jCZJAEk4SGhWKN .marker.cross{stroke:#333333;}#mermaid-svg-c3jCZJAEk4SGhWKN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c3jCZJAEk4SGhWKN p{margin:0;}#mermaid-svg-c3jCZJAEk4SGhWKN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c3jCZJAEk4SGhWKN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-c3jCZJAEk4SGhWKN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-c3jCZJAEk4SGhWKN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-c3jCZJAEk4SGhWKN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-c3jCZJAEk4SGhWKN .sequenceNumber{fill:white;}#mermaid-svg-c3jCZJAEk4SGhWKN #sequencenumber{fill:#333;}#mermaid-svg-c3jCZJAEk4SGhWKN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-c3jCZJAEk4SGhWKN .messageText{fill:#333;stroke:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c3jCZJAEk4SGhWKN .labelText,#mermaid-svg-c3jCZJAEk4SGhWKN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .loopText,#mermaid-svg-c3jCZJAEk4SGhWKN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-c3jCZJAEk4SGhWKN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-c3jCZJAEk4SGhWKN .noteText,#mermaid-svg-c3jCZJAEk4SGhWKN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-c3jCZJAEk4SGhWKN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c3jCZJAEk4SGhWKN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c3jCZJAEk4SGhWKN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-c3jCZJAEk4SGhWKN .actorPopupMenu{position:absolute;}#mermaid-svg-c3jCZJAEk4SGhWKN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-c3jCZJAEk4SGhWKN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-c3jCZJAEk4SGhWKN .actor-man circle,#mermaid-svg-c3jCZJAEk4SGhWKN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-c3jCZJAEk4SGhWKN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求进来记录 start 时间c.Next() 交给 Handler处理完返回记录 status + 耗时返回响应
⚠️ 中间件的核心是
c.Next():它把控制权「让」给后面的 handler,等 handler 跑完再回到中间件继续执行。所以你能在请求前做「计时」,请求后做「记录」,完美包裹住一次请求。
三、错误处理中间件:让服务「不崩、还统一」
3.1 两个问题:会崩 + 不统一
上一篇的错误处理有两个毛病:
panic会崩 :handler 里一旦 panic,整个进程挂掉(Gin 默认自带Recovery中间件能拦住 panic,但只会返回 500,不记录日志、不友好);- 返回格式不统一 :有的返回
{"error": "..."},有的返回裸字符串,前端没法统一处理。
生产级做法是**「统一错误码 + 统一响应结构 + Recovery 中间件」**三件套。
3.2 统一响应结构
先定一个「所有接口都长这样」的响应结构:
go
// pkg/response/response.go
package response
type Body struct {
Code int `json:"code"` // 业务码(0=成功)
Message string `json:"message"` // 提示信息
Data interface{} `json:"data,omitempty"` // 数据
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Body{Code: 0, Message: "success", Data: data})
}
func Fail(c *gin.Context, code int, msg string) {
c.JSON(http.StatusOK, Body{Code: code, Message: msg})
}
这样,前端只要判断 code == 0 就是成功,否则读 message 展示错误。所有接口返回格式高度统一。
3.3 定义业务错误码
把错误码集中管理,别在代码里散落一堆魔法数字:
go
// pkg/errno/errno.go
package errno
var (
OK = &Err{Code: 0, Msg: "success"}
ParamInvalid = &Err{Code: 40001, Msg: "参数错误"}
NotFound = &Err{Code: 40404, Msg: "资源不存在"}
Internal = &Err{Code: 50000, Msg: "服务器内部错误"}
)
type Err struct {
Code int
Msg string
}
func (e *Err) Error() string { return e.Msg }
3.4 Recovery 中间件:崩溃兜底
这是最关键的一环------任何 handler panic 都不让进程挂,而是统一捕获、记日志、返回友好错误:
go
// middleware/recovery.go
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/yourname/todo-server/pkg/response"
"github.com/yourname/todo-server/pkg/errno"
)
func Recovery(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil { // 接住 panic
// ① 记下完整堆栈,方便排查
log.Error("panic 发生",
zap.Any("panic", r),
zap.Stack("stack"),
)
// ② 返回统一错误,而不是让进程挂掉
response.Fail(c, errno.Internal.Code, errno.Internal.Msg)
c.Abort() // 中断后续处理
}
}()
c.Next()
}
}
#mermaid-svg-hv2geJzsorbuiAfQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hv2geJzsorbuiAfQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hv2geJzsorbuiAfQ .error-icon{fill:#552222;}#mermaid-svg-hv2geJzsorbuiAfQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hv2geJzsorbuiAfQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hv2geJzsorbuiAfQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hv2geJzsorbuiAfQ .marker.cross{stroke:#333333;}#mermaid-svg-hv2geJzsorbuiAfQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hv2geJzsorbuiAfQ p{margin:0;}#mermaid-svg-hv2geJzsorbuiAfQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hv2geJzsorbuiAfQ .cluster-label text{fill:#333;}#mermaid-svg-hv2geJzsorbuiAfQ .cluster-label span{color:#333;}#mermaid-svg-hv2geJzsorbuiAfQ .cluster-label span p{background-color:transparent;}#mermaid-svg-hv2geJzsorbuiAfQ .label text,#mermaid-svg-hv2geJzsorbuiAfQ span{fill:#333;color:#333;}#mermaid-svg-hv2geJzsorbuiAfQ .node rect,#mermaid-svg-hv2geJzsorbuiAfQ .node circle,#mermaid-svg-hv2geJzsorbuiAfQ .node ellipse,#mermaid-svg-hv2geJzsorbuiAfQ .node polygon,#mermaid-svg-hv2geJzsorbuiAfQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hv2geJzsorbuiAfQ .rough-node .label text,#mermaid-svg-hv2geJzsorbuiAfQ .node .label text,#mermaid-svg-hv2geJzsorbuiAfQ .image-shape .label,#mermaid-svg-hv2geJzsorbuiAfQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-hv2geJzsorbuiAfQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hv2geJzsorbuiAfQ .rough-node .label,#mermaid-svg-hv2geJzsorbuiAfQ .node .label,#mermaid-svg-hv2geJzsorbuiAfQ .image-shape .label,#mermaid-svg-hv2geJzsorbuiAfQ .icon-shape .label{text-align:center;}#mermaid-svg-hv2geJzsorbuiAfQ .node.clickable{cursor:pointer;}#mermaid-svg-hv2geJzsorbuiAfQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hv2geJzsorbuiAfQ .arrowheadPath{fill:#333333;}#mermaid-svg-hv2geJzsorbuiAfQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hv2geJzsorbuiAfQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hv2geJzsorbuiAfQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hv2geJzsorbuiAfQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hv2geJzsorbuiAfQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hv2geJzsorbuiAfQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hv2geJzsorbuiAfQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hv2geJzsorbuiAfQ .cluster text{fill:#333;}#mermaid-svg-hv2geJzsorbuiAfQ .cluster span{color:#333;}#mermaid-svg-hv2geJzsorbuiAfQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hv2geJzsorbuiAfQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hv2geJzsorbuiAfQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-hv2geJzsorbuiAfQ .icon-shape,#mermaid-svg-hv2geJzsorbuiAfQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hv2geJzsorbuiAfQ .icon-shape p,#mermaid-svg-hv2geJzsorbuiAfQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hv2geJzsorbuiAfQ .icon-shape .label rect,#mermaid-svg-hv2geJzsorbuiAfQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hv2geJzsorbuiAfQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hv2geJzsorbuiAfQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hv2geJzsorbuiAfQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 正常
panic!
请求进来
Recovery 中间件
defer recover()
Handler 执行
返回正常响应
recover() 接住
zap 记录堆栈
返回统一 500 错误
进程不崩
🎯 一句话 :错误处理的「三件套」= 统一响应结构 (接口格式一致)+ 错误码表 (业务错误集中管理)+ Recovery 中间件(panic 兜底不崩、还记日志)。有了它,前端好接、运维好查、服务不崩。
3.5 把中间件挂到路由上
最后,把这些中间件「串」到 Gin 上:
go
// router/router.go
package router
import (
"github.com/gin-gonic/gin"
"github.com/yourname/todo-server/handler"
"github.com/yourname/todo-server/middleware"
"go.uber.org/zap"
)
func SetupRouter(h *handler.TodoHandler, log *zap.Logger) *gin.Engine {
r := gin.New() // 用 New() 而不是 Default(),自己控制中间件
// 全局中间件:按顺序执行
r.Use(
middleware.Recovery(log), // ① 崩溃兜底(放最外层)
middleware.Logger(log), // ② 请求日志
)
api := r.Group("/api")
{
todos := api.Group("/todos")
{
todos.POST("", h.Create)
todos.GET("/:id", h.GetByID)
todos.PUT("/:id", h.Update)
todos.DELETE("/:id", h.Delete)
}
}
return r
}
中间件的执行顺序,是一条「洋葱模型」:
#mermaid-svg-QnFFWr9PTX9QVttb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QnFFWr9PTX9QVttb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QnFFWr9PTX9QVttb .error-icon{fill:#552222;}#mermaid-svg-QnFFWr9PTX9QVttb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QnFFWr9PTX9QVttb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QnFFWr9PTX9QVttb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QnFFWr9PTX9QVttb .marker.cross{stroke:#333333;}#mermaid-svg-QnFFWr9PTX9QVttb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QnFFWr9PTX9QVttb p{margin:0;}#mermaid-svg-QnFFWr9PTX9QVttb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QnFFWr9PTX9QVttb .cluster-label text{fill:#333;}#mermaid-svg-QnFFWr9PTX9QVttb .cluster-label span{color:#333;}#mermaid-svg-QnFFWr9PTX9QVttb .cluster-label span p{background-color:transparent;}#mermaid-svg-QnFFWr9PTX9QVttb .label text,#mermaid-svg-QnFFWr9PTX9QVttb span{fill:#333;color:#333;}#mermaid-svg-QnFFWr9PTX9QVttb .node rect,#mermaid-svg-QnFFWr9PTX9QVttb .node circle,#mermaid-svg-QnFFWr9PTX9QVttb .node ellipse,#mermaid-svg-QnFFWr9PTX9QVttb .node polygon,#mermaid-svg-QnFFWr9PTX9QVttb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QnFFWr9PTX9QVttb .rough-node .label text,#mermaid-svg-QnFFWr9PTX9QVttb .node .label text,#mermaid-svg-QnFFWr9PTX9QVttb .image-shape .label,#mermaid-svg-QnFFWr9PTX9QVttb .icon-shape .label{text-anchor:middle;}#mermaid-svg-QnFFWr9PTX9QVttb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QnFFWr9PTX9QVttb .rough-node .label,#mermaid-svg-QnFFWr9PTX9QVttb .node .label,#mermaid-svg-QnFFWr9PTX9QVttb .image-shape .label,#mermaid-svg-QnFFWr9PTX9QVttb .icon-shape .label{text-align:center;}#mermaid-svg-QnFFWr9PTX9QVttb .node.clickable{cursor:pointer;}#mermaid-svg-QnFFWr9PTX9QVttb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QnFFWr9PTX9QVttb .arrowheadPath{fill:#333333;}#mermaid-svg-QnFFWr9PTX9QVttb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QnFFWr9PTX9QVttb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QnFFWr9PTX9QVttb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QnFFWr9PTX9QVttb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QnFFWr9PTX9QVttb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QnFFWr9PTX9QVttb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QnFFWr9PTX9QVttb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QnFFWr9PTX9QVttb .cluster text{fill:#333;}#mermaid-svg-QnFFWr9PTX9QVttb .cluster span{color:#333;}#mermaid-svg-QnFFWr9PTX9QVttb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QnFFWr9PTX9QVttb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QnFFWr9PTX9QVttb rect.text{fill:none;stroke-width:0;}#mermaid-svg-QnFFWr9PTX9QVttb .icon-shape,#mermaid-svg-QnFFWr9PTX9QVttb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QnFFWr9PTX9QVttb .icon-shape p,#mermaid-svg-QnFFWr9PTX9QVttb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QnFFWr9PTX9QVttb .icon-shape .label rect,#mermaid-svg-QnFFWr9PTX9QVttb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QnFFWr9PTX9QVttb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QnFFWr9PTX9QVttb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QnFFWr9PTX9QVttb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求
Recovery
①
Logger
②
Handler
③ 核心逻辑
Logger 收尾
Recovery 收尾
响应
⚠️ 顺序很重要 :
Recovery要放最外面,这样它才能「兜住」里面所有 panic(包括日志中间件本身的)。中间件是「先进后出」------先注册的先执行、最后收尾。
3.6 组装到 main:工程化后的启动流程
把所有东西串起来,main.go 变成这样:
go
// main.go
package main
import (
"github.com/gin-gonic/gin"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"github.com/yourname/todo-server/config"
"github.com/yourname/todo-server/logger"
"github.com/yourname/todo-server/handler"
"github.com/yourname/todo-server/repository"
"github.com/yourname/todo-server/router"
"github.com/yourname/todo-server/service"
)
func main() {
// ① 先读配置
cfg, err := config.Load("./config/config.yaml")
if err != nil {
panic("读配置失败: " + err.Error())
}
// ② 再初始化日志
logger.Init(cfg.Log.Level, cfg.Log.File)
// ③ 连数据库
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN), &gorm.Config{})
if err != nil {
logger.Log.Fatal("连数据库失败", zap.Error(err))
}
// ④ 组装各层
repo := repository.NewTodoRepo(db)
svc := service.NewTodoService(repo)
h := handler.NewTodoHandler(svc)
// ⑤ 启动(带上日志)
r := router.SetupRouter(h, logger.Log)
if err := r.Run(":" + cfg.Server.Port); err != nil {
logger.Log.Fatal("启动失败", zap.Error(err))
}
}
工程化后的启动流程,比上一篇多了「配置 + 日志」两环:
#mermaid-svg-Xiv6bPi2xW1VYbMg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xiv6bPi2xW1VYbMg .error-icon{fill:#552222;}#mermaid-svg-Xiv6bPi2xW1VYbMg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xiv6bPi2xW1VYbMg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .marker.cross{stroke:#333333;}#mermaid-svg-Xiv6bPi2xW1VYbMg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xiv6bPi2xW1VYbMg p{margin:0;}#mermaid-svg-Xiv6bPi2xW1VYbMg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster-label text{fill:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster-label span{color:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster-label span p{background-color:transparent;}#mermaid-svg-Xiv6bPi2xW1VYbMg .label text,#mermaid-svg-Xiv6bPi2xW1VYbMg span{fill:#333;color:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .node rect,#mermaid-svg-Xiv6bPi2xW1VYbMg .node circle,#mermaid-svg-Xiv6bPi2xW1VYbMg .node ellipse,#mermaid-svg-Xiv6bPi2xW1VYbMg .node polygon,#mermaid-svg-Xiv6bPi2xW1VYbMg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .rough-node .label text,#mermaid-svg-Xiv6bPi2xW1VYbMg .node .label text,#mermaid-svg-Xiv6bPi2xW1VYbMg .image-shape .label,#mermaid-svg-Xiv6bPi2xW1VYbMg .icon-shape .label{text-anchor:middle;}#mermaid-svg-Xiv6bPi2xW1VYbMg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .rough-node .label,#mermaid-svg-Xiv6bPi2xW1VYbMg .node .label,#mermaid-svg-Xiv6bPi2xW1VYbMg .image-shape .label,#mermaid-svg-Xiv6bPi2xW1VYbMg .icon-shape .label{text-align:center;}#mermaid-svg-Xiv6bPi2xW1VYbMg .node.clickable{cursor:pointer;}#mermaid-svg-Xiv6bPi2xW1VYbMg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .arrowheadPath{fill:#333333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xiv6bPi2xW1VYbMg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Xiv6bPi2xW1VYbMg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xiv6bPi2xW1VYbMg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster text{fill:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg .cluster span{color:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Xiv6bPi2xW1VYbMg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Xiv6bPi2xW1VYbMg rect.text{fill:none;stroke-width:0;}#mermaid-svg-Xiv6bPi2xW1VYbMg .icon-shape,#mermaid-svg-Xiv6bPi2xW1VYbMg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Xiv6bPi2xW1VYbMg .icon-shape p,#mermaid-svg-Xiv6bPi2xW1VYbMg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Xiv6bPi2xW1VYbMg .icon-shape .label rect,#mermaid-svg-Xiv6bPi2xW1VYbMg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Xiv6bPi2xW1VYbMg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Xiv6bPi2xW1VYbMg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Xiv6bPi2xW1VYbMg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读配置 viper
初始化日志 zap
连数据库 GORM
组装各层
repo→service→handler
挂中间件
Recovery + Logger
启动服务
写在最后
我们从「能跑」走到了「能上线」。回顾这一篇给服务穿上的三件「铠甲」:
- 配置(viper):配置与代码分离,一份代码多环境部署,支持热加载、环境变量覆盖。
- 日志(zap):结构化 JSON 日志,带级别、带文件行号、高性能零分配,配合日志中间件做到「每个请求都可追溯」。
- 错误处理中间件:统一响应结构 + 错误码表 + Recovery 兜底,让服务「不崩、还统一、好排查」。
最后送你一句话:
「能跑」靠的是语法,「能上线」靠的是工程化。
业务代码只占一个服务的 20%,剩下的 80%,是配置怎么管、日志怎么记、错误怎么处理、异常怎么兜底------这些「看不见的地基」,才决定了一个服务在凌晨三点出问题时,你是一分钟定位 ,还是抓瞎一宿。
Go 的生态给了你一套趁手的工具:viper 管配置、zap 记日志、Gin 中间件做横切 。把这套「工程化三件套」练熟,你写的就不再是「demo」,而是一个能扛住生产环境的服务。
如果这篇对你有帮助,欢迎点赞、收藏、转发,我们下期见 🚀