Go Web 工程化:日志、配置与错误处理中间件,让服务「能上线」

上一篇《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 两个问题:会崩 + 不统一

上一篇的错误处理有两个毛病:

  1. panic 会崩 :handler 里一旦 panic,整个进程挂掉(Gin 默认自带 Recovery 中间件能拦住 panic,但只会返回 500,不记录日志、不友好);
  2. 返回格式不统一 :有的返回 {"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
启动服务


写在最后

我们从「能跑」走到了「能上线」。回顾这一篇给服务穿上的三件「铠甲」:

  1. 配置(viper):配置与代码分离,一份代码多环境部署,支持热加载、环境变量覆盖。
  2. 日志(zap):结构化 JSON 日志,带级别、带文件行号、高性能零分配,配合日志中间件做到「每个请求都可追溯」。
  3. 错误处理中间件:统一响应结构 + 错误码表 + Recovery 兜底,让服务「不崩、还统一、好排查」。

最后送你一句话:

「能跑」靠的是语法,「能上线」靠的是工程化。

业务代码只占一个服务的 20%,剩下的 80%,是配置怎么管、日志怎么记、错误怎么处理、异常怎么兜底------这些「看不见的地基」,才决定了一个服务在凌晨三点出问题时,你是一分钟定位 ,还是抓瞎一宿

Go 的生态给了你一套趁手的工具:viper 管配置、zap 记日志、Gin 中间件做横切 。把这套「工程化三件套」练熟,你写的就不再是「demo」,而是一个能扛住生产环境的服务

如果这篇对你有帮助,欢迎点赞、收藏、转发,我们下期见 🚀

相关推荐
tqs_1234524 分钟前
SaaS 多租户 Agent 平台实战:自研 Agent 中间件 SDK,Nacos 配置驱动实现开箱即用
中间件
SquabbyZhu31 分钟前
从 4 个 URL 到 1 个入口:Peaks-Loop 驱动的微前端聚合实践
前端
Hilaku40 分钟前
技术好就能升职是前端圈最大的谎言!
前端·javascript·程序员
lhldsg42 分钟前
树洞倾诉的核心需求与产品定位误区
java·前端·小程序
光影少年1 小时前
react navite高频手写/实操题
前端·javascript·react native·react.js·前端框架
hunterandroid1 小时前
HarmonyOS 弱网与离线优先架构实战:请求队列、本地缓存与增量同步
前端·前端框架
lhldsg1 小时前
宠物同城领养平台开发实战:从需求分析到上线部署指南
java·前端·小程序·需求分析·宠物
hunterandroid1 小时前
Android 内存泄漏排查实战:从 LeakCanary 报警到根因定位
android·前端
leoZ2311 小时前
AI+前端提效-12 AI辅助前端性能优化与监控:从开发到线上全流程提效
前端·人工智能·神经网络·自然语言处理·性能优化·keras·知识图谱