主题
Go 工程工具选型:沿着一个项目的重复劳动逐个选择
一个 Go 项目可能同时需要 CLI、测试、配置、日志和发布,但这不意味着开仓库的第一天就要把十几个工具全部装上。更有用的顺序是:先找到重复劳动,再检查一个工具怎样消除它、增加了什么约束,以及你能否在 CI 中复现它的行为。
本文用一个报价 CLI 贯穿选择过程。Cobra、Testify、Viper、Koanf、zap 和 slog 有本次固定版本实测;golangci-lint、Testcontainers、Wire、Air、GoReleaser 提供经过固定版本资料核对的入口和完整配置示意,明确没有运行。尤其是本环境没有 Docker,不能把一段容器测试代码写成已验证集成测试。
先看哪一种重复劳动需要工具
| 目前的问题 | 候选与入口 | 本文版本基线 | 本次验证 |
|---|---|---|---|
| 子命令、flags、帮助重复编写 | Cobra:Command、RunE | v1.10.1 | CLI、错误路径与测试 |
| 断言重复、失败信息不一致 | Testify:assert / require | v1.11.1 | require 断言 |
| 多种配置来源整合 | Viper:New、BindEnv、Get | v1.21.0 | JSON 配置、环境、显式覆盖 |
| 希望显式选择加载源与顺序 | Koanf:New、Load | v2.3.0;confmap v1.0.0 | 相同标量键的顺序覆盖 |
| 需要结构化日志 | slog / zap | Go 1.27.2 / v1.27.0 | 字段与 JSON 输出 |
| 多种检查器统一管理 | golangci-lint:run | 配置参考 v2.5.0 | 文档/配置核查,未运行 |
| 需要真实外部系统测试 | testcontainers-go | v0.39.0 | API/测试示意,未运行 |
| 大量依赖图连线重复 | Wire | v0.7.0 | 生成输入示意,未运行;项目已归档 |
| 修改代码后总要手动停启 | Air | v1.63.0 | 配置示意,未运行 |
| 多目标构建打包重复 | GoReleaser | v2.12.7 | 配置示意,未运行 |
这些是为了锁定本文 API 与配置的版本,不是“截至今天最新或最安全版本”的推荐清单。实际采用时还要核对支持的 Go 版本和已知问题;特别是静态分析工具,其发布二进制使用的 Go 版本、分析器和目标语言版本是否匹配,需要单独确认。golangci-lint 兼容性 FAQ
固定版本出处:Cobra、Testify、Viper、Koanf、zap。未执行工具的固定配置来源在对应段落给出。
CLI 开始有多个操作时,用 Cobra 管命令边界
如果只有一个简单开关,标准库 flag 已经够用。出现 shop quote、shop inspect 这样的多个操作后,Cobra 能集中管理命令树、参数验证、flags 与帮助输出。业务计算仍适合留在普通函数里,命令层负责转换参数和输出。Cobra v1.10.1
本例创建一个 quote 子命令:
go
quote := &cobra.Command{
Use: "quote [sku]",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if qty < 1 || qty > 100 {
return fmt.Errorf("quantity must be between 1 and 100")
}
_, err := fmt.Fprintf(cmd.OutOrStdout(),
"sku=%s qty=%d total=%d\n", args[0], qty, 250*qty)
return err
},
}实际运行:
text
$ go run . quote pen --qty=3
sku=pen qty=3 total=750
$ go run . quote pen --qty=0
quantity must be between 1 and 100
exit status 1RunE 返回错误,程序入口统一打印并决定退出码。例子关闭重复 usage/error 输出,避免同一个错误被 Cobra 与 main 各打印一次。cmd.OutOrStdout() 则让测试能注入 writer,不用替换整个进程 stdout。Command v1.10.1 API
代价是多了命令对象状态和一套框架约定。每个测试创建新命令,避免 flags 状态互相串用;真正复杂的业务不应该只能通过执行整个 CLI 来测试。本文没有生成 shell 补全或安装系统级命令。
Testify 减少断言样板,但不能替你决定测什么
go
root := newRoot()
root.SetOut(&out)
root.SetArgs([]string{"quote", "pen", "--qty=3"})
require.NoError(t, root.Execute())
require.Equal(t, "sku=pen qty=3 total=750\n", out.String())测试实际通过。require 在失败后立即终止当前测试流程,适合后续无法继续的前置条件;assert 报告失败后通常还能继续,让多个独立结果一次显示。require 的立即失败应在测试所运行的 goroutine 使用,后台任务应把错误传回该流程。Testify v1.11.1、require
| 选择 | 何时够用 | 要承担什么 |
|---|---|---|
if + t.Fatal | 简单判断,愿意自己写精确错误信息 | 少量重复代码 |
| assert / require | 常见比较多,希望统一表达与失败输出 | 测试依赖、API 约定和升级 |
| mock 框架 | 交互复杂,确实需要验证调用契约 | 预期设置可能绑定实现细节 |
Testify 带有 mock 不代表每个依赖都应该 mock。一个方法的小接口,手写 fake 往往更容易读;真实数据库语义则还需要集成测试。新增断言库解决的是表达成本,不能补救错误的预期或缺失的边界用例。
配置来源变多之后:选约定完整的 Viper,还是显式组合的 Koanf
只有几个环境变量时,LookupEnv 加解析、校验更直接。需要文件、环境、flags 等来源后,配置库能省下不少合并工作,但“到底哪个值生效”必须仍然可解释。
| 维度 | Viper | Koanf |
|---|---|---|
| 常用入口 | New、SetDefault、ReadConfig、BindEnv、Get、Unmarshal | New、Load、Get、Unmarshal |
| 来源组织 | 集成多种来源和优先级约定 | provider 提供数据,parser 解析格式,应用安排 Load |
| 本例覆盖方式 | 环境覆盖文件;显式 Set 再覆盖环境 | 后加载的同名标量覆盖前值 |
| 需要审查 | 键名映射、空环境值、优先级、并发读写 | provider/parser 版本、加载顺序、合并规则、并发边界 |
Viper 的优先级从高到低是显式 Set、flags、环境、配置文件、外部键值存储、默认值。默认不把空环境值当有效值,可用 AllowEmptyEnv 改变;读取 getter 时读取环境的机制也不能等同于冻结快照。Viper v1.21.0
本次先设置默认 8080,读取 JSON 配置值 9000,绑定示例环境变量值 9001,再显式 Set 9002:
text
viper env= 9001
viper explicit= 9002Koanf 通过两个 confmap provider 依次加载 port=9000、port=9001:
go
k := koanf.New(".")
k.Load(confmap.Provider(map[string]interface{}{"port": 9000}, "."), nil)
k.Load(confmap.Provider(map[string]interface{}{"port": 9001}, "."), nil)text
koanf later= 9001这段正文省去了错误检查以突出加载顺序,附录完整程序逐次检查 Load 错误。本次只验证相同标量键覆盖,复杂 map、数组、类型冲突要看所选版本的合并规则,不能从这一个结果推断所有 YAML 合并行为。Koanf v2.3.0、confmap v1.0.0
选择建议:愿意采用一套现成来源约定,可以选 Viper;希望加载步骤全部显式写出,可选 Koanf。两者最终都应转换并验证为应用配置。Viper 文档明确提示同一实例并发读写并不自动安全;热更新要发布一致快照,并处理连接池等资源的重建,不能让业务代码一边读取一边逐字段修改。Viper 并发说明
日志先选契约,再比较 slog 与 zap
slog 是标准库 Logger/Handler 接口,自带 JSON 与文本输出;zap 提供类型字段 Logger、便利的 SugaredLogger,以及可组合的 Core/Encoder 等机制。两者都能输出结构化事件,团队已有生态和实际性能需求往往比库名更重要。slog、zap v1.27.0
本例用同一个内存 buffer,分别输出 ready 事件和 port 字段,移除时间以固定结果:
text
zap {"level":"info","msg":"ready","port":9001}
slog {"level":"INFO","msg":"ready","port":9001}这是输出格式比较,没有做吞吐量、分配或真实磁盘测试,不据此给性能排座次。不同级别文字、时间和字段编码要与采集端约定,别只看到“都是 JSON”就认为切换完全无差异。
zap 的典型入口是 zap.NewProduction()、logger.Info(..., zap.Int(...))、logger.Sync();要更方便的参数形式可用 Sugar,代价和行为看对应文档。本例直接构造 Core 是为了精确控制输出格式,并检查 Sync 的错误。zap Logger
若项目没有额外需求,slog 能减少一个依赖;已有大量 zap 字段和后端集成的项目,也没有必要只因标准库出现替代而仓促重写。无论选谁,敏感信息、请求关联、输出阻塞与采样都需要单独设计。
把本地反馈缩短:golangci-lint 管检查,Air 管重建重启
这两个工具经常都在开发环境里运行,但职责不同。
正在绘制图示…
查看图源代码
flowchart LR
E["修改源码"] --> A["Air 观察文件变化"]
A --> B["执行 build.cmd"]
B -->|"成功"| R["停止旧进程并运行新程序"]
E --> L["golangci-lint run"]
L --> D["输出静态诊断"]
D --> E
B -->|"失败"| F["保留构建错误供修复"]
图中 Air 的旧进程行为还受配置控制;本例 stop_on_error=true 表示构建出错也停止旧程序,不保证服务持续可用。它用于开发反馈,不是生产高可用流程。Air v1.63.0 配置
golangci-lint:规则少而清楚,通常比全开更有用
最小 .golangci.yml:
yaml
version: "2"
linters:
default: none
enable: [govet, ineffassign, unused]入口为 golangci-lint run ./...。这份配置按 v2.5.0 的官方 reference 核对,只明确开启三项;不是在 Go 1.27.2 环境跑过该版本二进制。正式接入先选声明支持项目 Go 版本的工具版本,再固定二进制与配置,执行自己的检查用例。v2.5.0 配置参考、版本兼容 FAQ
收益是统一多个分析器的执行与报告,代价是更多配置、误报处理和升级变化。新增规则时看清检查范围,给例外留下原因,不要为了绿灯整包忽略;也不能把所有 lint 都过了当成业务测试已通过。
Air:没有把新代码热补丁进旧进程
附录的 .air.toml 让它执行 go build -o ./tmp/shop .,随后运行生成的程序。入口是 air -c .air.toml。exclude_dir 排除 tmp、vendor、dist,避免产物变化引发无意义的再次构建。Air 官方项目、固定版本配置
进程重启会失去内存状态,也可能重复执行启动副作用;需要外部持久化的开发数据要明确隔离。Air 可以减少敲命令的次数,不能替代程序的信号处理、资源清理和生产部署。本文没有安装 Air,也没有验证文件监视或重启行为。
fake 无法代表外部系统时,再引入 Testcontainers
Testcontainers 管理测试容器的生命周期,让测试可以面对真实 Redis、数据库或其他服务。它需要可用的容器运行环境和镜像获取能力;测试必须等待服务可用,失败后清理资源。Testcontainers Quickstart
v0.39.0 的常见入口是 GenericContainer,用 ContainerRequest 声明镜像、暴露端口与等待策略;随后获取实际 host 和映射端口,不能假设永远是 localhost:6379。固定 API、ContainerRequest
v0.39.0 的 GenericContainer 在创建或启动失败时也可能返回非 nil 容器,因此拿到非 nil 对象就先登记有限时长的清理,再处理初始化错误,避免漏掉部分创建的资源。固定版本失败行为
附录给一个完整但未执行的 Redis 测试:启动容器、等待端口、连接实际映射地址、发送 RESP PING、断言 PONG,最后清理。它使用固定标签 redis:7.4.2-alpine 作为教学输入;本次没有拉取或核验这个镜像,正式 CI 应选经过团队安全检查的版本并固定摘要。端口可连接只是一层就绪条件,协议级 PING 再检查应用响应。端口等待策略 v0.39.0
| 得到的能力 | 仍然没得到的能力 |
|---|---|
| 独立测试依赖、明确创建与清理 | 自动代表生产主从、故障切换或网络条件 |
| 真实协议、约束与驱动行为验证 | 自动保证测试数据隔离与断言充分 |
| 可写入 CI 的环境准备代码 | 免费的启动时间、磁盘、内存和镜像供应链 |
因此它适合验证 fake 无法覆盖的边界,不适合把每个纯函数测试都搬进容器。本文没有 Docker,容器示意从未启动,也没有生成容器测试的成功输出。
组装代码很多时看 Wire,但先看到归档状态
Wire 用构造函数作为 provider,通过 wire.Build(...) 描述如何构造结果,再生成普通 Go 初始化代码。手工注入仍是小项目更清楚的起点;重复依赖图很大时,代码生成才可能省下维护连线的工作。Wire v0.7.0 指南
附录保留两份输入:普通构造函数在 app.go;带 wireinject 构建标签的 wire.go 描述 injector。生成入口可固定为 go run github.com/google/wire/cmd/wire@v0.7.0。这只是读懂已有 Wire 项目的最小示意,本文没有下载运行生成器,也没有提供不存在的 wire_gen.go 或声称代码生成成功。
官方仓库在 2025-08-25 归档,README 明确不再维护,已于 2026-10-13 核对。对新项目,优先手工构造函数;如果确需自动化,再核查候选工具或 fork 的维护者、兼容性与生命周期能力。运行时容器与编译期代码生成是不同取舍,不能把另一个知名库名直接当作无成本替换。Wire 当前状态
多平台发布变成固定工作后,用 GoReleaser 编排制品
GoReleaser 可以组织构建矩阵、归档、校验和和发布流程。最小 .goreleaser.yaml 指定 main、binary、GOOS/GOARCH 与 CGO_ENABLED,完整配置在附录。v2.12.7 Go 构建配置
建议接入时先用 goreleaser check 检查配置,再做不发布的本地 snapshot:
bash
goreleaser release --snapshot --clean --skip=publish这是操作示意,本文未执行。--clean 会清理工具的输出目录,运行前确认其中没有需要保留的手工文件;--skip=publish 明确跳过发布阶段,不能据此推断其他自定义 hooks 没有副作用。固定版本 quick start
它减少多平台打包脚本的重复,但不能解决目标环境测试、cgo 交叉工具链、凭据权限或回滚兼容性。release job 的写权限应与普通 PR 检查分开,源码、构建参数、制品摘要要能对应;最终发布必须经过项目已有的授权流程。
采用一个工具前,先做一个能退出的试用
本文建议每次只回答四件事:它省掉哪段重复劳动;固定哪个版本和配置;最小样例能否通过;撤掉以后是否还有可读的代码或制品。先用 Cobra/Testify 跑通可测试的 CLI,再按实际需要加入配置库、日志后端、外部依赖测试和发布编排,比一次装齐工具更容易找到问题。
维护状态也需要持续核对。本文只对 Wire 明确给出归档结论;其他项目的固定版本资料和当前官方页面已查阅,不把“页面可访问”当作长期维护承诺,也没有声称这些历史基线都是最新安全版本。
证据与实际运行
2026-10-13,Go 1.27.2、Linux/amd64,Cobra v1.10.1、Testify v1.11.1,以及 Viper v1.21.0、Koanf v2.3.0/confmap v1.0.0、zap v1.27.0 的独立模块均经官方 Go 代理与 checksum database 下载核对,go mod verify 输出 all modules verified。两模块的 test、race、vet 通过,CLI 成功/失败输出及配置/日志输出如正文所示。完整 go.mod 和 go.sum 在下方。
未运行:golangci-lint、Air、Testcontainers、Wire、GoReleaser;没有容器、真实配置中心、日志采集平台或远端发布。其配置片段依据各段的固定版本官方资料,不是通过运行认证的整套 Go 1.27.2 工具组合。
完整可运行实验
按下列路径保存,使用 Go 1.27.2,设置 GOTOOLCHAIN=local、GOFLAGS=-buildvcs=false,执行 bash run_tools.sh。脚本只运行 cli 与 configlog 两个模块;后面的 recipes 目录不会被脚本执行。需要联网下载依赖,完整性校验不等同于漏洞扫描。
run_tools.sh
bash
#!/usr/bin/env bash
set -eu
cd "$(dirname "$0")/tools"
for dir in cli configlog; do
(
cd "$dir"
echo "== $dir pinned dependencies =="
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod tidy
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download all
go mod verify
go test -count=1 -v ./...
go test -count=1 -race ./...
go vet ./...
)
done
cd cli
echo '== cli success =='
go run . quote pen --qty=3
echo '== cli rejected input =='
if go run . quote pen --qty=0; then exit 1; else echo expected_failure=true; fi
cd ../configlog
echo '== configuration and logger comparison =='
go run .tools/cli/go.mod
go.mod
module example.com/v2/toolcli
go 1.27.2
require (
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)tools/cli/go.sum
text
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=tools/cli/main.go
go
package main
import (
"fmt"
"github.com/spf13/cobra"
"os"
)
func newRoot() *cobra.Command {
root := &cobra.Command{Use: "shop", SilenceUsage: true, SilenceErrors: true}
qty := 1
quote := &cobra.Command{Use: "quote [sku]", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
if qty < 1 || qty > 100 {
return fmt.Errorf("quantity must be between 1 and 100")
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "sku=%s qty=%d total=%d\n", args[0], qty, 250*qty)
return err
}}
quote.Flags().IntVar(&qty, "qty", 1, "number of items")
root.AddCommand(quote)
return root
}
func main() {
if err := newRoot().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}tools/cli/main_test.go
go
package main
import (
"bytes"
"github.com/stretchr/testify/require"
"testing"
)
func TestQuote(t *testing.T) {
var out bytes.Buffer
root := newRoot()
root.SetOut(&out)
root.SetArgs([]string{"quote", "pen", "--qty=3"})
require.NoError(t, root.Execute())
require.Equal(t, "sku=pen qty=3 total=750\n", out.String())
}
func TestBadQuantity(t *testing.T) {
root := newRoot()
root.SetArgs([]string{"quote", "pen", "--qty=0"})
require.ErrorContains(t, root.Execute(), "quantity must be")
}
func TestMissingSKU(t *testing.T) {
root := newRoot()
root.SetArgs([]string{"quote"})
require.Error(t, root.Execute())
}tools/configlog/go.mod
go.mod
module example.com/v2/configlogtools
go 1.27.2
require (
github.com/knadh/koanf/providers/confmap v1.0.0
github.com/knadh/koanf/v2 v2.3.0
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.27.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)tools/configlog/go.sum
代码块 · 69 行 · 点击收起 / 展开
text
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=tools/configlog/main.go
代码块 · 65 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"fmt"
"github.com/knadh/koanf/providers/confmap"
"github.com/knadh/koanf/v2"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"log/slog"
"os"
"strings"
)
func run() error {
v := viper.New()
v.SetDefault("port", 8080)
v.SetConfigType("json")
if err := v.ReadConfig(strings.NewReader(`{"port":9000}`)); err != nil {
return err
}
if err := os.Setenv("ENGINEERING_V2_PORT", "9001"); err != nil {
return err
}
if err := v.BindEnv("port", "ENGINEERING_V2_PORT"); err != nil {
return err
}
fmt.Println("viper env=", v.GetInt("port"))
v.Set("port", 9002)
fmt.Println("viper explicit=", v.GetInt("port"))
k := koanf.New(".")
if err := k.Load(confmap.Provider(map[string]interface{}{"port": 9000}, "."), nil); err != nil {
return err
}
if err := k.Load(confmap.Provider(map[string]interface{}{"port": 9001}, "."), nil); err != nil {
return err
}
fmt.Println("koanf later=", k.Int("port"))
var out bytes.Buffer
enc := zap.NewProductionEncoderConfig()
enc.TimeKey = ""
z := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(enc), zapcore.AddSync(&out), zap.InfoLevel))
z.Info("ready", zap.Int("port", 9001))
if err := z.Sync(); err != nil {
return err
}
fmt.Print("zap ", out.String())
out.Reset()
s := slog.New(slog.NewJSONHandler(&out, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}}))
s.Info("ready", slog.Int("port", 9001))
fmt.Print("slog ", out.String())
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}tools/configlog/main_test.go
go
package main
import "testing"
func TestRun(t *testing.T) {
t.Setenv("ENGINEERING_V2_PORT", "")
if err := run(); err != nil {
t.Fatal(err)
}
}完整配置示意:未执行
以下文件用于理解对应工具入口。lint、Air、GoReleaser 的配置需要放到目标应用模块根目录;本例可选 tools/cli。Wire 和容器测试各有独立 go.mod,尚未产生 go.sum,运行前需要在支持环境中下载并核验依赖。Wire 需额外执行生成器才有生成实现;Testcontainers 需 Docker 和经过核准的测试镜像。不要把这些文件的存在当作运行通过。
tools/recipes/.air.toml
toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/shop ."
bin = "tmp/shop"
include_ext = ["go"]
exclude_dir = ["tmp", "vendor", "dist"]
stop_on_error = truetools/recipes/.golangci.yml
yaml
version: "2"
linters:
default: none
enable:
- govet
- ineffassign
- unusedtools/recipes/.goreleaser.yaml
yaml
version: 2
builds:
- id: shop
main: .
binary: shop
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
- arm64tools/recipes/containers/go.mod
go.mod
module example.com/container-recipe
go 1.27.2
require github.com/testcontainers/testcontainers-go v0.39.0tools/recipes/containers/redis_test.go
代码块 · 63 行 · 点击收起 / 展开
go
package recipe
import (
"bufio"
"context"
"fmt"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
"net"
"testing"
"time"
)
func TestRedisPing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "redis:7.4.2-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp"),
}, Started: true,
})
if c != nil {
t.Cleanup(func() {
closeCtx, closeCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer closeCancel()
if err := c.Terminate(closeCtx); err != nil {
t.Error(err)
}
})
}
if err != nil {
t.Fatal(err)
}
host, err := c.Host(ctx)
if err != nil {
t.Fatal(err)
}
port, err := c.MappedPort(ctx, "6379/tcp")
if err != nil {
t.Fatal(err)
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port.Port()), 5*time.Second)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := fmt.Fprint(conn, "*1\r\n$4\r\nPING\r\n"); err != nil {
t.Fatal(err)
}
reply, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
t.Fatal(err)
}
if reply != "+PONG\r\n" {
t.Fatalf("reply=%q", reply)
}
}tools/recipes/wire/app.go
go
package recipe
type Store struct{}
type Service struct{ store *Store }
func NewStore() *Store { return &Store{} }
func NewService(s *Store) *Service { return &Service{store: s} }tools/recipes/wire/go.mod
go.mod
module example.com/wire-recipe
go 1.27.2
require github.com/google/wire v0.7.0tools/recipes/wire/wire.go
go
//go:build wireinject
package recipe
import "github.com/google/wire"
func Initialize() *Service {
wire.Build(NewStore, NewService)
return nil
}