﻿---
title: "Go 大工程的代码目录分析：以 k8s、multica、etcd、prometheus 为例"
description: "从 Java 转过来的人打开 kubernetes 源码，第一反应是找 controller、service、dao——看到的却是 cmd、pkg、staging。顺着这个困惑往下追：这三个目录各是什么来历，为什么 etcd 连 cmd 和 internal 都没有，k8s 的 controller 为什么不是那个 controller，multica 又怎么长回了 handler/service/db 三层，prometheus 为什么连 service 层的生态位都没有。"
pubDate: 2026-10-31
tags: ["Go", "架构", "源码分析", "Kubernetes"]
clc: "TP311.5"
hero: "./hero.svg"
refs:
  - id: issue-117
    title: "this is not a standard Go project layout · Issue #117"
    url: https://github.com/golang-standards/project-layout/issues/117
    author: Russ Cox
    site: GitHub
    date: 2021-04-09
    note: "rsc：\"the vast majority of packages in the Go ecosystem do not put the importable packages in a pkg subdirectory\""
  - id: modules-layout
    title: "Organizing a Go module"
    url: https://go.dev/doc/modules/layout
    site: go.dev
    note: "官方对服务器项目的建议：命令放 cmd/，逻辑放 internal/；示例里没有 pkg/"
  - id: go14
    title: "Go 1.4 Release Notes — Internal packages"
    url: https://go.dev/doc/go1.4
    site: go.dev
    date: 2014-12
    note: "internal 包机制的引入：\"may not be imported by packages outside the source subtree in which they reside\""
  - id: cmdgo-internal
    title: "cmd/go 文档 — Internal packages"
    url: https://pkg.go.dev/cmd/go#hdr-Internal_packages
    site: pkg.go.dev
    note: "\"Code in or below a directory named 'internal' is importable only by code that shares the same import path above the internal directory.\""
  - id: layout-readme
    title: "Standard Go Project Layout"
    url: https://github.com/golang-standards/project-layout
    site: GitHub
    note: "README 自述：\"This is NOT an official standard defined by the core Go dev team\""
  - id: organizing-go-code
    title: "Organizing Go code"
    url: https://go.dev/blog/organizing-go-code
    author: Andrew Gerrand
    site: Go Blog
    date: 2012-08-16
    note: "包名为内容提供上下文；最小化导出接口（\"If in doubt, leave it out!\"）"
  - id: package-names
    title: "Package names"
    url: https://go.dev/blog/package-names
    author: Sameer Ajmani
    site: Go Blog
    date: 2015-02-04
    note: "\"Avoid meaningless package names. Packages named util, common, or misc …\""
  - id: k8s-staging-readme
    title: "kubernetes/staging/README.md"
    url: https://github.com/kubernetes/kubernetes/blob/master/staging/README.md
    site: GitHub
    note: "\"The code in the staging/ directory is authoritative\"，由 publishing-bot 单向发布到 k8s.io 各仓库"
  - id: etcd-gowork
    title: "etcd/go.work"
    url: https://github.com/etcd-io/etcd/blob/main/go.work
    site: GitHub
    note: "2026-10-31 的 main 分支：14 个 use 项，server、client/v3、api、etcdctl、etcdutl 等各为独立 module"
  - id: etcd-server-main
    title: "etcd/server/main.go"
    url: https://github.com/etcd-io/etcd/blob/main/server/main.go
    site: GitHub
    note: "文件注释自称 \"simple wrapper of the real etcd entrypoint\"，只调 etcdmain.Main(os.Args)"
  - id: etcd-client-internal
    title: "etcd/client/v3/internal"
    url: https://github.com/etcd-io/etcd/tree/main/client/v3/internal
    site: GitHub
    note: "endpoint/ 与 resolver/ 两个包：module 内部仍在使用 internal/ 的实证"
  - id: k8s-kcm
    title: "cmd/kube-controller-manager/app/controllermanager.go"
    url: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-controller-manager/app/controllermanager.go
    site: GitHub
    note: "cobra Long 描述：\"a controller is a control loop that watches the shared state of the cluster …\""
  - id: k8s-descriptor
    title: "cmd/kube-controller-manager/app/controller_descriptor.go"
    url: https://github.com/kubernetes/kubernetes/blob/master/cmd/kube-controller-manager/app/controller_descriptor.go
    site: GitHub
    note: "NewControllerDescriptors() 注册全部内置控制器；旧名 NewControllerInitializers 已重构"
  - id: k8s-deploy-ctrl
    title: "pkg/controller/deployment/deployment_controller.go"
    url: https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/deployment/deployment_controller.go
    site: GitHub
    note: "informer 回调只做 dc.queue.Add(key)；worker 出队调 syncDeployment；失败 AddRateLimited，maxRetries=15"
  - id: k8s-registry
    title: "pkg/registry"
    url: https://github.com/kubernetes/kubernetes/tree/master/pkg/registry
    site: GitHub
    note: "按 API group / 资源组织的 REST storage 层，core 下 pod、service、node、namespace 等各一个目录"
  - id: multica-comment
    title: "multica/server/internal/handler/comment.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/handler/comment.go
    site: GitHub
    note: "CreateComment :1629；computeCommentAgentTriggers :2636（行号为 2026-10-31 的 main）"
  - id: multica-task
    title: "multica/server/internal/service/task.go"
    url: https://github.com/multica-ai/multica/blob/main/server/internal/service/task.go
    site: GitHub
    note: "enqueueMentionTaskWithCommentPlan :1464；claimTask :3612；ErrDuplicatePendingTask :755"
  - id: multica-agent-sql
    title: "multica/server/pkg/db/queries/agent.sql"
    url: https://github.com/multica-ai/multica/blob/main/server/pkg/db/queries/agent.sql
    site: GitHub
    note: ":734 `-- name: ClaimAgentTask :one`，sqlc 据此生成类型安全的 Go 方法"
  - id: spring-stereotype
    title: "Classpath Scanning and Managed Components — Stereotype Annotations"
    url: https://docs.spring.io/spring-framework/reference/core/beans/classpath-scanning.html
    site: Spring Framework 文档
    note: "\"@Repository, @Service, and @Controller are specializations of @Component … (persistence, service, and presentation layers)\""
  - id: prom-api
    title: "prometheus/web/api/v1/api.go"
    url: https://github.com/prometheus/prometheus/blob/main/web/api/v1/api.go
    site: GitHub
    note: "97KB 装下全部 v1 端点；NewAPI 的 40+ 个参数直接是 promql.QueryEngine、storage.Queryable 等本尊"
  - id: prom-main
    title: "prometheus/cmd/prometheus/main.go"
    url: https://github.com/prometheus/prometheus/blob/main/cmd/prometheus/main.go
    site: GitHub
    note: "93KB 的手工装配文件：各子系统 Manager 在这里接进全局"
  - id: benjohnson
    title: "Standard Package Layout"
    url: https://www.gobeyond.dev/standard-package-layout/
    author: Ben Johnson
    site: Go Beyond
    date: 2016-08-10
    note: "领域类型放根包：\"The root package should not depend on any other package in your application!\""
  - id: threedots
    title: "How to implement Clean Architecture in Go (Golang)"
    url: https://threedots.tech/post/introducing-clean-architecture/
    author: Miłosz Smółka
    site: Three Dots Labs
    date: 2020-09-01
    note: "ports / adapters / application / domain 四层，分层规则是依赖方向"
---

从 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"[^issue-117]。连「标准布局」本身都是一场误会，那 Go 大工程的代码到底按什么组织？

我们把问题拆开：

1. cmd/、pkg/、internal/ 这三个随处可见的目录，各是什么来历？谁定的？
2. 为什么 etcd 顶层连 cmd/ 和 internal/ 都没有？
3. k8s 里明明有个 pkg/controller——它是 Java 意义上的 controller 吗？那 service、DAO 的对应物在哪？
4. 有没有 Go 项目真的按 handler/service/db 分层？
5. prometheus 为什么又是另一种答案？

本文顺着这五个问题往下走，样本是四个真实的大工程：kubernetes、multica（一个用 Go 写的 AI agent 协作平台）、etcd、prometheus。文中所有目录名、文件名和行号，都来自 2026-10-31 当天各仓库的主分支。

---

## 一、cmd、pkg、internal：三个目录，三种来历

先看 k8s 的顶层：

```text
$ 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 等独立仓库[^k8s-staging-readme]。）

**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/[^modules-layout]。

**internal/ 是唯一有编译器背书的目录语义。** 它不是惯例，是语言特性。Go 1.4 的发布说明写得很清楚：go 命令引入了一种机制，internal 包「不可以被其所在源码子树之外的包导入」[^go14]。现行工具链文档的表述更精确：internal 目录里（及其之下）的代码，只有与 internal 共享同一上级 import path 的代码才能导入[^cmdgo-internal]。大项目用它把「公开 API」和「私有实现」隔开——比如 multica 的 server module 把全部 49 个业务包都放进了 internal/。

**pkg/ 是争议最大的一个。** 它的流行来自那份 "Standard Go Project Layout"：把「可被外部引用的库代码」放进 pkg/[^layout-readme]。但这份「标准」的 README 自己声明它不是官方标准，Russ Cox 在 issue #117 里说得更直白——Go 生态里绝大多数包并不放在 pkg/ 子目录里，他认可的最小布局只有三条：根目录放 LICENSE、go.mod、Go 代码，「That's it. That's the "standard".」[^issue-117]。官方的组织哲学是按功能内聚切包，包名说明「提供什么」而不是「装了什么」[^organizing-go-code]，util、common、misc 这类名字被官方博客点名反对[^package-names]。事实上，prometheus 和 etcd 的顶层都没有 pkg/；multica 保留了 pkg/，但里面放的是「跨进程共享的契约」（sqlc 生成的 DAO、daemon 与 server 之间的 WebSocket 协议），语义已经和 project-layout 的本意不同了。

<figure class="dd dd-figure dt-figure">
<div class="dt-panel">
<div class="dt-title" id="fig1-title">四个仓库，三套答案（均为 2026-10-31 的主分支）</div>
<div class="dt-scroll">
<table class="dt" aria-labelledby="fig1-title">
<colgroup><col style="width:15%"><col style="width:33%"><col style="width:26%"><col class="hl"></colgroup>
<thead>
<tr><th scope="col">仓库</th><th scope="col">顶层目录风格</th><th scope="col">二进制入口</th><th scope="col">「私有实现」的围栏</th></tr>
</thead>
<tbody>
<tr><th scope="row">kubernetes</th><td>cmd + pkg + staging，单 module</td><td class="mono">cmd/&lt;组件&gt;/，main 极薄</td><td>公开面走 staging 发布；pkg/ 不对外</td></tr>
<tr><th scope="row">multica（server/）</th><td class="mono">cmd + internal + pkg，单 module</td><td class="mono">cmd/server、cmd/multica</td><td class="mono">internal/ 圈住 49 个业务包</td></tr>
<tr><th scope="row">prometheus</th><td>21 个功能包平铺，没有 pkg/</td><td class="mono">cmd/prometheus、cmd/promtool</td><td>internal/ 很小，近乎无围栏</td></tr>
<tr><th scope="row">etcd</th><td>顶层目录 = module（go.work × 14）</td><td class="mono">module 根的 main.go</td><td>go.mod 依赖图 + 个别 internal/</td></tr>
</tbody>
</table>
</div>
<p class="dt-note">cmd/ 和 internal/ 看似标配，其实各自回答一个具体问题：有几个二进制要放？哪些包不许外人碰？</p>
</div>
<figcaption>图 1 · 目录约定不是规定动作，而是对「入口收纳」和「可见性围栏」两个问题的回答；etcd 把两个答案都换了写法</figcaption>
</figure>

cmd/ 和 internal/ 看起来是大项目的标配。**但打开 etcd，这两个目录都不在——它们解决的问题消失了吗？**

## 二、etcd：module 边界取代了 cmd/ 和 internal/

先看现象，etcd 的顶层：

```text
$ ls etcd/
api/  cache/  client/  contrib/  etcdctl/  etcdutl/  pkg/
server/  tests/  tools/  go.work  …
```

没有 cmd/，顶层也没有 internal/。原因藏在根目录的 go.work 里：这个仓库不是一个 Go module，而是 14 个 module 组成的 workspace[^etcd-gowork]——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"[^etcd-server-main]。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 端点解析的私有实现[^etcd-client-internal]。module 边界替代的只是 internal/ 在 module 之间的角色；module 之内想做细粒度隐藏，internal/ 依然是 Go 给的唯一工具。至于 server module 完全不用 internal/，更像历史惯例而非设计——etcdutl 既然能 require 整个 server/v3，server/etcdserver、server/storage 这些包在技术上就是「事实上的公开 API」。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="etcd-mod-title etcd-mod-desc">
<title id="etcd-mod-title">etcd 的多 module 结构</title>
<desc id="etcd-mod-desc">etcd 仓库由 go.work 串起 14 个 module。外部项目 import client/v3 时依赖图里只有轻依赖；etcdutl 才 require 带 raft 和 bbolt 的 server/v3。internal 在 module 内部仍在使用：client/v3/internal 下有 endpoint 和 resolver 两个包。</desc>
<defs>
<marker id="etcd-mod-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="etcd-mod-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="420" rx="10"/>
<rect class="dd-zone" x="200" y="16" width="584" height="388" rx="8"/>
<text class="t-zone" x="220" y="44">etcd-io/etcd · 根 go.work 串起 14 个 module</text>
<path class="dd-edge-acc" d="M176 148 H210" marker-end="url(#etcd-mod-arrow-acc)"/>
<path class="dd-edge" d="M291 272 V220" marker-end="url(#etcd-mod-arrow)"/>
<path class="dd-edge" d="M595 272 V220" marker-end="url(#etcd-mod-arrow)"/>
<rect class="dd-card" x="24" y="120" width="152" height="56" rx="6"/>
<rect class="dd-card" x="216" y="64" width="272" height="150" rx="6"/>
<path class="dd-card-head" d="M216.6 96 V70 Q216.6 64.6 222 64.6 H482 Q487.4 64.6 487.4 70 V96 Z"/>
<line class="dd-edge" x1="216" y1="96" x2="488" y2="96"/>
<rect class="dd-card" x="520" y="64" width="248" height="150" rx="6"/>
<path class="dd-card-head" d="M520.6 96 V70 Q520.6 64.6 526 64.6 H762 Q767.4 64.6 767.4 70 V96 Z"/>
<line class="dd-edge" x1="520" y1="96" x2="768" y2="96"/>
<rect class="dd-card" x="216" y="272" width="150" height="36" rx="6"/>
<rect class="dd-card" x="520" y="272" width="150" height="36" rx="6"/>
<rect class="dd-derived" x="228" y="156" width="248" height="46" rx="6"/>
<text class="t-name" x="40" y="142">外部项目</text>
<text class="t-mono" x="40" y="164">k8s.io/apiserver</text>
<text class="t-note" x="40" y="192">import <tspan class="t-acc">client/v3</tspan> →</text>
<text class="t-mono" x="232" y="85"><tspan class="t-acc">go.etcd.io/etcd/client/v3</tspan></text>
<text class="t-mono" x="232" y="113">require</text>
<text class="t-val" x="300" y="113">api/v3 · client/pkg/v3</text>
<line class="dd-hair" x1="232" y1="122" x2="472" y2="122"/>
<text class="t-mono" x="232" y="139">server/v3</text>
<text class="t-val" x="316" y="139">不 require —— 依赖图即围栏</text>
<text class="t-mono" x="244" y="176">internal/</text>
<text class="t-val" x="316" y="176">endpoint/ · resolver/</text>
<text class="t-note" x="244" y="194">module 内部的隐藏仍靠它</text>
<text class="t-mono" x="536" y="85">go.etcd.io/etcd/server/v3</text>
<text class="t-mono" x="536" y="113">main.go</text>
<text class="t-val" x="612" y="113">→ etcdmain.Main(...)</text>
<line class="dd-hair" x1="536" y1="122" x2="752" y2="122"/>
<text class="t-mono" x="536" y="139">require</text>
<text class="t-val" x="612" y="139">raft/v3 v3.7.0</text>
<line class="dd-hair" x1="536" y1="148" x2="752" y2="148"/>
<text class="t-val" x="612" y="165">bbolt v1.5.0</text>
<text class="t-name" x="232" y="295">etcdctl <tspan class="t-val">main.go</tspan></text>
<text class="t-name" x="536" y="295">etcdutl <tspan class="t-val">main.go</tspan></text>
<text class="t-note" x="303" y="248">轻 · 只依赖 client/v3</text>
<text class="t-note" x="607" y="248">重 · 拖入 raft/bbolt</text>
<text class="t-say" x="520" y="336">虚线：internal/ 没有被消灭，</text>
<text class="t-say" x="520" y="356">module 内部它依然在场。</text>
<text class="t-say" x="220" y="388">顶层目录 = 发布物 = module：每个目录自带 go.mod。</text>
</svg>
</div>
<figcaption>图 2 · cmd/ 的角色被「一个 module 一个二进制」彻底取代；internal/ 只在 module 之间被依赖图取代，module 内部仍在使用</figcaption>
</figure>

目录约定搞清楚了。**回到最初的问题：controller 在哪？k8s 里明明有个 pkg/controller/——但打开它，会发现这个词被重新定义了。**

## 三、k8s 的 controller：一个被重新定义的词

抱着找「接 HTTP 请求的那个 controller」的预期，打开 pkg/controller/deployment/deployment_controller.go，看到的 `DeploymentController` struct 里没有路由、没有 HTTP，最显眼的字段是一个队列：`queue workqueue.TypedRateLimitingInterface[string]`[^k8s-deploy-ctrl]。

原因是在 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"[^k8s-kcm]。pkg/controller/ 下约 50 个目录——deployment、replicaset、statefulset、cronjob、namespace、garbagecollector……——全是这样的循环，由 cmd/kube-controller-manager 统一注册、统一启动。注册入口是 app/controller_descriptor.go 的 `NewControllerDescriptors()`；读旧资料时留意，它以前叫 NewControllerInitializers，后来重构成了现在的名字[^k8s-descriptor]。

deployment controller 是这种循环的教科书样例，结构分三段（图 3）。它给 Deployment、ReplicaSet、Pod 三个 informer 注册事件回调，回调里**不写任何业务逻辑**，只做一件事：把对象的 key 放进队列。另一边，若干 worker 从队列里取 key，调 `syncDeployment`：从 informer 的本地缓存读对象、对比期望状态与实际状态、决定滚动更新还是扩缩容；失败就把 key 限流重入队，最多重试 15 次[^k8s-deploy-ctrl]。informer（监听 + 缓存）、workqueue（去重 + 限流重试）、sync loop（纠偏），三段拼出一个完整的 reconcile loop。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="k8s-loop-title k8s-loop-desc">
<title id="k8s-loop-title">k8s 控制循环的三段式结构</title>
<desc id="k8s-loop-desc">deployment controller 的三段式：informer 事件回调只做 dc.queue.Add(key)；workqueue 去重限流；worker 出队调 syncDeployment 纠偏，失败 AddRateLimited 重入队（maxRetries=15）。全部控制器由 NewControllerDescriptors 统一注册。</desc>
<defs>
<marker id="k8s-loop-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="380" rx="10"/>
<rect class="dd-zone" x="16" y="88" width="768" height="276" rx="8"/>
<text class="t-zone" x="36" y="116">pkg/controller/deployment · deployment_controller.go</text>
<path class="dd-edge" d="M246 60 V82" marker-end="url(#k8s-loop-arrow)"/>
<path class="dd-edge" d="M260 216 H304" marker-end="url(#k8s-loop-arrow)"/>
<path class="dd-edge" d="M512 216 H556" marker-end="url(#k8s-loop-arrow)"/>
<path class="dd-edge dd-dash" d="M674 292 V330 H406 V298" marker-end="url(#k8s-loop-arrow)"/>
<rect class="dd-card" x="36" y="24" width="420" height="36" rx="6"/>
<rect class="dd-card" x="36" y="140" width="224" height="152" rx="6"/>
<path class="dd-card-head" d="M36.6 172 V146 Q36.6 140.6 42 140.6 H254 Q259.4 140.6 259.4 146 V172 Z"/>
<line class="dd-edge" x1="36" y1="172" x2="260" y2="172"/>
<rect class="dd-card" x="312" y="140" width="200" height="152" rx="6"/>
<path class="dd-card-head" d="M312.6 172 V146 Q312.6 140.6 318 140.6 H506 Q511.4 140.6 511.4 146 V172 Z"/>
<line class="dd-edge" x1="312" y1="172" x2="512" y2="172"/>
<rect class="dd-card" x="564" y="140" width="220" height="152" rx="6"/>
<path class="dd-card-head" d="M564.6 172 V146 Q564.6 140.6 570 140.6 H778 Q783.4 140.6 783.4 146 V172 Z"/>
<line class="dd-edge" x1="564" y1="172" x2="784" y2="172"/>
<text class="t-mono" x="52" y="47">NewControllerDescriptors()</text>
<text class="t-note" x="440" y="47" text-anchor="end">controller_descriptor.go</text>
<text class="t-note" x="258" y="76">register × 约 50 个控制器</text>
<text class="t-name" x="52" y="161">informer 事件回调</text>
<text class="t-note" x="244" y="161" text-anchor="end">3 个对象</text>
<text class="t-mono" x="52" y="193">d/rs/podInformer</text>
<line class="dd-hair" x1="52" y1="202" x2="244" y2="202"/>
<text class="t-mono" x="52" y="219">ResourceEventHandlerFuncs</text>
<line class="dd-hair" x1="52" y1="228" x2="244" y2="228"/>
<text class="t-val" x="52" y="245">回调只做一件事：</text>
<line class="dd-hair" x1="52" y1="254" x2="244" y2="254"/>
<text class="t-mono" x="52" y="271"><tspan class="t-acc">dc.queue.Add(key)</tspan></text>
<text class="t-name" x="328" y="161">workqueue</text>
<text class="t-note" x="496" y="161" text-anchor="end">去重 · 限流</text>
<text class="t-mono" x="328" y="193">TypedRateLimiting</text>
<line class="dd-hair" x1="328" y1="202" x2="496" y2="202"/>
<text class="t-mono" x="328" y="219">Interface[string]</text>
<line class="dd-hair" x1="328" y1="228" x2="496" y2="228"/>
<text class="t-val" x="328" y="245">同一个 <tspan class="t-acc">key</tspan> 只留一份</text>
<line class="dd-hair" x1="328" y1="254" x2="496" y2="254"/>
<text class="t-mono" x="328" y="271">processNextWorkItem</text>
<text class="t-name" x="580" y="161">syncDeployment</text>
<text class="t-val" x="580" y="193">按 <tspan class="t-acc">key</tspan> 读 Deployment</text>
<line class="dd-hair" x1="580" y1="202" x2="768" y2="202"/>
<text class="t-val" x="580" y="219">对比 ReplicaSet 实际状态</text>
<line class="dd-hair" x1="580" y1="228" x2="768" y2="228"/>
<text class="t-mono" x="580" y="245">rolloutRolling /</text>
<line class="dd-hair" x1="580" y1="254" x2="768" y2="254"/>
<text class="t-mono" x="580" y="271">rolloutRecreate / sync</text>
<text class="t-note" x="283" y="204" text-anchor="middle">key 入队</text>
<text class="t-note" x="534" y="204" text-anchor="middle">worker 出队</text>
<text class="t-note" x="540" y="322" text-anchor="middle">失败 AddRateLimited 重入队 · maxRetries = 15</text>
<text class="t-say" x="36" y="346">事件回调不写业务逻辑，只入队一个 <tspan class="t-acc">key</tspan>；纠偏全部发生在 syncDeployment。</text>
</svg>
</div>
<figcaption>图 3 · k8s 的 controller 是 informer + workqueue + sync 的控制循环：事件只落一个 key，真正的业务在异步纠偏里</figcaption>
</figure>

那 HTTP 请求路径上有什么？apiserver 收到「PUT 一个 Deployment」时，走的是认证 → 鉴权 → 准入 → 存储，全程无状态、没有「业务逻辑」可言——真正的业务（该建几个 Pod、怎么滚动）发生在上面那个异步循环里。请求路径上唯一像 Java 分层里某个角色的，是 **pkg/registry**：按 API group 和资源组织的 REST storage 层，core 组下 pod、service、node、namespace、configmap……每种资源一个目录，把 CRUD 收口到 apiserver 的 generic registry，最终落到唯一的持久化后端 etcd[^k8s-registry]。（严谨起见标注一下：各资源目录内部的 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）：

1. **handler 层**：internal/handler/comment.go 的 `CreateComment`（:1629）接请求；`computeCommentAgentTriggers`（:2636）算出这条评论该叫醒谁，@提及由 util.ParseMentions 解析[^multica-comment]。
2. **service 层**：handler 调 `TaskService.EnqueueTaskForMention`，最终落到 internal/service/task.go 的 `enqueueMentionTaskWithCommentPlan`（:1464）——事务、去重（哨兵错误 `ErrDuplicatePendingTask`）、入队都在这里[^multica-task]。
3. **DAO 层**：领取任务时 `claimTask`（:3612）调用 `qtx.ClaimAgentTask`。而 `ClaimAgentTask` 不是手写的 Go 函数——它是 sqlc 从 pkg/db/queries/agent.sql:734 那段 SQL（`-- name: ClaimAgentTask :one`）生成的类型安全方法[^multica-agent-sql]。queries/ 下 67 个 .sql 文件的角色几乎就是 MyBatis 的 mapper XML，generated/ 目录是生成的 DAO。

对照 Spring 官方文档的定义——@Controller、@Service、@Repository 分别是表现层、业务层、持久层的 @Component 特化[^spring-stereotype]——multica 的 handler / service / db 几乎一一对应。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 440" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mc-layer-title mc-layer-desc">
<title id="mc-layer-title">multica 的三层与 Java 对照</title>
<desc id="mc-layer-desc">左边是 Java Spring 的 @Controller/@Service/@Repository 三层；右边是 multica 的真实调用链：handler/comment.go 的 CreateComment 到 service/task.go 的 EnqueueTaskForMention，再到 pkg/db 里 sqlc 从 agent.sql:734 生成的 ClaimAgentTask。虚线表示读路径 handler 直查 db、不经过 service。</desc>
<defs>
<marker id="mc-layer-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="mc-layer-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="440" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="232" height="368" rx="8"/>
<text class="t-zone" x="36" y="44">Java Spring（对照）</text>
<rect class="dd-zone" x="272" y="16" width="512" height="368" rx="8"/>
<text class="t-zone" x="292" y="44">multica/server · main @ 2026-10-31</text>
<path class="dd-edge" d="M132 124 V162" marker-end="url(#mc-layer-arrow)"/>
<path class="dd-edge" d="M132 204 V242" marker-end="url(#mc-layer-arrow)"/>
<path class="dd-edge-acc" d="M516 152 V180" marker-end="url(#mc-layer-arrow-acc)"/>
<path class="dd-edge-acc" d="M516 274 V294" marker-end="url(#mc-layer-arrow-acc)"/>
<path class="dd-edge dd-dash" d="M736 108 H756 V342 H742" marker-end="url(#mc-layer-arrow)"/>
<rect class="dd-card" x="40" y="88" width="184" height="36" rx="6"/>
<rect class="dd-card" x="40" y="168" width="184" height="36" rx="6"/>
<rect class="dd-card" x="40" y="248" width="184" height="36" rx="6"/>
<rect class="dd-card" x="296" y="64" width="440" height="88" rx="6"/>
<path class="dd-card-head" d="M296.6 96 V70 Q296.6 64.6 302 64.6 H730 Q735.4 64.6 735.4 70 V96 Z"/>
<line class="dd-edge" x1="296" y1="96" x2="736" y2="96"/>
<rect class="dd-card" x="296" y="186" width="440" height="88" rx="6"/>
<path class="dd-card-head" d="M296.6 218 V192 Q296.6 186.6 302 186.6 H730 Q735.4 186.6 735.4 192 V218 Z"/>
<line class="dd-edge" x1="296" y1="218" x2="736" y2="218"/>
<rect class="dd-card" x="296" y="300" width="440" height="84" rx="6"/>
<path class="dd-card-head" d="M296.6 332 V306 Q296.6 300.6 302 300.6 H730 Q735.4 300.6 735.4 306 V332 Z"/>
<line class="dd-edge" x1="296" y1="332" x2="736" y2="332"/>
<text class="t-mono" x="56" y="111">@Controller</text>
<text class="t-val" x="142" y="111">表现层</text>
<text class="t-mono" x="56" y="191">@Service</text>
<text class="t-val" x="128" y="191">业务层</text>
<text class="t-mono" x="56" y="271">@Repository</text>
<text class="t-val" x="142" y="271">持久层（DAO）</text>
<text class="t-note" x="40" y="312">注解把 bean 按技术职能标层</text>
<text class="t-name" x="312" y="85">internal/handler/comment.go</text>
<text class="t-note" x="720" y="85" text-anchor="end">chi</text>
<text class="t-mono" x="312" y="117"><tspan class="t-acc">CreateComment</tspan>(w, r)  :1629</text>
<line class="dd-hair" x1="312" y1="124" x2="720" y2="124"/>
<text class="t-mono" x="312" y="141">computeCommentAgentTriggers  :2636</text>
<text class="t-name" x="312" y="207">internal/service/task.go</text>
<text class="t-note" x="720" y="207" text-anchor="end">TaskService</text>
<text class="t-mono" x="312" y="239"><tspan class="t-acc">EnqueueTaskForMention</tspan></text>
<line class="dd-hair" x1="312" y1="246" x2="720" y2="246"/>
<text class="t-mono" x="312" y="263">enqueueMentionTaskWithCommentPlan  :1464</text>
<text class="t-name" x="312" y="321">pkg/db（sqlc）</text>
<text class="t-note" x="720" y="321" text-anchor="end">SQL 即 DAO</text>
<text class="t-mono" x="312" y="353">queries/agent.sql:734  <tspan class="t-acc">ClaimAgentTask</tspan></text>
<line class="dd-hair" x1="312" y1="360" x2="720" y2="360"/>
<text class="t-mono" x="312" y="377">generated/agent.sql.go（生成）</text>
<text class="t-note" x="528" y="172">写 / 编排走 service</text>
<text class="t-note" x="528" y="292">类型安全调用</text>
<text class="t-say" x="292" y="412">虚线：读路径 handler 持有 h.Queries 直查 db，不经过 service。</text>
</svg>
</div>
<figcaption>图 4 · multica 的 handler/service/db 与 Java 三层一一对应，但读路径允许越层——分层存在，边界更松</figcaption>
</figure>

和 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)` 逐条注册路由[^prom-api]。但往下看一层就断了：**没有 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 的装配文件把它接进全局[^prom-main]。rules/、discovery/（下挂约 30 个服务发现来源）、notifier/ 都是同样的形态。storage/ 与 tsdb/ 的分离则是另一个标本：storage/interface.go（31KB）定义 Queryable / Appendable 接口，tsdb/ 是内置实现，web、promql、rules 只依赖接口，实现由 main 在装配时注入。

<figure class="dd dd-figure">
<div class="dd-scroll">
<svg class="dd-svg" viewBox="0 0 800 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="prom-flat-title prom-flat-desc">
<title id="prom-flat-title">prometheus 的 web 层直调功能包</title>
<desc id="prom-flat-desc">web/api/v1/api.go 的 API struct 通过 NewAPI 的 40 多个参数直接持有 promql、storage、scrape、rules 各子系统的本尊，中间没有 service 层；storage 是接口，tsdb 是实现。</desc>
<defs>
<marker id="prom-flat-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head" d="M1 1 L7 4 L1 7"/></marker>
<marker id="prom-flat-arrow-acc" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path class="dd-head-acc" d="M1 1 L7 4 L1 7"/></marker>
</defs>
<rect class="dd-frame" width="800" height="360" rx="10"/>
<rect class="dd-zone" x="16" y="16" width="768" height="328" rx="8"/>
<text class="t-zone" x="36" y="44">prometheus/prometheus · 顶层 21 个功能包平铺，没有 pkg/</text>
<path class="dd-edge-acc" d="M286 120 H322 Q330 120 330 112 V86 Q330 78 338 78 H414" marker-end="url(#prom-flat-arrow-acc)"/>
<path class="dd-edge-acc" d="M286 150 H414" marker-end="url(#prom-flat-arrow-acc)"/>
<path class="dd-edge-acc" d="M286 200 H330 V214 Q330 222 338 222 H414" marker-end="url(#prom-flat-arrow-acc)"/>
<path class="dd-edge-acc" d="M286 230 H322 Q330 230 330 238 V286 Q330 294 338 294 H414" marker-end="url(#prom-flat-arrow-acc)"/>
<path class="dd-edge" d="M620 294 H654" marker-end="url(#prom-flat-arrow)"/>
<rect class="dd-card" x="36" y="96" width="250" height="150" rx="6"/>
<path class="dd-card-head" d="M36.6 128 V102 Q36.6 96.6 42 96.6 H280 Q285.4 96.6 285.4 102 V128 Z"/>
<line class="dd-edge" x1="36" y1="128" x2="286" y2="128"/>
<rect class="dd-card" x="420" y="60" width="200" height="36" rx="6"/>
<rect class="dd-card" x="420" y="132" width="200" height="36" rx="6"/>
<rect class="dd-card" x="420" y="204" width="200" height="36" rx="6"/>
<rect class="dd-card" x="420" y="276" width="200" height="36" rx="6"/>
<rect class="dd-card" x="660" y="276" width="108" height="36" rx="6"/>
<text class="t-name" x="52" y="117">web/api/v1/api.go</text>
<text class="t-note" x="270" y="117" text-anchor="end">97KB</text>
<text class="t-mono" x="52" y="149">type API struct</text>
<line class="dd-hair" x1="52" y1="158" x2="270" y2="158"/>
<text class="t-mono" x="52" y="175">NewAPI(40+ 个参数)</text>
<line class="dd-hair" x1="52" y1="184" x2="270" y2="184"/>
<text class="t-val" x="52" y="201">直接持有 <tspan class="t-acc">QueryEngine /</tspan></text>
<line class="dd-hair" x1="52" y1="210" x2="270" y2="210"/>
<text class="t-val" x="52" y="227"><tspan class="t-acc">Queryable / retrievers</tspan></text>
<text class="t-name" x="436" y="83">promql <tspan class="t-val">engine.go 199KB</tspan></text>
<text class="t-name" x="436" y="155">scrape <tspan class="t-val">scrape.go 81KB</tspan></text>
<text class="t-name" x="436" y="227">rules <tspan class="t-val">manager.go</tspan></text>
<text class="t-name" x="436" y="299">storage <tspan class="t-val">接口 · 31KB</tspan></text>
<text class="t-name" x="676" y="299">tsdb <tspan class="t-val">实现</tspan></text>
<text class="t-note" x="350" y="140">handler 直调</text>
<text class="t-note" x="637" y="284" text-anchor="middle">实现</text>
<text class="t-note" x="420" y="262">…（discovery、notifier 同构）</text>
<text class="t-say" x="36" y="330">没有 service 层：API struct 直接持有各子系统的本尊，web 只剩协议适配。</text>
</svg>
</div>
<figcaption>图 5 · prometheus 的 web 层像 controller，但 handler 星形直连各功能包——service 层没有生态位</figcaption>
</figure>

prometheus 为什么没有 service 层的生态位？因为它的「业务」本来就不是「一次请求编排多个领域对象」，而是各子系统永不停歇的主循环：抓取、评估规则、发告警。HTTP API 只是探视这些循环的窗口，窗口后面没有等待编排的东西，service 层自然无从谈起。

这种切法在 Go 社区有理论表述。Ben Johnson 的 Standard Package Layout 主张领域类型放根包、子包按**依赖**分组（postgres、http 各做一个适配器），并且明确要求根包不依赖应用内任何其他包[^benjohnson]；Clean Architecture 在 Go 里的实践（ports / adapters / application / domain）同样把分层规则定义成**依赖方向**：外层可以引用内层，反过来不行[^threedots]。也就是说，Go 社区不是不分层，而是把分层的轴从「技术职能」（controller/service/dao 横切）换成了「依赖方向与领域」（纵切）。

**三个项目，三种答案。回头看开头的五个问题，可以收拢了。**

## 六、写在最后

回到开头那个 `ls` 的结果。现在再看 k8s 的顶层目录，cmd、pkg、staging 不再是陌生的词汇；再遇到一个新的 Go 仓库，找代码的姿势可以总结成三步：

1. **先看这个仓库产出什么**：cmd/ 下有几个二进制？或者像 etcd 那样，go.work 串了几个 module、哪个 module 的根上有 main.go？
2. **再看边界在哪**：internal/ 圈出了私有实现；go.mod 的 require 圈出了 module 之间的真实依赖。
3. **最后按业务形态猜逻辑的位置**：CRUD 业务系统，去请求路径上找（handler/service）；声明式基础设施，去控制循环里找（reconcile loop）；单一职责系统，去各子系统的主循环里找，HTTP 层只是窗口。

五个问题的答案收拢如下：

1. cmd/ 是「多二进制」的收纳惯例，internal/ 是 Go 1.4 起编译器强制的可见性围栏，pkg/ 是流行但有争议的社区惯例——所谓「标准 Go 布局」并不标准。
2. etcd 没有 cmd/，因为「一个 module 一个二进制」让集中收纳失去了意义；internal/ 在 module 之间被 go.mod 依赖图取代，在 module 内部依然在场（client/v3/internal 为证）。
3. k8s 的 controller 是控制循环，不是 MVC controller；service 层不存在，职责转移给了异步的 reconcile；DAO 的对应物是 pkg/registry + etcd3 store。
4. multica 真实存在 handler / service / db 三层（sqlc 让 SQL 即 DAO），因为它是 CRUD 业务系统；但边界比 Java 松，读路径 handler 直查 db。
5. prometheus 没有 service 层的生态位：子系统纵切自治，web 层直调功能包，装配集中在 main.go。

一句话总结：**Go 项目的目录结构不是语言规定的，而是业务形态（同步 CRUD 还是异步控制循环）和发布形态（单 module 还是多 module）决定的；service 和 controller 的概念都在，只是住的楼层不一样了。**

## 参考资料
