每个 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 |
redisx | Redis 客户端封装:连接池、健康检查、分布式锁 | redisx.MustNew |
cache | 统一缓存接口:内存 LRU 或 Redis 实现 | cache.NewMemCache |
httpx | HTTP 一站式:参数绑定、统一响应、中间件、路由、优雅关闭 | httpx.NewServer |
jwt | HS256/384/512 签名解析 + 鉴权中间件 | jwt.MustNew |
ratelimit | 令牌桶 / 滑动窗口,内存版和 Redis 版 | ratelimit.NewTokenBucket |
breaker | Google SRE 算法熔断器,快速失败防止雪崩 | breaker.Do |
retry | 指数退避 / 固定延迟 / 抖动重试 | retry.Do |
taskq | 基于 asynq 的异步任务队列,生产者 / 消费者 | taskq.NewProducer |
storage | 统一对象存储:本地文件 / OSS / COS / KODO | storage.New |
websocket | 基于 gorilla/websocket 的事件驱动实时通信 | websocket.MustNew |
trace | OpenTelemetry 分布式追踪:agent / span / 传播 | trace.StartAgent |
hash | 密码散列 / AES-GCM 加密 / HMAC 签名 | hash.BcryptHashDefault |
cast、stringx、syncx | 类型转换、字符串、并发原语(SingleFlight 等) | cast.To[T] |
service | 把多个服务的启动 / 停止编排到一起 | service.NewServiceGroup |
设计哲学
infra-go 有四个贯穿始终的原则:
- 一致风格:所有模块都是英文注释、英文错误信息、函数式配置;约定俗成
New返回 error、MustNew失败就 panic。 - 可控依赖:模块可独立导入,你不用它,它就永远不会进入你的
go.mod/go.sum。轻量工具包(cast/stringx/syncx)零第三方依赖。 - 类型安全:全面使用泛型,比如统一响应
Response[T]、cast.To[T]。 - 可测试:每个模块都有完整单测,支持
-race检测。
快速上手:一个零外部依赖的最小服务
不依赖数据库不依赖 Redis,configuration + logging + HTTP + 生命周期编排就四个模块。先看效果:
1 | package main |
config.yaml 甚至可以不存在——缺字段时用 struct 里的默认值兜底:
1 | host: 0.0.0.0 |
请求回来永远是同一套约定好的结构:
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 | type ServerConfig struct { |
除了 tag 校验,还能实现 Validate() error 接口做跨字段校验,加载完会自动调用:
1 | func (c ServerConfig) Validate() error { |
环境变量:${VAR} 展开
conf.UseEnv() 打开配置文件里的环境变量引用,语义跟 shell 一致:
1 | jwt: |
它有一个非常关键的实现细节:先解析、后展开。也就是说展开只发生在已解析出的字符串值上,展开结果绝不会被再次解析。这意味着:
- 环境变量里就算塞了
","admin":true这种片段,它也只是一段字符串,不可能凭空造出配置键(没有注入风险); - 值里的引号、括号、逗号、换行都不会破坏解析;
- 想要字面
$,写成$$就行。
这是很多配置库最容易踩的坑——“展开后再解析一次”,结果就是配置注入漏洞的温床。
数字精度:json.Number
大整数不经过 float64,内部用 json.Number 保精,9223372036854775807 这种 int64 上限值原样落地,不会被截断。
logger:全局日志,随手一抄
基于 zap,但把那些繁琐配置都藏起来了,API 直接挂在包级函数上:
1 | logger.SetGlobal(logger.New(logger.Config{ |
写日志时只要 logger.XxxCtx,配合 httpx.WithTracing、httpx.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 会自动包一层,拿到 *CodeError 或 error 时自动取业务码和消息:
1 | httpx.OkJSON(w, data) // {"code":0,"msg":"ok","data":...} |
参数绑定:一份代码,校验一步到位
绑定 + 校验一次完成,失败时 MustBind* 已经自动写了 400,处理器里 return 即可:
1 | type CreateUserRequest struct { |
Query / Header / URI / Form 用法一致,绑定用哪个来源就选哪个函数(BindQuery / BindHeader / BindURI / BindForm),甚至还有自动选择来源的 Bind。校验规则基于 go-playground/validator,required、min/max、gte/lte、email、oneof 这些常见的都在。
单值读取还给了免定义 struct 的快路径:
1 | page := httpx.QueryValue(r, "page", 1) |
中间件全家桶
拆箱即用的是这么一批 With*:
1 | srv.Use(httpx.WithTracing("/healthz")) // 分布式追踪,放最外层让日志带上 trace_id |
还有 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 | api := srv.Group("/api", authMW) // 前缀 /api + 鉴权 |
server.Start() 阻塞运行,收到 SIGINT / SIGTERM / SIGHUP 自动优雅关闭;手动停用 server.Stop(),正好能被 service.AsService 纳入 ServiceGroup 统一管理。
组合实战:一个带鉴权 + 限流 + 熔断 + 追踪的完整服务
把前面的模块拼起来,一个”配置 + 日志 + 数据库 + Redis + JWT 鉴权 + 限流熔断 + 追踪 + 统一启停”的典型服务长这样:
1 | package main |
这里想强调三个点,都是业务里见过血才补上的:
- 熔断 + 重试要配着用:
breaker挡”雪崩式”的连片失败,retry消化”偶发”的网络抖动,两者层层叠叠,短时故障不会直接打到数据库。 - JWT 中间件只挂在管理组上:
route group的中间件叠加能力,让”开放接口”和”需要鉴权的接口”清晰分开,不用在 handler 里到处if判断身份。 XxxCtx全家桶:OkJSONCtx、ErrorCtx、MustBind失败响应……全都从 context 里带出request_id/trace_id,前端报错贴个 ID 就能从日志捞到底层原因。
分布式锁、缓存、异步队列、对象存储
挑几个高频又容易踩坑的场景看看框架怎么帮你兜底。
redisx:分布式锁
多实例部署后,单机 sync.Mutex 就失效了。redisx 直接给锁:
1 | lock := rds.Locker("order:123:lock", 10*time.Second) |
还有个更实用的用法——把”持锁做某事”封装成回调,整个生命周期由框架保证:
1 | err := rds.SetNXWithLock(ctx, "coupon:99:issue", 10*time.Second, func(ctx context.Context) error { |
cache:缓存击穿与穿透
内存 LRU 版适合单机热点数据,还带命中率统计;Redis 版内置了防击穿防穿透的保护。接口统一,切换实现只改一行构造:
1 | mem := cache.NewMemCache(ctx, 5*time.Minute, cache.WithLRUMaxSize(1000)) |
taskq:异步任务队列
发优惠券、推送通知这类”能异步就不同步”的活儿,taskq 基于 asynq,生产者消费者模式:
1 | producer := taskq.NewProducer(taskq.Config{Redis: cfg.Redis}) |
消费者侧用一个 Handler 注册订阅,框架处理 ack、重试和并发。
storage:对象存储统一接口
本地文件、阿里 OSS、腾讯 COS、七牛 KODO 一套接口:
1 | st, _ := storage.New(storage.Config{Driver: storage.DriverLocal, SaveDir: "./uploads"}) |
换存储厂商只改配置,业务代码一行不动——谁用谁知道,跟云厂商 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 syncx | 0(纯标准库) |
conf hash | 1 |
breaker logger ratelimit service | 3 |
cache redisx | 6 |
jwt | 11 |
storage taskq | 12 |
orm | 14(三个驱动全量) |
httpx | 16 |
trace | 17(四种 exporter 全量) |
如果产物体积是硬指标(比如云函数冷启动),orm / storage / trace 就绕一层、直接用厂商官方 SDK。这是文档里明说的取舍,属于”我告诉你代价,你自己权衡”。
New / MustNew:约定即语义
New 返回 error,可恢复的对象自己决定怎么处理;MustNew panic,全局单例 / 服务级组件启动失败就该崩。这个约定让你扫代码一眼就分清”这个挂了还能商量”还是”这个挂了起不来”。
错误与可观测性
- 底层用 sentinel error +
errors.Is,语义判断不靠字符串; - 业务错误用
httpx.NewCodeErrorWithCause(code, msg, err),errors.As能拿到根因; request_id、trace_id全程随着context走,日志、响应、链路三处对得上。
并发安全
Reader、Server 这类组件设计为并发安全,测试里专门有并发加载的用例,并提倡 -race 跑。有状态的东西(单例、连接池)都放到对象上而不是包级变量。
质量与契约
库自己的质量门槛写得很清楚,也推荐这么约束自己的业务代码:
1 | go build ./... # 编译 |
每个模块都有配套单测文件,无死角。
小结
infra-go 解决的不是”Go 做不到”,而是”每次都重新做”。落地到业务项目里,你的收益是:
- 新服务五分钟起一个骨架,不用再重复初始化一堆老三样;
- 团队写出来的代码风格统一,错误处理、统一响应、日志规范有约定可循;
- 中间件、限流、熔断、追踪这些横切能力开箱即用,不用自己踩规范和边界条件的坑(比如
Retry-After、WWW-Authenticate这种东西,自己实现十有八九有问题)。
如果你也想偷个懒,把基础设施从一个个”能用就行”的胶水代码,收敛成一个统一风格、可插拔、经得起 review 的库,infra-go 是个不错的参考。