题图

本文永久链接https://tonybai.com/2026/07/13/spf13-idiomatic-go

大家好,我是Tony Bai。

Steve Francia,也就是 Go 社区里人尽皆知的 spf13,履历几乎覆盖了整个 Go 生态的关键节点:Cobra、Viper、goHugo、Afero 的原作者,前 Google Go 核心团队产品负责人,MongoDB 与 Docker 的技术布道者。可以说,今天大多数 Go 命令行工具的骨架,或多或少都流淌着他的代码基因。

近日,他公开了一套名为 go-skills 的 Agent Skill 合集(github.com/spf13/go-skills),面向的不是人类读者,而是 AI 编程智能体。README 里的第一句话直接甩出了一个不留情面的判断:

“As Go has grown in popularity, developers are importing structural baggage from other languages—specifically Java and Spring Boot—and rebranding them as best practices."(随着 Go 越来越流行,开发者们正在把其他语言——尤其是 Java 和 Spring Boot——的结构性包袱搬进来,还把它们包装成"最佳实践”。)

他把矛头直接指向了那些流传甚广的"Go 项目结构指南",比如著名的 golang-standards/project-layout:深度嵌套的目录、service/repository/pkg/ 这类人为架构分层、笨重的 worker pool、复杂的 mock 框架。在 spf13 看来,这些根本不是 Go 的模式,而是套了 Go 语法外壳的 Java 模式,它们从根子上和 Go 的设计哲学作对。

更让他坐不住的是,大语言模型的训练语料里塞满了这些"伪最佳实践"。于是 AI 智能体写起 Go 代码,默认动作就是生成一份 Java-in-Go-syntax,你一反驳,它还振振有词地跟你辩论。go-skills 这套东西,说白了就是 spf13 写给 AI 看的"矫正教材"——用第一性原理的权威指导,让模型别再反射性地掏出 internal/ 垃圾抽屉、BDD 框架和静态 worker pool。

这篇文章不谈 AI Agent Skill 这套工程机制本身,而是把 go-skills 当作一份浓缩的观点文档,拆解出 spf13 心目中"地道 Go 代码"到底长什么样。

黄金法则:Clear is better than clever

整套仓库最后落脚在一句话上:

“Clear is better than clever. Go code should be boring in the best possible way—predictable, consistent, and immediately understandable to a new developer opening the file for the first time. When in doubt, delete the abstraction.”

翻译过来就是:清晰胜于巧妙。Go 代码应该以最好的方式显得"无聊"——可预测、一致、新人打开文件的第一眼就能看懂。拿不准的时候,删掉那层抽象,而不是加一层。

这不是一句空洞的口号,而是贯穿整个 skill 包所有具体建议的判断准则。下面我们按主题拆开看。

一、包组织:拍平优先,按领域而非按层次

spf13 给出的反模式非常具体:默认使用深度嵌套的目录树,或者过度依赖 internal/ 目录来人为地制造"整洁架构"式的分层。这类做法会导致循环依赖和难以导航的代码库。

他给出的判断路径分三步:

第一步,单包起步。 如果你在做一个微服务或简单工具,把所有代码都放在根目录(或与 main.go 平级)。只有当你真正需要一个新的命名空间来厘清代码、或者需要解耦一个严格独立的领域时,才创建新包。

第二步,internal/ 要谨慎使用。 internal/ 目录有明确的编译器语义:阻止其他模块导入其中的代码。对应用程序而言,反正没人能导入你的可执行程序,用 internal/ 大多数时候只是徒增路径深度;对库而言,internal/ 应该省着用,只留给那些需要在自己的多个包之间共享导出类型、但绝对不希望终端用户依赖这些类型的复杂子系统。

第三步,服务型应用用领域包(domain package),而不是分层包。 规则依然是"只深一层"——不要 internal/ 嵌套,不要整洁架构式的分层。判断一个领域是否该独立成包的信号很简单:这个领域能不能用一句话说清楚,并且它对其他包一无所知? 如果是,它就配得上一个独立的包。

myservice/
├── main.go        # 只做装配,不含业务逻辑
├── config/        # 配置结构体、环境变量加载
├── auth/          # 身份验证、会话中间件
├── db/            # 数据存储客户端 + 全部查询
├── storage/       # 对象存储(S3、R2、GCS)
├── billing/       # 支付服务商 + 信用账本
├── jobs/          # 任务生命周期 + 队列派发 + worker 处理(同一领域,一个包)
├── web/           # HTTP handler + HTML 模板 + 静态资源(设计上就该耦合在一起)
├── transcribe/    # 领域特定的处理逻辑——独立的纯函数

几条具体的纪律值得摘出来:

  • 每个包只有一个清晰的职责,按"它做什么"命名,而不是按"它是哪一层"命名(应该是 jobs/,而不是 service/);
  • 包与包之间不应该横向互相导入,出现循环依赖就是边界划错了的信号,main 才是装配的地方;
  • 总是一起出现的相关子关注点留在同一个包里,比如任务创建和 worker 处理器同属 jobs/,因为它们共享同一个任务生命周期领域;
  • HTTP handler 和它渲染的模板放在一起(web/)——它们本来在设计上就是紧耦合的;
  • 不要创建 utils/helpers/common/ 这类包——它们是职责不清的症状。

spf13 把整洁架构(Clean Architecture)/ DDD 分层(service/repository/controller/domain/)明确列为要拒绝的反模式:这类按层命名的包会导致循环导入、逼出过度的接口,在 Go 里增加不了任何清晰度。领域命名的包(auth/billing/jobs/)才是"太扁平"和"过度工程化"之间正确的折中点。

二、接口设计:先写具体类型,接口是被发现的

Go 社区一句老话是"接受接口,返回结构体",spf13 把这句话拆成了三条更细的规则。

接口是被发现的,不是预先设计的。 先写具体类型。只有当你发现有多种类型需要被某个消费者互换使用时,才定义接口。

接口定义在使用它的地方,而不是实现它的地方。 这一条直接决定了包与包之间的耦合方向:

// processor/processor.go

// 地道写法:消费者精确定义自己需要什么。
// 具体的 UserStore 甚至不需要知道这个接口的存在。
type UserFetcher interface {
    FetchUser(id string) (*User, error)
}

type Processor struct {
    fetcher UserFetcher
}

入参要小接口,出参要具体类型。 入参尽量用最小的接口(比如 io.Reader 而不是 *os.File),但返回值应该是具体的结构体,这样调用方不需要靠类型断言才能拿到具体字段和方法。

值得一提的是,go-spec-reviewer 这个子技能(专门用来在写代码之前审阅设计文档)把接口检查列进了正式的检查表:接口是否由消费方定义?是否足够小(1–3 个方法)?是不是真的存在多态需求才引入的?——这几乎是把上面这条原则做成了可执行的 checklist。

三、错误处理:值,而不是异常

这部分篇幅不长,但态度很明确:

“Errors aren’t exceptions to be caught; they are values to be handled. Check them explicitly.”

错误不是用来"捕获"的异常,而是需要被处理的值,要显式检查。返回错误时要为其附加上下文,而不是为了留堆栈跟踪:

data, err := os.ReadFile(path)
if err != nil {
    return fmt.Errorf("loading config file %s: %w", path, err)
}

配合"Clear is better than clever"的第三条核心原则——尽早返回,把主干逻辑留在最左边:立刻处理错误和边界情况并返回,不要用 else 包裹主逻辑,函数的"happy path"永远不应该被缩进。

四、并发:用 channel 编排,而不是用 mutex 加锁

spf13 直接把"笨重的静态 Worker Pool"列为反模式:Go 的调度器已经足够高效,你不需要像其他语言里手动管理 OS 线程那样,去手动管理一池 worker。

他给出的替代路径:

能用 channel 传递数据,就不要用 mutex 保护共享数据。 原话是 “channels orchestrate execution; mutexes serialize execution”——channel 是用来编排执行的,mutex 是用来串行化执行的,两者语义不同,别混用。

限制并发度用 errgroup.SetLimit,别手搓信号量或死板的 worker pool:

func FetchAll(ctx context.Context, urls []string, maxConcurrent int) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(maxConcurrent)

    for _, url := range urls {
        g.Go(func() error {
            return fetch(ctx, url)
        })
    }

    return g.Wait()
}

顺带提一句版本细节:自 Go 1.22 起循环变量按迭代绑定,永远不要再写那句古老的 url := url 补丁代码;不需要错误传播时,Go 1.25 引入的 sync.WaitGroup.Go 直接省掉了 Add/Done 的样板代码。

每一个 go func() 都必须清楚知道自己怎么停下来,通常靠 context.Context 或一个被关闭的 channel 来托管。这条纪律在 go-spec-reviewer 的检查表里同样出现了:“goroutine 有清晰的归属,并且有明确的关闭路径吗?”

五、测试:Go 测试就该是 Go 编程

spf13 对"重型 BDD 框架(比如 Ginkgo)或者复杂的 mock 生成工具"同样不客气:Go 的测试本质上就应该是写 Go 代码,而不是引入一整套 DSL。

具体落地成几条实践:

表驱动测试是绝对标准。 用一个结构体切片装输入和期望输出,配合 t.Run() 遍历:

func TestParseConfig(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        wantErr bool
    }{
        {"valid config", "port=8080", false},
        {"invalid format", "port=abc", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := ParseConfig(tt.input)
            if (err != nil) != tt.wantErr {
                t.Fatalf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

用 fake/stub,而不是重型 mock。 依靠 Go 隐式接口的天然优势,手写简单的 fake,让测试依赖保持轻量、测试逻辑保持透明。

文件系统要抽象成接口。 不要在业务逻辑深处硬编码 os 包调用,而是为文件系统接受一个接口,让测试可以完全在内存里跑,不碰磁盘——这正是 spf13 自己那个 afero 库存在的意义。测试里注入 afero.NewMemMapFs(),彻底消除磁盘 I/O,避免又慢又不稳定的 flaky test。

复杂结构体比较用 cmp,别用 reflect.DeepEqual github.com/google/go-cmp/cmp 能给出可读性更好的差异输出。

拥抱 Go 1.24+/1.25 的测试新特性:

// 测试作用域的 context,测试结束时自动取消
func TestFetch(t *testing.T) {
    ctx := t.Context()
    ...
}

// b.Loop() 取代经典的 b.N 循环——更准确,避免编译器把被测调用优化掉
func BenchmarkParse(b *testing.B) {
    for b.Loop() {
        Parse(input)
    }
}

对于并发代码,Go 1.25 的 testing/synctest 用一个假时钟做到确定性测试:

func TestTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(t.Context(), time.Second)
        defer cancel()
        time.Sleep(2 * time.Second) // 瞬间完成——当所有 goroutine 阻塞时假时钟会自动前进
        if ctx.Err() == nil {
            t.Fatal("expected timeout")
        }
    })
}

一条硬规矩:永远不要为了"等一个 goroutine"在测试里写 time.Sleep(100 * time.Millisecond),该用 synctest、channel 或显式同步。

六、泛型:解决重复算法,不是拿来搭类型体系

Go 1.18 引入泛型之后,社区里最容易踩的坑就是把它当成 Java 的泛型来用。spf13 给出的判断标准非常直白:

“Generics exist to eliminate duplicated algorithms, not to create type hierarchies. If you are thinking about generics in terms of inheritance or polymorphism, stop—you are writing Java.”

泛型存在的意义是消除重复的算法,不是用来创建类型体系。如果你是在用继承或多态的思路思考泛型,打住——你在写 Java。

该用的场景:同一个算法要作用在多个具体类型上。

func Map[S, T any](slice []S, f func(S) T) []T {
    result := make([]T, len(slice))
    for i, v := range slice {
        result[i] = f(v)
    }
    return result
}

不该用的场景:拿泛型接口去做多态,那本质上还是 Java:

// 不好:用泛型接口做多态——这是 Java
type Repository[T any] interface {
    Find(id string) (T, error)
    Save(entity T) error
}

// 好:只写你真正需要的具体接口
type UserStore interface {
    FindUser(id string) (*User, error)
    SaveUser(u *User) error
}

几条附加纪律:不要创建泛型基类、泛型 service、泛型 repository;不要用 any 当约束去表达"我还不知道具体类型",这是设计层面的坏味道;需要 map key 或相等性判断时用 comparable;需要 <> 比较时用 cmp.Ordered;先写具体实现,只有当同一段逻辑在 3 个以上类型间重复出现时才泛化。

七、标准库优先:LLM 最容易漏掉的那部分知识

这一节其实是 go-skills 里信息密度最高、也最能体现"AI 训练数据滞后"这个痛点的部分。spf13 直言,LLM 经常推荐第三方工具或手写辅助函数,而这些能力早在 Go 1.21 之后就已经进了标准库。他列出的清单几乎是一份"Go 1.21–1.25 新特性速查表":

  • slices 包(1.21): slices.Containsslices.Sortslices.SortFuncslices.Reverseslices.Compactslices.Cloneslices.Concat(1.22)——永远不要在 slices.Sort(s) 已经存在的情况下还手写 sort.Slice
  • maps 包(1.21,迭代器 1.23): maps.Keysmaps.Valuesmaps.Clone,常见的一行式惯用法是 keys := slices.Sorted(maps.Keys(m))
  • cmp 包(1.21): cmp.Comparecmp.Or(替代三元表达式的变通写法)以及内置的 min/max
  • errors.Join(1.20): 合并多个错误不再需要任何第三方 multierr 库,且能正确配合 errors.Is/errors.As
  • 迭代器 iter 与 range-over-func(1.23):iter.Seq[T] 暴露序列而不必分配切片,调用方用普通的 range 消费——不要自己发明 Next()/HasNext() 那一套,那是 Java 的做派。
  • math/rand/v2(1.22): 永远导入新版而不是旧版,自动播种,API 更干净。
  • encoding/jsonomitzero(1.24): 能正确处理 time.Time{} 这类"零值结构体",这是 omitempty 一直没搞对的地方。

在 HTTP 层面,他同样点名批评了一个常见的路由选择反射动作:

“LLMs reflexively recommend gorilla/mux or chi for any routing beyond the trivial. Since Go 1.22, the standard net/http ServeMux handles method and path-parameter routing natively.”

自 Go 1.22 起,标准库的 net/http.ServeMux 已经原生支持方法级路由和路径参数:

mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    // ...
})

只有真正需要具名路由生成或正则约束时才上 chi 或 gorilla/mux——中间件需求本身撑不起引入一个框架的理由,因为中间件在 Go 里就是一个函数:func(http.Handler) http.Handler,靠组合而不是框架来实现。

另一个极容易被 AI 生成代码忽略、但生产事故率极高的点是超时设置:

http.ListenAndServe(addr, mux) ships with no timeouts—a single slow client can hold a connection open forever (slow-loris). LLM-generated servers almost never set these.”

http.ListenAndServe 默认不带任何超时——一个慢客户端就能把连接永远占住(slow-loris 攻击)。而 LLM 生成的服务端代码几乎从不设置这些参数:

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       120 * time.Second,
}

这一节最后落在了一条关于调试心态的纪律上,读起来更像是写给 AI 智能体本身的:Go 工具链几乎从来不是 bug 的根源。 当代码修改后错误依然存在时,按优先级排查的顺序应该是:改动本身没修对逻辑、改错了文件或函数、还有第二处相同 bug 的调用点没更新、错误其实来自另一条代码路径——而不是怀疑 go build 用了脏缓存,动辄建议 go clean -cache

八、CLI 架构:把 Cobra/Viper 用回它们本来的样子

作为 Cobra 和 Viper 的原作者,spf13 单独拿出一整个子技能来讲"命令行应用该怎么搭",核心理念叫 Command-First 架构:把应用的二进制文件当作一个命令路由器,CLI 框架只负责 flag、参数和路由,核心业务逻辑对 CLI 层完全无感知,这样才能保证高可测试性和可复用性。

配套的是统一配置理念:Viper 作为唯一真相来源,把默认值、配置文件、环境变量、命令行 flag 合并成一份一致的状态,再传给应用逻辑。

最容易被忽视、但被反复强调的一条是:命令是构建出来的,不是声明出来的。 用工厂函数(NewRootCmd())搭建命令树,而不是包级别的 var 声明。原因很直接:全局变量会让命令不可测试(状态会在测试之间泄漏),也没法作为库被嵌入使用。一个搭建良好的 CLI 里唯一的全局,应该只有 main.go 里那一次对工厂函数的调用。

func Execute() error {
    return NewRootCmd().Execute()
}

func NewRootCmd() *cobra.Command {
    v := viper.New()

    rootCmd := &cobra.Command{
        Use:           "mycli",
        SilenceUsage:  true,
        SilenceErrors: true,
        PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
            return initConfig(v, cmd)
        },
    }

    rootCmd.AddCommand(NewServeCmd(v))
    rootCmd.AddCommand(NewBuildCmd(v))
    return rootCmd
}

这个理念直接体现在测试写法上。spf13 明确点名了两个反模式:一是通过编译出二进制文件、再用 os/exec 去跑测试——极慢、极脆弱,还没法统计覆盖率;二是直接执行某个包级别的子命令变量——因为 cmd.Execute() 总是从命令树的开始执行,对全局命令做修改会在测试之间泄漏 flag 状态,这也正是为什么命令必须由工厂构建。

func TestServeCommand(t *testing.T) {
    tests := []struct {
        name    string
        args    []string
        want    string
        wantErr bool
    }{
        {"default port", []string{"serve"}, "listening on :8080", false},
        {"custom port", []string{"serve", "--addr", ":9090"}, "listening on :9090", false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            buf := new(bytes.Buffer)
            root := cmd.NewRootCmd() // 每个测试都是全新的命令树 + 全新的 Viper
            root.SetOut(buf)
            root.SetErr(buf)
            root.SetArgs(tt.args)

            err := root.ExecuteContext(t.Context())
            if (err != nil) != tt.wantErr {
                t.Fatalf("Execute() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

其他几条工程细节同样值得记下来:用 RunE 而不是 Run,让错误沿执行链正确向上传播,而不是靠散落各处、会绕过 deferlog.Fatal;根命令上设置 SilenceUsage: trueSilenceErrors: true,让 main.go 只打印一次错误,而不是每次报错都甩一整屏帮助文本;位置参数的数量校验交给 Cobra 的 Args 系统(cobra.ExactArgs(1) 等),不要在 RunE 内部手写校验逻辑;命令输出一律走 cmd.OutOrStdout(),直接用 fmt.Printf 会绕过 SetOut,导致测试没法捕获输出。

engine(或任何核心逻辑包)里绝对不能出现 github.com/spf13/cobragithub.com/spf13/viper 的导入——这是 Command-First 架构能够成立的底线。

九、把这些标准做成"审阅关卡":go-spec-reviewer

go-skills 里还有一个很值得单独说一说的设计:go-spec-reviewer。它不是用来审代码的,而是用来在写代码之前审设计文档的——趁改起来还便宜的时候,把过度工程化、缺失的错误路径、接口误用、违反 Cobra/Viper 约定这些问题挡在实现之前。

它给自己设定的审阅人格很有意思:

“Think like Rob Pike reviewing this design: is it simple? Does it do one thing well? Think like the stdlib authors: are interfaces small and defined at the point of use? Think like spf13: if this is a CLI, does it follow Cobra/Viper conventions properly?”

像 Rob Pike 一样审:这个设计够简单吗?它是不是把一件事做好了?像标准库作者一样审:接口够小、定义在使用点上吗?像 spf13 一样审:如果这是个 CLI,它有没有正确遵循 Cobra/Viper 的约定?

它的检查表把本文前面提到的所有原则,逐条落成了可执行的问题清单:简洁性(是否存在只有一个实现的抽象)、依赖(每一个第三方依赖是否都相对标准库方案做了论证)、接口(是不是消费者定义的、是不是够小)、错误处理(是否显式返回并正确 wrap)、并发(goroutine 是否有明确归属和关闭路径)、包设计(是否存在 utils/common/ 这类提案)、YAGNI(抽象是不是被实际需求驱动,而非提前臆测的未来需求)。

这一层设计本身也传递了一个信号:idiomatic Go 不该是代码写完之后靠 code review 才发现的问题,而应该是设计阶段就能被结构化检验的标准。

写在最后:为什么这份"教材"是写给 AI 的,也是写给人的

回到最初的问题:spf13 心目中的 idiomatic Go 是什么样子?把上面九条拼起来看,其实是一套高度自洽的价值排序——清晰优先于巧妙,具体优先于抽象,标准库优先于框架,组合优先于继承,channel 优先于锁,值优先于异常。

这套排序本身并不新鲜,Effective Go、Go 标准库源码、历年 GopherCon 的演讲里都能找到同样的主张。

go-skills 真正的价值,在于它把这些散落多年的共识,第一次以"喂给 AI 看"的姿态,浓缩成了一份可以被检索、被引用、被强制执行的文档——本质上是在做一件所有资深 Gopher 私下都在做、却很少有人系统写下来的事:把"这不是 Go 的做法"这句话,讲清楚为什么。

对于人类 Go 开发者而言,即便你从不打算把这套 skill 接进 Claude Code 或 Copilot,把它当成一份"资深工程师的代码审查心法"来读,同样值得——它提醒我们,AI 生成代码能不能保持地道,最终还是取决于人是否真正理解、并愿意坚持这套判断标准。技术品味这件事,终究不能外包给模型,只能内化给人。

本文所引用的原则与代码示例均来自 spf13/go-skills 仓库(README.md、go/SKILL.md、cobra-viper/SKILL.md、go-spec-reviewer/SKILL.md),仓库以 MIT 协议开源。


还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 从0 开始构建 Agent Harness 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


还在为“复制粘贴喂AI”而烦恼?我的新专栏 AI原生开发工作流实战 将带你:

  • 告别低效,重塑开发范式
  • 驾驭AI Agent(Claude Code),实现工作流自动化
  • 从“AI使用者”进化为规范驱动开发的“工作流指挥家”

扫描下方二维码,开启你的AI原生开发之旅。


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。