从 Java 转 Go 的人,第一次打开 kubernetes/kubernetes 的源码,大多会做同一个动作:找 controller 在哪、service 在哪、DAO 在哪。毕竟在 Spring 的世界里,代码就住在这三层里。但 ls 的结果是一套陌生的词汇:cmd、pkg、staging、api、vendor……一个熟悉的名字都没有。
更添乱的是,搜索引擎会把我们带到一份 “Standard Go Project Layout”,看起来这就是官方答案;而 Go 的技术负责人 Russ Cox 专门去那个仓库开过一个 issue,标题就叫 “this is not a standard Go project layout”1。连「标准布局」本身都是一场误会,那 Go 大工程的代码到底按什么组织?
我们把问题拆开:
- cmd/、pkg/、internal/ 这三个随处可见的目录,各是什么来历?谁定的?
- 为什么 etcd 顶层连 cmd/ 和 internal/ 都没有?
- k8s 里明明有个 pkg/controller——它是 Java 意义上的 controller 吗?那 service、DAO 的对应物在哪?
- 有没有 Go 项目真的按 handler/service/db 分层?
- prometheus 为什么又是另一种答案?
本文顺着这五个问题往下走,样本是四个真实的大工程:kubernetes、multica(一个用 Go 写的 AI agent 协作平台)、etcd、prometheus。文中所有目录名、文件名和行号,都来自 2026-10-31 当天各仓库的主分支。
一、cmd、pkg、internal:三个目录,三种来历
先看 k8s 的顶层:
$ ls kubernetes/api/ build/ cluster/ cmd/ docs/ hack/ pkg/ plugin/staging/ test/ third_party/ vendor/ …(staging 顺带交代一句,后面不再展开:它是「待拆分仓库的孵化区」,staging/src/k8s.io/ 下的代码是唯一权威副本,由 publishing-bot 单向发布成 client-go、apimachinery 等独立仓库2。)
cmd/ 是多二进制的收纳处。 cmd/ 下每个子目录编译出一个二进制,目录名就是二进制名。k8s 的 cmd/ 下有 kube-apiserver、kube-controller-manager、kube-scheduler、kubelet、kubectl、kubeadm 等组件入口,外加十几个代码生成工具。这些入口文件统一的特点是非常薄:cmd/kube-apiserver/apiserver.go 的 main 全部有效代码只有三行(NewAPIServerCommand → cli.Run → os.Exit),cmd/kubelet/kubelet.go 只有 1488 字节,真正的装配都在同目录的 app/ 子包里。Go 官方文档对服务器项目的建议正是这个分工:命令放 cmd/,逻辑放 internal/3。
internal/ 是唯一有编译器背书的目录语义。 它不是惯例,是语言特性。Go 1.4 的发布说明写得很清楚:go 命令引入了一种机制,internal 包「不可以被其所在源码子树之外的包导入」4。现行工具链文档的表述更精确:internal 目录里(及其之下)的代码,只有与 internal 共享同一上级 import path 的代码才能导入5。大项目用它把「公开 API」和「私有实现」隔开——比如 multica 的 server module 把全部 49 个业务包都放进了 internal/。
pkg/ 是争议最大的一个。 它的流行来自那份 “Standard Go Project Layout”:把「可被外部引用的库代码」放进 pkg/6。但这份「标准」的 README 自己声明它不是官方标准,Russ Cox 在 issue #117 里说得更直白——Go 生态里绝大多数包并不放在 pkg/ 子目录里,他认可的最小布局只有三条:根目录放 LICENSE、go.mod、Go 代码,「That’s it. That’s the “standard”.」1。官方的组织哲学是按功能内聚切包,包名说明「提供什么」而不是「装了什么」7,util、common、misc 这类名字被官方博客点名反对8。事实上,prometheus 和 etcd 的顶层都没有 pkg/;multica 保留了 pkg/,但里面放的是「跨进程共享的契约」(sqlc 生成的 DAO、daemon 与 server 之间的 WebSocket 协议),语义已经和 project-layout 的本意不同了。
| 仓库 | 顶层目录风格 | 二进制入口 | 「私有实现」的围栏 |
|---|---|---|---|
| kubernetes | cmd + pkg + staging,单 module | cmd/<组件>/,main 极薄 | 公开面走 staging 发布;pkg/ 不对外 |
| multica(server/) | cmd + internal + pkg,单 module | cmd/server、cmd/multica | internal/ 圈住 49 个业务包 |
| prometheus | 21 个功能包平铺,没有 pkg/ | cmd/prometheus、cmd/promtool | internal/ 很小,近乎无围栏 |
| etcd | 顶层目录 = module(go.work × 14) | module 根的 main.go | go.mod 依赖图 + 个别 internal/ |
cmd/ 和 internal/ 看似标配,其实各自回答一个具体问题:有几个二进制要放?哪些包不许外人碰?
cmd/ 和 internal/ 看起来是大项目的标配。但打开 etcd,这两个目录都不在——它们解决的问题消失了吗?
二、etcd:module 边界取代了 cmd/ 和 internal/
先看现象,etcd 的顶层:
$ ls etcd/api/ cache/ client/ contrib/ etcdctl/ etcdutl/ pkg/server/ tests/ tools/ go.work …没有 cmd/,顶层也没有 internal/。原因藏在根目录的 go.work 里:这个仓库不是一个 Go module,而是 14 个 module 组成的 workspace9——server/、client/v3/、api/、etcdctl/、etcdutl/、pkg/、tests/……每个顶层目录自带 go.mod,独立版本、独立发布。
cmd/ 为什么消失了。 回想 cmd/ 的本质:它是「一个仓库要产出多个二进制」时的集中收纳。etcd 把每个二进制拆成了独立 module——server module 产 etcd,etcdctl module 产 etcdctl,etcdutl module 产 etcdutl。一个 module 只产出一个二进制时,main 包放在 module 根就是唯一合理的位置,cmd/ 反而成了多余的一层嵌套。server/main.go 确实就在那里,而且同样薄:整个文件只是 etcdmain.Main(os.Args) 的壳,文件注释自称 “simple wrapper of the real etcd entrypoint”10。k8s 恰好是反面的例子:单一大 module 要产出几十个二进制,才需要 cmd/ 来聚类。
internal/ 的消失要分两层说。 在 module 之间,围栏换了角色:go.mod 的依赖图本身就是可见性边界。client/v3 的 go.mod 只 require api/v3、client/pkg/v3 和 grpc 等少数依赖,根本不 require server/v3——外部项目(比如 k8s 的 apiserver)import client/v3 时,server 的 raft、bbolt 这些重依赖连进入依赖图的机会都没有。反过来,etcdutl 是快照恢复工具,它的 go.mod 老老实实 require 了 server/v3、bbolt 和 raft。谁轻谁重,go.mod 里写得明明白白。
但在 module 内部,internal/ 并没有被消灭:client/v3/internal/ 下面就躺着 endpoint/ 和 resolver/ 两个包,是 gRPC 端点解析的私有实现11。module 边界替代的只是 internal/ 在 module 之间的角色;module 之内想做细粒度隐藏,internal/ 依然是 Go 给的唯一工具。至于 server module 完全不用 internal/,更像历史惯例而非设计——etcdutl 既然能 require 整个 server/v3,server/etcdserver、server/storage 这些包在技术上就是「事实上的公开 API」。
目录约定搞清楚了。回到最初的问题:controller 在哪?k8s 里明明有个 pkg/controller/——但打开它,会发现这个词被重新定义了。
三、k8s 的 controller:一个被重新定义的词
抱着找「接 HTTP 请求的那个 controller」的预期,打开 pkg/controller/deployment/deployment_controller.go,看到的 DeploymentController struct 里没有路由、没有 HTTP,最显眼的字段是一个队列:queue workqueue.TypedRateLimitingInterface[string]12。
原因是在 k8s 的语境里,controller 不是 MVC 的 controller,而是控制循环(control loop)。kube-controller-manager 的启动命令对自己的描述原文就是定义:“a controller is a control loop that watches the shared state of the cluster through the apiserver and makes changes attempting to move the current state towards the desired state”13。pkg/controller/ 下约 50 个目录——deployment、replicaset、statefulset、cronjob、namespace、garbagecollector……——全是这样的循环,由 cmd/kube-controller-manager 统一注册、统一启动。注册入口是 app/controller_descriptor.go 的 NewControllerDescriptors();读旧资料时留意,它以前叫 NewControllerInitializers,后来重构成了现在的名字14。
deployment controller 是这种循环的教科书样例,结构分三段(图 3)。它给 Deployment、ReplicaSet、Pod 三个 informer 注册事件回调,回调里不写任何业务逻辑,只做一件事:把对象的 key 放进队列。另一边,若干 worker 从队列里取 key,调 syncDeployment:从 informer 的本地缓存读对象、对比期望状态与实际状态、决定滚动更新还是扩缩容;失败就把 key 限流重入队,最多重试 15 次12。informer(监听 + 缓存)、workqueue(去重 + 限流重试)、sync loop(纠偏),三段拼出一个完整的 reconcile loop。
那 HTTP 请求路径上有什么?apiserver 收到「PUT 一个 Deployment」时,走的是认证 → 鉴权 → 准入 → 存储,全程无状态、没有「业务逻辑」可言——真正的业务(该建几个 Pod、怎么滚动)发生在上面那个异步循环里。请求路径上唯一像 Java 分层里某个角色的,是 pkg/registry:按 API group 和资源组织的 REST storage 层,core 组下 pod、service、node、namespace、configmap……每种资源一个目录,把 CRUD 收口到 apiserver 的 generic registry,最终落到唯一的持久化后端 etcd15。(严谨起见标注一下:各资源目录内部的 storage/storage.go 文件名是按惯例的推断,本次只核实到目录层级。)
对齐一下两边的词汇:Java 的 controller ≈ apiserver 的 REST / registry 层(接请求,但不做业务);Java 的 service ≈ 不存在——它的职责整体转移给了 pkg/controller 里的异步控制循环;Java 的 DAO ≈ pkg/registry + etcd3 store。
难道 Go 项目都没有 service 层?那倒未必——换一个业务系统看看。
四、multica:长回三层的 Go 项目
multica 是用 Go 写的 AI agent 协作平台,它的 server module 是教科书式的 cmd + internal + pkg。打开 internal/,两个包的名字毫不掩饰:handler、service。
以「评论里 @ 了一个 agent,把它叫起来干活」为例,整条调用链可以逐行对上(图 4):
- handler 层:internal/handler/comment.go 的
CreateComment(:1629)接请求;computeCommentAgentTriggers(:2636)算出这条评论该叫醒谁,@提及由 util.ParseMentions 解析16。 - service 层:handler 调
TaskService.EnqueueTaskForMention,最终落到 internal/service/task.go 的enqueueMentionTaskWithCommentPlan(:1464)——事务、去重(哨兵错误ErrDuplicatePendingTask)、入队都在这里17。 - DAO 层:领取任务时
claimTask(:3612)调用qtx.ClaimAgentTask。而ClaimAgentTask不是手写的 Go 函数——它是 sqlc 从 pkg/db/queries/agent.sql:734 那段 SQL(-- name: ClaimAgentTask :one)生成的类型安全方法18。queries/ 下 67 个 .sql 文件的角色几乎就是 MyBatis 的 mapper XML,generated/ 目录是生成的 DAO。
对照 Spring 官方文档的定义——@Controller、@Service、@Repository 分别是表现层、业务层、持久层的 @Component 特化19——multica 的 handler / service / db 几乎一一对应。
和 Java 严格三层相比,multica 有两处明显的「Go 式松动」。一是边界:写路径和业务编排走 service,简单的读路径 handler 直接持有 h.Queries 查 db,不经过 service——在 Java 的严格分层里,这是会被 code review 拦下来的写法。二是装配:没有框架扫描,也没有依赖注入容器,cmd/server/main.go 手工 new 出一切(db.New(pool),再逐个传给 service);路由集中在 130KB 的 router.go 里注册,那张路由表的角色约等于一整份 @RequestMapping 清单。
为什么这个项目「敢」分层、也需要分层?因为它是 CRUD 业务系统:同步请求路径上真的有编排要做——解析提及、计算该触发谁、去重、入队、保证同一 (issue, agent) 的任务串行。业务形态决定了分层的收益。
那是不是所有 Go 项目都该抄这三层?prometheus 给出了否定的答案。
五、prometheus:没有 service 层生态位的系统
prometheus 的顶层是另一种风格:21 个目录全部平铺——scrape/、rules/、promql/、storage/、tsdb/、web/、discovery/、notifier/……没有 pkg/,cmd/ 下也只有 prometheus 和 promtool 两个入口。
它的 HTTP 层看起来像 controller:web/api/v1/api.go 一个 97KB 的文件装下全部 v1 端点,type API struct + NewAPI(...) + Register(router) 逐条注册路由20。但往下看一层就断了:没有 service 层。NewAPI 的 40 多个参数直接是各子系统的本尊——promql.QueryEngine、storage.Queryable、scrape 和 rules 的 retriever——handler 里直接 api.QueryEngine.NewInstantQuery(...) 然后执行,web 层只剩下参数解析、错误码映射、响应序列化。
业务逻辑住在各子系统自己的目录里,而且是纵切自治的。以 scrape/ 为例:manager.go 管配置重载和 target 集合,scrape.go(81KB)是抓取循环,target.go 是领域对象,metrics.go 是这个子系统自己的监控指标——一个目录就是一个自包含的小系统,对外只暴露一个 Manager,等着 cmd/prometheus/main.go 那个 93KB 的装配文件把它接进全局21。rules/、discovery/(下挂约 30 个服务发现来源)、notifier/ 都是同样的形态。storage/ 与 tsdb/ 的分离则是另一个标本:storage/interface.go(31KB)定义 Queryable / Appendable 接口,tsdb/ 是内置实现,web、promql、rules 只依赖接口,实现由 main 在装配时注入。
prometheus 为什么没有 service 层的生态位?因为它的「业务」本来就不是「一次请求编排多个领域对象」,而是各子系统永不停歇的主循环:抓取、评估规则、发告警。HTTP API 只是探视这些循环的窗口,窗口后面没有等待编排的东西,service 层自然无从谈起。
这种切法在 Go 社区有理论表述。Ben Johnson 的 Standard Package Layout 主张领域类型放根包、子包按依赖分组(postgres、http 各做一个适配器),并且明确要求根包不依赖应用内任何其他包22;Clean Architecture 在 Go 里的实践(ports / adapters / application / domain)同样把分层规则定义成依赖方向:外层可以引用内层,反过来不行23。也就是说,Go 社区不是不分层,而是把分层的轴从「技术职能」(controller/service/dao 横切)换成了「依赖方向与领域」(纵切)。
三个项目,三种答案。回头看开头的五个问题,可以收拢了。
六、写在最后
回到开头那个 ls 的结果。现在再看 k8s 的顶层目录,cmd、pkg、staging 不再是陌生的词汇;再遇到一个新的 Go 仓库,找代码的姿势可以总结成三步:
- 先看这个仓库产出什么:cmd/ 下有几个二进制?或者像 etcd 那样,go.work 串了几个 module、哪个 module 的根上有 main.go?
- 再看边界在哪:internal/ 圈出了私有实现;go.mod 的 require 圈出了 module 之间的真实依赖。
- 最后按业务形态猜逻辑的位置:CRUD 业务系统,去请求路径上找(handler/service);声明式基础设施,去控制循环里找(reconcile loop);单一职责系统,去各子系统的主循环里找,HTTP 层只是窗口。
五个问题的答案收拢如下:
- cmd/ 是「多二进制」的收纳惯例,internal/ 是 Go 1.4 起编译器强制的可见性围栏,pkg/ 是流行但有争议的社区惯例——所谓「标准 Go 布局」并不标准。
- etcd 没有 cmd/,因为「一个 module 一个二进制」让集中收纳失去了意义;internal/ 在 module 之间被 go.mod 依赖图取代,在 module 内部依然在场(client/v3/internal 为证)。
- k8s 的 controller 是控制循环,不是 MVC controller;service 层不存在,职责转移给了异步的 reconcile;DAO 的对应物是 pkg/registry + etcd3 store。
- multica 真实存在 handler / service / db 三层(sqlc 让 SQL 即 DAO),因为它是 CRUD 业务系统;但边界比 Java 松,读路径 handler 直查 db。
- prometheus 没有 service 层的生态位:子系统纵切自治,web 层直调功能包,装配集中在 main.go。
一句话总结:Go 项目的目录结构不是语言规定的,而是业务形态(同步 CRUD 还是异步控制循环)和发布形态(单 module 还是多 module)决定的;service 和 controller 的概念都在,只是住的楼层不一样了。
参考资料
Footnotes
-
this is not a standard Go project layout · Issue #117github.com ↗rsc:"the vast majority of packages in the Go ecosystem do not put the importable packages in a pkg subdirectory"https://github.com/golang-standards/project-layout/issues/117 ↩ ↩2
-
kubernetes/staging/README.mdgithub.com ↗"The code in the staging/ directory is authoritative",由 publishing-bot 单向发布到 k8s.io 各仓库https://github.com/kubernetes/kubernetes/blob/master/staging/README.md ↩
-
Organizing a Go modulego.dev ↗官方对服务器项目的建议:命令放 cmd/,逻辑放 internal/;示例里没有 pkg/https://go.dev/doc/modules/layout ↩
-
Go 1.4 Release Notes — Internal packagesgo.dev ↗internal 包机制的引入:"may not be imported by packages outside the source subtree in which they reside"https://go.dev/doc/go1.4 ↩
-
cmd/go 文档 — Internal packagespkg.go.dev ↗"Code in or below a directory named 'internal' is importable only by code that shares the same import path above the internal directory."https://pkg.go.dev/cmd/go#hdr-Internal_packages ↩
-
Standard Go Project Layoutgithub.com ↗README 自述:"This is NOT an official standard defined by the core Go dev team"https://github.com/golang-standards/project-layout ↩
-
Organizing Go codego.dev ↗包名为内容提供上下文;最小化导出接口("If in doubt, leave it out!")https://go.dev/blog/organizing-go-code ↩
-
Package namesgo.dev ↗"Avoid meaningless package names. Packages named util, common, or misc …"https://go.dev/blog/package-names ↩
-
etcd/go.workgithub.com ↗2026-10-31 的 main 分支:14 个 use 项,server、client/v3、api、etcdctl、etcdutl 等各为独立 modulehttps://github.com/etcd-io/etcd/blob/main/go.work ↩
-
etcd/server/main.gogithub.com ↗文件注释自称 "simple wrapper of the real etcd entrypoint",只调 etcdmain.Main(os.Args)https://github.com/etcd-io/etcd/blob/main/server/main.go ↩
-
etcd/client/v3/internalgithub.com ↗endpoint/ 与 resolver/ 两个包:module 内部仍在使用 internal/ 的实证https://github.com/etcd-io/etcd/tree/main/client/v3/internal ↩
-
pkg/controller/deployment/deployment_controller.gogithub.com ↗informer 回调只做 dc.queue.Add(key);worker 出队调 syncDeployment;失败 AddRateLimited,maxRetries=15https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/deployment/deployment_controller.go ↩ ↩2
-
cmd/kube-controller-manager/app/controllermanager.gogithub.com ↗cobra Long 描述:"a controller is a control loop that watches the shared state of the cluster …"https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-controller-manager/app/controllermanager.go ↩
-
cmd/kube-controller-manager/app/controller_descriptor.gogithub.com ↗NewControllerDescriptors() 注册全部内置控制器;旧名 NewControllerInitializers 已重构https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-controller-manager/app/controller_descriptor.go ↩
-
pkg/registrygithub.com ↗按 API group / 资源组织的 REST storage 层,core 下 pod、service、node、namespace 等各一个目录https://github.com/kubernetes/kubernetes/tree/master/pkg/registry ↩
-
multica/server/internal/handler/comment.gogithub.com ↗CreateComment :1629;computeCommentAgentTriggers :2636(行号为 2026-10-31 的 main)https://github.com/multica-ai/multica/blob/main/server/internal/handler/comment.go ↩
-
multica/server/internal/service/task.gogithub.com ↗enqueueMentionTaskWithCommentPlan :1464;claimTask :3612;ErrDuplicatePendingTask :755https://github.com/multica-ai/multica/blob/main/server/internal/service/task.go ↩
-
multica/server/pkg/db/queries/agent.sqlgithub.com ↗:734
-- name: ClaimAgentTask :one,sqlc 据此生成类型安全的 Go 方法https://github.com/multica-ai/multica/blob/main/server/pkg/db/queries/agent.sql ↩ -
Classpath Scanning and Managed Components — Stereotype Annotationsdocs.spring.io ↗"@Repository, @Service, and @Controller are specializations of @Component … (persistence, service, and presentation layers)"https://docs.spring.io/spring-framework/reference/core/beans/classpath-scanning.html ↩
-
prometheus/web/api/v1/api.gogithub.com ↗97KB 装下全部 v1 端点;NewAPI 的 40+ 个参数直接是 promql.QueryEngine、storage.Queryable 等本尊https://github.com/prometheus/prometheus/blob/main/web/api/v1/api.go ↩
-
prometheus/cmd/prometheus/main.gogithub.com ↗93KB 的手工装配文件:各子系统 Manager 在这里接进全局https://github.com/prometheus/prometheus/blob/main/cmd/prometheus/main.go ↩
-
Standard Package Layoutgobeyond.dev ↗领域类型放根包:"The root package should not depend on any other package in your application!"https://www.gobeyond.dev/standard-package-layout/ ↩
-
How to implement Clean Architecture in Go (Golang)threedots.tech ↗ports / adapters / application / domain 四层,分层规则是依赖方向https://threedots.tech/post/introducing-clean-architecture/ ↩