Go 服务基础设施一站式方案:infra-go 实战

chihqiang chihqiang 2026-09-18 约 39 分钟 15548 字

每个 Go 业务服务,绕不开这几件事:读配置、打日志、连数据库、接 Redis、起 HTTP 服务、做鉴权限流、安全优雅地退出。每开一个新项目都要把这一套重新实现一遍,虽然熟,但很烦,而且每个人写的风格还不一样。

如果把这些能力收敛成一个库,模块可插拔、独立导入、开箱即用,事情就简单多了。infra-go 就是这个思路:一个把配置、日志、数据库、Redis、HTTP、对象存储写成统一风格的 Go 基础设施库。

这篇文章会从整体设计讲起,然后从一个最小服务到一个完整服务,把常用模块逐个过一遍,最后聊聊几个值得称道的设计取舍。

模块全景

模块干什么的入口
conf读 JSON/YAML 配置、默认值、环境变量、参数校验conf.Load
logger基于 zap + lumberjack 的结构化日志,支持滚动logger.New
orm基于 gorm,MySQL / PostgreSQL / SQLite 一套代码orm.MustNew
redisxRedis 客户端封装:连接池、健康检查、分布式锁redisx.MustNew
cache统一缓存接口:内存 LRU 或 Redis 实现cache.NewMemCache
httpxHTTP 一站式:参数绑定、统一响应、中间件、路由、优雅关闭httpx.NewServer
jwtHS256/384/512 签名解析 + 鉴权中间件jwt.MustNew
ratelimit令牌桶 / 滑动窗口,内存版和 Redis 版ratelimit.NewTokenBucket
breakerGoogle SRE 算法熔断器,快速失败防止雪崩breaker.Do
retry指数退避 / 固定延迟 / 抖动重试retry.Do
taskq基于 asynq 的异步任务队列,生产者 / 消费者taskq.NewProducer
storage统一对象存储:本地文件 / OSS / COS / KODOstorage.New
websocket基于 gorilla/websocket 的事件驱动实时通信websocket.MustNew
traceOpenTelemetry 分布式追踪:agent / span / 传播trace.StartAgent
hash密码散列 / AES-GCM 加密 / HMAC 签名hash.BcryptHashDefault
caststringxsyncx类型转换、字符串、并发原语(SingleFlight 等)cast.To[T]
service把多个服务的启动 / 停止编排到一起service.NewServiceGroup

设计哲学

infra-go 有四个贯穿始终的原则:

  1. 一致风格:所有模块都是英文注释、英文错误信息、函数式配置;约定俗成 New 返回 error、MustNew 失败就 panic。
  2. 可控依赖:模块可独立导入,你不用它,它就永远不会进入你的 go.mod / go.sum。轻量工具包(cast / stringx / syncx)零第三方依赖。
  3. 类型安全:全面使用泛型,比如统一响应 Response[T]cast.To[T]
  4. 可测试:每个模块都有完整单测,支持 -race 检测。

快速上手:一个零外部依赖的最小服务

不依赖数据库不依赖 Redis,configuration + logging + HTTP + 生命周期编排就四个模块。先看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package main

import (
"net/http"

"github.com/chihqiang/infra-go/conf"
"github.com/chihqiang/infra-go/httpx"
"github.com/chihqiang/infra-go/logger"
"github.com/chihqiang/infra-go/service"
)

// 用 json tag 声明默认值和约束
type Config struct {
Host string `json:",default=0.0.0.0"`
Port int `json:",default=8080,range=[1:65535]"`
}

type helloRequest struct {
Name string `json:"name" binding:"required"`
}

func main() {
// 1. 全局日志,最先初始化;Sync 在退出前冲刷缓冲
logger.SetGlobal(logger.New(logger.Config{
Level: logger.InfoLevel,
AppName: "demo",
}))
defer logger.Sync()

// 2. 配置加载:默认值 / JSON / YAML / 环境变量展开
var cfg Config
if err := conf.Load("config.yaml", &cfg, conf.UseEnv()); err != nil {
logger.Fatal("load config failed", logger.Err(err))
}

// 3. HTTP 服务:参数绑定 + 校验 + 统一响应,中间件即插即用
srv := httpx.NewServer(httpx.ServerConfig{Host: cfg.Host, Port: cfg.Port})
srv.Use(httpx.WithRequestID(), httpx.WithRecovery())
srv.AddRoute(httpx.Route{
Method: "POST",
Path: "/hello",
Handler: func(w http.ResponseWriter, r *http.Request) {
var req helloRequest
if err := httpx.MustBindJSON(w, r, &req); err != nil {
return // 绑定 / 校验失败已自动写入 400
}
httpx.OkJSON(w, map[string]string{"msg": "hello, " + req.Name})
},
})

// 4. 生命周期编排:AsService 把 *httpx.Server 适配成 Service,统一启停
sg := service.NewServiceGroup()
sg.Add(service.AsService(srv))
sg.Start()
}

config.yaml 甚至可以不存在——缺字段时用 struct 里的默认值兜底:

1
2
host: 0.0.0.0
port: 8080

请求回来永远是同一套约定好的结构:

1
{"code": 0, "msg": "ok", "data": {"msg": "hello, world"}, "request_id": "..."}

conf:配置不是玄学

写业务的第一件事就是读配置。conf 把常见诉求都收敛进了 struct tag:

指令作用
default=...配置文件没给时用默认值。支持基本类型、time.Duration、切片(比如 default=[a.com,b.com]
env=VAR优先从环境变量读,为空时再回落文件
optional标记可选字段,缺了不报错
range=[1:65535]数值范围,支持开闭区间,如 [:100][1:]
options=[file,console]枚举校验,用竖线分隔也兼容
string强制先当字符串再解析,方便 JSON 里写 "9090" 这种
1
2
3
4
5
6
7
8
type ServerConfig struct {
Host string `json:",default=0.0.0.0"`
Port int `json:",default=8080,range=[1:65535]"`
Timeout time.Duration `json:",default=3s"`
LogMode string `json:",options=[file,console]"`
Verbose bool `json:",optional"`
DB Database `json:"db"` // 嵌套结构体,各自的默认值独立生效
}

除了 tag 校验,还能实现 Validate() error 接口做跨字段校验,加载完会自动调用:

1
2
3
4
5
6
func (c ServerConfig) Validate() error {
if c.Port == c.DB.Port {
return fmt.Errorf("server port and db port must not be the same")
}
return nil
}

环境变量:${VAR} 展开

conf.UseEnv() 打开配置文件里的环境变量引用,语义跟 shell 一致:

1
2
3
4
jwt:
secret: ${JWT_SECRET:-dev-secret} # 未设置时回落 dev-secret
db:
password: ${DB_PASSWORD}

它有一个非常关键的实现细节:先解析、后展开。也就是说展开只发生在已解析出的字符串值上,展开结果绝不会被再次解析。这意味着:

  • 环境变量里就算塞了 ","admin":true 这种片段,它也只是一段字符串,不可能凭空造出配置键(没有注入风险);
  • 值里的引号、括号、逗号、换行都不会破坏解析;
  • 想要字面 $,写成 $$ 就行。

这是很多配置库最容易踩的坑——“展开后再解析一次”,结果就是配置注入漏洞的温床。

数字精度:json.Number

大整数不经过 float64,内部用 json.Number 保精,9223372036854775807 这种 int64 上限值原样落地,不会被截断。

logger:全局日志,随手一抄

基于 zap,但把那些繁琐配置都藏起来了,API 直接挂在包级函数上:

1
2
3
4
5
6
7
8
9
logger.SetGlobal(logger.New(logger.Config{
Level: logger.InfoLevel,
AppName: "user-service",
}))
defer logger.Sync()

logger.Info("user created", logger.String("id", id))
logger.Error("db failed", logger.Err(err))
logger.ErrorCtx(ctx, "query failed", logger.Err(err)) // 自动带上 trace_id / request_id

写日志时只要 logger.XxxCtx,配合 httpx.WithTracinghttpx.WithRequestID,链路 ID 自动进日志,排查问题不用再靠猜。

httpx:HTTP 一站到底

这是 infra-go 里最重的一个模块,拆成了几个子包,即可整包用,也可单点复用:

  • 主包:server + 统一响应 + 绑定助手 + With* 中间件适配层;
  • **binding**:绑定实现,六种来源(JSON / XML / Form / Query / Header / URI);
  • **middleware**:中间件核心逻辑,标准 func(http.Handler) http.Handler 形态,gin / echo 都能直接复用;
  • **x**:路径匹配、客户端 IP 解析等通用工具;
  • **respw**:ResponseWriter 增强。

统一响应 Response[T]

前后端不用再为”返回格式到底是啥”打架。OkJSON 会自动包一层,拿到 *CodeErrorerror 时自动取业务码和消息:

1
2
3
4
httpx.OkJSON(w, data)          // {"code":0,"msg":"ok","data":...}
httpx.OkJSONCtx(ctx, w, data) // 自动带 request_id
httpx.WriteHTTPError(w, status, msg)
httpx.WriteHTTPErrorWithCode(w, status, code, msg) // 业务码与 HTTP 状态码分离

参数绑定:一份代码,校验一步到位

绑定 + 校验一次完成,失败时 MustBind* 已经自动写了 400,处理器里 return 即可:

1
2
3
4
5
6
7
8
9
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=20"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required,oneof=admin user guest"`
}

if err := httpx.MustBindJSON(w, r, &req); err != nil {
return
}

Query / Header / URI / Form 用法一致,绑定用哪个来源就选哪个函数(BindQuery / BindHeader / BindURI / BindForm),甚至还有自动选择来源的 Bind。校验规则基于 go-playground/validator,requiredmin/maxgte/lteemailoneof 这些常见的都在。

单值读取还给了免定义 struct 的快路径:

1
2
page  := httpx.QueryValue(r, "page", 1)
token := httpx.HeaderValue(r, "X-Token", "")

中间件全家桶

拆箱即用的是这么一批 With*

1
2
3
4
5
6
7
8
9
srv.Use(httpx.WithTracing("/healthz"))   // 分布式追踪,放最外层让日志带上 trace_id
srv.Use(httpx.WithRequestID()) // request_id 注入 context 并回写响应头
srv.Use(httpx.WithRecovery()) // panic 兜底,记栈转 500
srv.Use(httpx.WithCors("*")) // CORS,回显具体 Origin
srv.Use(httpx.WithRateLimit(ratelimit.NewTokenBucket(100, 200))) // 限流 429
srv.Use(httpx.WithTimeout(5 * time.Second))
srv.Use(httpx.WithJWT(j, func(r *http.Request) string {
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}))

还有 WithLogger(访问日志)、WithBreaker / WithRouteBreaker(熔断)、WithMaxBytes(请求体大小)、WithGunzip(解压)、WithMaxConns(并发数)、WithCryption(AES-GCM 加解密)、WithContentSecurity(防篡改防重放)。

有意思的是这些中间件写得很”讲规范”:

  • 401 一定会带 WWW-Authenticate: Bearer error="invalid_token"
  • 429 / 503 会尽量带准确的 Retry-After(限流器精确算”下一个token多久到”,不足 1 秒也发 1,估算不出来就不发);
  • 超时检测到客户端断开就写 499(nginx 约定的观测码,日志里能看出”用户提前走了”)。

非 httpx 的第三方标准中间件也可以用 httpx.AsMiddleware 挂进来。

路由与优雅关闭

路由走 Go 1.22 的 {param} 模式,支持分组和中间件分层:

1
2
3
4
5
6
api := srv.Group("/api", authMW) // 前缀 /api + 鉴权
v1 := api.Group("/v1", logMW) // /api/v1,中间件叠加
v1.AddRoute(httpx.Route{Method: "GET", Path: "/users/{id}", Handler: func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
httpx.OkJSON(w, id)
}})

server.Start() 阻塞运行,收到 SIGINT / SIGTERM / SIGHUP 自动优雅关闭;手动停用 server.Stop(),正好能被 service.AsService 纳入 ServiceGroup 统一管理。

组合实战:一个带鉴权 + 限流 + 熔断 + 追踪的完整服务

把前面的模块拼起来,一个”配置 + 日志 + 数据库 + Redis + JWT 鉴权 + 限流熔断 + 追踪 + 统一启停”的典型服务长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package main

import (
"net/http"
"time"

"github.com/chihqiang/infra-go/breaker"
"github.com/chihqiang/infra-go/conf"
"github.com/chihqiang/infra-go/httpx"
"github.com/chihqiang/infra-go/jwt"
"github.com/chihqiang/infra-go/logger"
"github.com/chihqiang/infra-go/orm"
"github.com/chihqiang/infra-go/ratelimit"
"github.com/chihqiang/infra-go/redisx"
"github.com/chihqiang/infra-go/service"
"github.com/chihqiang/infra-go/trace"
)

type Config struct {
Host string `json:",default=0.0.0.0"`
Port int `json:",default=8080,range=[1:65535]"`
JWTSecret string `json:"jwt_secret,env=JWT_SECRET"`
DB orm.Config `json:"db"`
Redis redisx.Config `json:"redis"`
}

func main() {
logger.SetGlobal(logger.New(logger.Config{Level: logger.InfoLevel, AppName: "shop"}))
defer logger.Sync()

var cfg Config
conf.MustLoad("config.yaml", &cfg, conf.UseEnv())

db := orm.MustNew(cfg.DB)
defer orm.Close(db)

rds := redisx.MustNew(cfg.Redis)
defer rds.Close()

j := jwt.MustNew(jwt.Config{Secret: cfg.JWTSecret, Issuer: "shop"})

trace.StartAgent(trace.Config{Name: "shop", Endpoint: cfg.Redis.Addr}) // 生产换 OTLP
defer trace.StopAgent()

srv := httpx.NewServer(httpx.ServerConfig{Host: cfg.Host, Port: cfg.Port})
srv.Use(
httpx.WithTracing("/healthz"), // 追踪放最外层
httpx.WithRequestID(),
httpx.WithRecovery(),
httpx.WithCors("https://admin.example.com"),
httpx.WithRateLimit(ratelimit.NewTokenBucket(100, 200)), // 100 qps,突发 200
)

api := srv.Group("/api") // 商品相关接口开放
api.AddRoute(httpx.Route{Method: "GET", Path: "/products", Handler: listProducts})

admin := srv.Group("/api/admin", httpx.WithJWT(j, func(r *http.Request) string {
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}))
admin.AddRoute(httpx.Route{Method: "POST", Path: "/products", Handler: createProduct})

sg := service.NewServiceGroup()
sg.Add(service.AsService(srv))
sg.Start()
}

func createProduct(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Price int64 `json:"price" binding:"required,min=1"`
}
if err := httpx.MustBindJSON(w, r, &req); err != nil {
return
}
// 下游写库用熔断 + 重试把瞬态故障挡在外面
err := breaker.Do("db.create-product", func() error {
return retry.Do(r.Context(), func(ctx context.Context) error {
return db.Create(&Product{Name: req.Name, Price: req.Price}).Error
})
})
if err != nil {
logger.ErrorCtx(r.Context(), "create product failed", logger.Err(err))
httpx.WriteHTTPError(w, http.StatusInternalServerError, "internal error")
return
}
httpx.OkJSONCtx(r.Context(), w, map[string]any{"ok": true})
}

这里想强调三个点,都是业务里见过血才补上的:

  1. 熔断 + 重试要配着用breaker 挡”雪崩式”的连片失败,retry 消化”偶发”的网络抖动,两者层层叠叠,短时故障不会直接打到数据库。
  2. JWT 中间件只挂在管理组上route group 的中间件叠加能力,让”开放接口”和”需要鉴权的接口”清晰分开,不用在 handler 里到处 if 判断身份。
  3. XxxCtx 全家桶OkJSONCtxErrorCtxMustBind 失败响应……全都从 context 里带出 request_id / trace_id,前端报错贴个 ID 就能从日志捞到底层原因。

分布式锁、缓存、异步队列、对象存储

挑几个高频又容易踩坑的场景看看框架怎么帮你兜底。

redisx:分布式锁

多实例部署后,单机 sync.Mutex 就失效了。redisx 直接给锁:

1
2
3
4
5
6
lock := rds.Locker("order:123:lock", 10*time.Second)
if err := lock.Lock(ctx); err != nil {
return err
}
defer lock.Unlock(ctx)
// 临界区:只有一台实例能进来

还有个更实用的用法——把”持锁做某事”封装成回调,整个生命周期由框架保证:

1
2
3
err := rds.SetNXWithLock(ctx, "coupon:99:issue", 10*time.Second, func(ctx context.Context) error {
return issueCoupon(ctx) // 同一把 key 只执行一次
})

cache:缓存击穿与穿透

内存 LRU 版适合单机热点数据,还带命中率统计;Redis 版内置了防击穿防穿透的保护。接口统一,切换实现只改一行构造:

1
2
mem := cache.NewMemCache(ctx, 5*time.Minute, cache.WithLRUMaxSize(1000))
red := cache.NewRedisCache(rds)

taskq:异步任务队列

发优惠券、推送通知这类”能异步就不同步”的活儿,taskq 基于 asynq,生产者消费者模式:

1
2
producer := taskq.NewProducer(taskq.Config{Redis: cfg.Redis})
err := producer.Enqueue(ctx, "notify.order", &taskq.Message{Payload: order.ID})

消费者侧用一个 Handler 注册订阅,框架处理 ack、重试和并发。

storage:对象存储统一接口

本地文件、阿里 OSS、腾讯 COS、七牛 KODO 一套接口:

1
2
3
4
st, _ := storage.New(storage.Config{Driver: storage.DriverLocal, SaveDir: "./uploads"})
st.Write(ctx, "avatar/1.png", data)
url, _ := st.URL(ctx, "avatar/1.png")
st.Delete(ctx, "avatar/1.png")

换存储厂商只改配置,业务代码一行不动——谁用谁知道,跟云厂商 OSS SDK 深度耦合的痛,换一次才懂。

值得称道的设计取舍

依赖足迹:你可以不带”打满”的行李

模块图剪枝(Go 1.17+)保证了你不导入的模块绝不会进构建。只 import stringx 一个模块时,消费者的 go.sum 只有 8 行,二进制里没有任何云 SDK / gorm / asynq 的符号。

但要注意一个反例:同一个包里的兄弟实现是一起编译进来的orm 一个包内就绑定了 MySQL + PostgreSQL + SQLite 三个驱动,storage 绑定了三个云 SDK,trace 绑定了四种 exporter——你没法”只挑一个”。所以:

模块拉进的第三方依赖数
cast mapping retry stringx syncx0(纯标准库)
conf hash1
breaker logger ratelimit service3
cache redisx6
jwt11
storage taskq12
orm14(三个驱动全量)
httpx16
trace17(四种 exporter 全量)

如果产物体积是硬指标(比如云函数冷启动),orm / storage / trace 就绕一层、直接用厂商官方 SDK。这是文档里明说的取舍,属于”我告诉你代价,你自己权衡”。

New / MustNew:约定即语义

New 返回 error,可恢复的对象自己决定怎么处理;MustNew panic,全局单例 / 服务级组件启动失败就该崩。这个约定让你扫代码一眼就分清”这个挂了还能商量”还是”这个挂了起不来”。

错误与可观测性

  • 底层用 sentinel error + errors.Is,语义判断不靠字符串;
  • 业务错误用 httpx.NewCodeErrorWithCause(code, msg, err)errors.As 能拿到根因;
  • request_idtrace_id 全程随着 context 走,日志、响应、链路三处对得上。

并发安全

ReaderServer 这类组件设计为并发安全,测试里专门有并发加载的用例,并提倡 -race 跑。有状态的东西(单例、连接池)都放到对象上而不是包级变量。

质量与契约

库自己的质量门槛写得很清楚,也推荐这么约束自己的业务代码:

1
2
3
go build ./...                # 编译
go vet ./... # 静态检查
go test ./... -race -count=1 # 单测 + 竞态检测

每个模块都有配套单测文件,无死角。

小结

infra-go 解决的不是”Go 做不到”,而是”每次都重新做”。落地到业务项目里,你的收益是:

  • 新服务五分钟起一个骨架,不用再重复初始化一堆老三样;
  • 团队写出来的代码风格统一,错误处理、统一响应、日志规范有约定可循;
  • 中间件、限流、熔断、追踪这些横切能力开箱即用,不用自己踩规范和边界条件的坑(比如 Retry-AfterWWW-Authenticate 这种东西,自己实现十有八九有问题)。

如果你也想偷个懒,把基础设施从一个个”能用就行”的胶水代码,收敛成一个统一风格、可插拔、经得起 review 的库,infra-go 是个不错的参考。

分享: Twitter Facebook
chihqiang

chihqiang

代码与生活的点滴记录