Go:项目目录怎么拆,才能从脚本走向可维护工程

学 Go 学到这里时,会开始遇到一类很典型的问题:

  • 单文件脚本已经能跑,但一加需求就乱
  • main.go 里什么都有:参数解析、配置读取、业务逻辑、HTTP 调用、结果输出
  • 想“工程化”,于是一下子建了十几个目录,结果自己也记不住
  • 看到别人项目里有 cmdinternalpkgapiscriptsbuild,就会觉得目录也应该全套照搬

这时候最容易走向两个极端:

  • 一种是完全不拆,最后所有逻辑都塞进一个包里
  • 另一种是拆得过度,在业务还没长起来时先搭了一层空架子

这两个方向都会让项目变难维护。

Go 的项目目录设计,真正要解决的从来不是“看起来像不像大厂项目”,而是下面这些更实际的问题:

  • 新功能应该放哪,团队成员能不能快速找到
  • 不同层的代码能不能保持边界,不互相乱调
  • 入口是不是清晰,一个仓库里多个程序能不能共存
  • 测试代码能不能就近放置,改动后能不能快速验证
  • 配置、依赖和启动流程是不是能稳定扩展

这一篇就围绕一个很实际的小场景来讲:把一个最初只有 main.go 的巡检工具,逐步演进成一个可维护的 Go 工程。

这个巡检工具一开始只做一件事:

  1. 读取服务名和环境参数
  2. 调用一个 HTTP 接口做健康检查
  3. 输出检查结果

后来需求慢慢变多:

  1. 要支持命令行模式和定时任务模式
  2. 要支持配置文件
  3. 要补单元测试
  4. 要把 HTTP 调用和业务规则拆开
  5. 要给团队里其他同学持续维护

这时候,“目录怎么拆”才真正变成工程问题。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. Go 项目什么时候还可以继续像脚本一样写
  2. 什么时候说明你该拆目录、拆包了
  3. cmdinternalpkg 分别在解决什么问题
  4. 项目目录和代码层次边界应该怎么对应
  5. 多入口程序、配置、测试和依赖该怎么放
  6. 哪些“看起来很专业”的拆法其实是在过度设计

如果这些问题能答清,后面你写 Go CLI、任务执行器、HTTP 服务、定时任务时,工程骨架会稳很多。

二、先看一个最容易失控的起点:脚本式单文件

先看最常见的第一版:

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package main

import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)

type Config struct {
BaseURL string `json:"base_url"`
Token string `json:"token"`
}

func main() {
service := flag.String("service", "", "service name")
env := flag.String("env", "test", "target env")
flag.Parse()

file, err := os.Open("config.json")
if err != nil {
panic(err)
}
defer file.Close()

body, err := io.ReadAll(file)
if err != nil {
panic(err)
}

var cfg Config
if err := json.Unmarshal(body, &cfg); err != nil {
panic(err)
}

if strings.TrimSpace(*service) == "" {
panic("service can not be empty")
}

client := &http.Client{Timeout: 3 * time.Second}
url := fmt.Sprintf("%s/health?service=%s&env=%s", cfg.BaseURL, *service, *env)
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+cfg.Token)

resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
panic(fmt.Sprintf("status code is %d", resp.StatusCode))
}

fmt.Println("check passed")
}

这个版本不是不能用。

如果你只是:

  • 学 Go 的第一周
  • 写一个一次性脚本
  • 只自己用两次

那它是能接受的。

但只要多加两个需求,它马上就会变得很难维护:

  • 再加一个入口,比如批量巡检模式
  • 再加一组重试规则
  • 再加一个测试环境和生产环境的配置切换
  • 再加结果上报

这时问题就出现了:

  • 参数解析和业务逻辑混在一起
  • 配置读取和 HTTP 调用混在一起
  • 没法对核心逻辑做单元测试
  • 没法复用“巡检”能力到另一个入口
  • 所有依赖都从 main 一路传不清楚

目录设计的意义,就是从这里开始出现的。

三、先把一句话原则说清楚

Go 项目目录不是为了好看,而是为了让下面三件事稳定成立:

  1. 入口清晰
  2. 依赖方向清晰
  3. 职责边界清晰

如果一个目录拆分不能提升这三件事,那多半只是形式上的“工程化”。

更直接一点:

  • cmd 解决的是入口问题
  • internal 解决的是仓库内边界和私有实现问题
  • pkg 解决的是“是否要给仓库外复用”这个问题

先把这三个问题分开想,目录就不会乱。

四、什么时候还不需要拆复杂目录

一学到工程化,就很容易想一步到位搭标准目录。

其实不是。

下面这些情况,完全可以先保持简单:

  1. 只有一个入口
  2. 业务逻辑非常薄
  3. 没有多人长期维护
  4. 没有明确复用需求
  5. 还在快速试错阶段

这时一个小而稳的结构就够了:

1
2
3
4
5
service_probe/
├── go.mod
├── main.go
├── probe.go
└── probe_test.go

或者稍微进一小步:

1
2
3
4
5
6
7
8
9
service_probe/
├── cmd/
│ └── probe/
│ └── main.go
├── internal/
│ └── probe/
│ ├── probe.go
│ └── probe_test.go
└── go.mod

也就是说,不要为了“像工程”而过早复制复杂骨架。

五、什么时候说明你该开始拆目录了

一般出现下面这些信号时,说明单文件脚本快到边界了:

  1. main.go 超过两三百行,而且混合了多类职责
  2. 同一套业务逻辑要被多个入口复用
  3. 你想写测试,却发现逻辑都粘在 main
  4. 配置项开始变多,启动流程开始复杂
  5. 同事接手时,很难判断哪个目录是核心逻辑
  6. 代码之间开始出现循环依赖风险

这时候就该从“脚本组织方式”转向“工程组织方式”。

注意,这里说的是逐步演进,不是一次性重构成一个巨型框架。

六、先给出一个可维护版本的最小目录

对于这个巡检工具,先给一个足够稳、又不过度的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
service_probe/
├── cmd/
│ ├── probe/
│ │ └── main.go
│ └── probe-worker/
│ └── main.go
├── internal/
│ ├── app/
│ │ └── probeapp/
│ │ └── service.go
│ ├── config/
│ │ └── config.go
│ ├── probe/
│ │ ├── checker.go
│ │ ├── client.go
│ │ └── model.go
│ └── platform/
│ └── clock.go
├── pkg/
│ └── httputil/
│ └── header.go
├── testdata/
│ └── config.local.json
├── go.mod
└── README.md

先别急着背目录名,先看它背后的含义:

  • cmd 放入口程序
  • internal 放仓库内部业务与实现
  • pkg 只放少量明确要复用给仓库外的公共包
  • testdata 放测试输入样例

这已经能覆盖大多数中小型 Go 项目的主干需求。

七、cmd 到底在解决什么问题

cmd 的核心作用非常朴素:

一个可执行程序一个目录。

比如这个项目里:

  • cmd/probe 是命令行巡检入口
  • cmd/probe-worker 是定时执行入口

它们可以共用内部逻辑,但启动方式不同。

一个典型的 main.go 应该尽量薄,只负责:

  1. 解析参数
  2. 加载配置
  3. 组装依赖
  4. 调用应用服务
  5. 处理退出码和日志

看一个更像工程代码的入口:

cmd/probe/main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"context"
"flag"
"log"
"os"

"service_probe/internal/app/probeapp"
"service_probe/internal/config"
"service_probe/internal/probe"
)

func main() {
service := flag.String("service", "", "service name")
env := flag.String("env", "test", "target env")
configPath := flag.String("config", "./config.json", "config file")
flag.Parse()

cfg, err := config.Load(*configPath)
if err != nil {
log.Printf("load config failed: %v", err)
os.Exit(1)
}

client := probe.NewHTTPClient(cfg.BaseURL, cfg.Token)
checker := probe.NewChecker(client)
app := probeapp.NewService(checker)

err = app.Run(context.Background(), probeapp.RunInput{
Service: *service,
Env: *env,
})
if err != nil {
log.Printf("run probe failed: %v", err)
os.Exit(1)
}
}

这里 main 没有直接写业务规则,也没有自己处理 HTTP 细节。

这就是入口该有的边界。

八、为什么 main 里不该塞满业务逻辑

看到这里时,很容易觉得:

  • 反正最终也是从 main 进来
  • 把逻辑写在 main 里最直接

但这样会立刻带来三个问题:

  1. 逻辑很难测试
    因为 main 很难像普通函数一样稳定调用和断言。

  2. 逻辑很难复用
    另一个入口想复用巡检逻辑时,只能复制代码。

  3. 逻辑很难替换依赖
    比如你想把真实 HTTP client 换成 fake client 做测试,就很麻烦。

所以更稳的做法是:

  • main 负责启动
  • internal/app/...internal/... 负责业务编排
  • 更底层的实现各自放进合适的包

九、internal 的意义,不是高级,而是“明确私有”

internal 是 Go 很有价值的一点。

它不是一种命名习惯,而是语言工具链支持的边界:

  • internal 下的包只能被当前仓库内部导入
  • 仓库外代码不能随便引用它

这解决的是一个很实际的问题:

哪些代码只是当前项目的内部实现,不希望外部项目依赖。

比如这个巡检工具里的这些内容,通常都该放 internal

  • 业务模型
  • 应用服务
  • HTTP 调用适配层
  • 配置装载
  • 数据库存储实现
  • 任务编排逻辑

因为这些东西本来就不是为了给外部项目当公共库用的。

也就是说,默认情况下,先用 internal,而不是先用 pkg

十、pkg 不是“专业项目标配”,而是慎用选项

看到 GitHub 上一些项目里有 pkg,常见误判是:

  • 只要是正式项目,就应该把通用代码放 pkg

这是很常见的误解。

pkg 真正适合的是:

  1. 这个包确实有明确对外复用价值
  2. 你愿意为它的 API 稳定性负责
  3. 其他仓库导入它是合理的

比如:

  • 一个明确要复用的日志格式化工具
  • 一个对外暴露的 SDK 小组件
  • 一个稳定的字符串或 HTTP 小工具包

如果还不确定,那就先放 internal

因为一旦外部项目开始依赖 pkg 里的代码,你后面改接口的成本就会显著变高。

一句更实用的判断标准是:

不确定时先私有,确定要共享时再公开。

十一、目录只是表面,真正关键的是依赖方向

目录拆得很整齐,项目也依然可能很乱。

根因通常不是目录名错了,而是依赖方向错了。

对这个巡检工具来说,一个更稳的方向通常是:

1
cmd -> app -> domain/usecase -> infra

或者不强调名字,只强调职责:

1
入口层 -> 业务编排层 -> 具体实现层

什么意思?

  • cmd 只负责启动
  • app 负责串业务流程
  • probeconfigstore 这些包负责各自职责
  • 低层实现不要反过来依赖高层入口

只要方向反过来,比如:

  • internal/probe 反过来 import cmd/probe
  • config 包里去调业务逻辑
  • 底层 HTTP client 直接依赖 CLI flag 参数结构

项目很快就会变形。

十二、先给这个小项目定一个更稳的层次边界

可以把巡检工具的代码职责大致分成四类:

  1. 入口层
    解析参数、启动应用、设置退出码

  2. 应用编排层
    校验输入、调用检查器、汇总结果

  3. 领域或核心逻辑层
    巡检规则、请求模型、结果模型、错误分类

  4. 基础设施层
    HTTP 调用、配置读取、文件写入、数据库访问

不一定非要给它们起很“架构化”的名字,但职责至少要分清。

看一个简单的分配:

1
2
3
4
5
6
7
8
9
10
11
internal/
├── app/
│ └── probeapp/
│ └── service.go
├── config/
│ └── config.go
└── probe/
├── checker.go
├── client.go
├── errors.go
└── model.go

这里:

  • app/probeapp 负责流程编排
  • probe 负责核心巡检逻辑
  • config 负责配置装载

已经足够清晰。

十三、一个完整的小项目目录示例

把前面的思路再落成一个更完整的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
service_probe/
├── cmd/
│ ├── probe/
│ │ └── main.go
│ └── probe-worker/
│ └── main.go
├── internal/
│ ├── app/
│ │ └── probeapp/
│ │ ├── service.go
│ │ └── service_test.go
│ ├── config/
│ │ ├── config.go
│ │ └── config_test.go
│ ├── probe/
│ │ ├── checker.go
│ │ ├── checker_test.go
│ │ ├── client.go
│ │ ├── errors.go
│ │ └── model.go
│ └── platform/
│ └── clock.go
├── pkg/
│ └── httputil/
│ └── header.go
├── testdata/
│ ├── config.invalid.json
│ └── config.valid.json
├── go.mod
└── README.md

这份结构里有几个刻意保留的点:

  • 测试文件尽量和被测代码放在一起
  • testdata 放共享的输入样例
  • pkg 只有一个真正可能被外部复用的小工具
  • 没有为了“像框架”再拆出很多空目录

十四、应用服务层怎么写,才能把目录和边界对应起来

来看核心编排服务:

internal/app/probeapp/service.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package probeapp

import (
"context"
"fmt"
"strings"

"service_probe/internal/probe"
)

type Checker interface {
Check(context.Context, probe.Target) (probe.Result, error)
}

type Service struct {
checker Checker
}

type RunInput struct {
Service string
Env string
}

func NewService(checker Checker) Service {
return Service{checker: checker}
}

func (s Service) Run(ctx context.Context, input RunInput) error {
if strings.TrimSpace(input.Service) == "" {
return fmt.Errorf("service can not be empty")
}
if strings.TrimSpace(input.Env) == "" {
return fmt.Errorf("env can not be empty")
}

_, err := s.checker.Check(ctx, probe.Target{
Service: input.Service,
Env: input.Env,
})
if err != nil {
return fmt.Errorf("probe service %s in %s: %w", input.Service, input.Env, err)
}
return nil
}

这个 Service 很像前面几篇文章里一直强调的那种“应用层”:

  • 它不关心命令行 flag 怎么来
  • 它不关心 HTTP 请求具体怎么发
  • 它只关心业务流程怎么串起来

这样目录拆分就不只是文件移动,而是真正有了边界。

十五、配置代码应该放哪,别让它长进业务包里

配置是另一个很容易越写越乱的地方。

很多项目的问题不是没有配置目录,而是:

  • 所有包都能随手读环境变量
  • 所有函数都能直接打开配置文件
  • 配置结构散落在多个包里

更稳的做法是:

  1. 在一个集中位置完成配置加载
  2. 把解析后的配置对象传给需要的组件
  3. 避免业务层直接依赖配置读取动作

例如:

internal/config/config.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package config

import (
"encoding/json"
"fmt"
"os"
)

type Config struct {
BaseURL string `json:"base_url"`
Token string `json:"token"`
Timeout int `json:"timeout"`
}

func Load(path string) (Config, error) {
file, err := os.Open(path)
if err != nil {
return Config{}, fmt.Errorf("open config: %w", err)
}
defer file.Close()

var cfg Config
if err := json.NewDecoder(file).Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("decode config: %w", err)
}
if cfg.BaseURL == "" {
return Config{}, fmt.Errorf("base_url can not be empty")
}
return cfg, nil
}

这里关键不是 Config 结构本身,而是边界:

  • 配置加载发生在 config
  • 业务代码拿到的是已经解析好的值
  • 业务层不需要知道配置文件格式细节

十六、测试应该跟着代码走,而不是最后集中补

做目录设计时,很容易只想到生产代码,忘了测试也需要结构。

Go 项目里更自然的做法通常是:

  • 单元测试文件和代码放在同目录
  • 集成测试按需要单独标识
  • 共享测试数据放 testdata

例如:

1
2
3
4
5
internal/
└── app/
└── probeapp/
├── service.go
└── service_test.go

单元测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package probeapp

import (
"context"
"errors"
"testing"

"service_probe/internal/probe"
)

type fakeChecker struct {
result probe.Result
err error
}

func (f fakeChecker) Check(ctx context.Context, target probe.Target) (probe.Result, error) {
return f.result, f.err
}

func TestServiceRun(t *testing.T) {
app := NewService(fakeChecker{})

err := app.Run(context.Background(), RunInput{
Service: "order-api",
Env: "test",
})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}

func TestServiceRunReturnWrappedError(t *testing.T) {
app := NewService(fakeChecker{
err: errors.New("upstream timeout"),
})

err := app.Run(context.Background(), RunInput{
Service: "order-api",
Env: "test",
})
if err == nil {
t.Fatal("expected error, got nil")
}
}

测试结构如果一开始就跟着目录一起落下来,后面维护成本会小很多。

十七、错误示例一:把所有代码都塞进 pkg

这是很常见的“假工程化”。

错误目录可能长这样:

1
2
3
4
5
6
7
8
9
10
service_probe/
├── cmd/
│ └── probe/
│ └── main.go
└── pkg/
├── config/
├── app/
├── probe/
├── httpclient/
└── model/

看起来很整齐,但问题是:

  • pkg 会暗示“这些都可以给外部项目导入”
  • 实际上这些包大多只是本项目内部实现
  • 后面别人一旦导入,你的改动成本会变高

如果没有明确外部复用需求,这样拆只会把私有实现过早公开。

十八、错误示例二:照着别人的大仓库一口气建十几层

另一个常见问题是:

  • api
  • biz
  • service
  • dao
  • repository
  • model
  • types
  • utils
  • common
  • lib

全部一次性建出来。

问题不在于这些名字一定错,而在于:

  • 当前项目规模根本撑不起这些层
  • 很多目录一开始是空的
  • 同学为了“有地方放”就开始乱放
  • 最后目录多了,边界反而更糊

目录层数一旦超过团队的认知负担,维护体验会显著下降。

Go 项目尤其要警惕这种“先搭架子再找内容填”的习惯。

十九、错误示例三:公共模型到处 import,最后耦合成团

还有一种更隐蔽的问题:

  • 为了“复用”,搞一个大而全的 model
  • 所有层都 import 它
  • 甚至配置、HTTP 响应、数据库记录、业务实体全塞进去

最后会发生什么?

  • 任何字段变动都会影响很多包
  • 业务层和基础设施层被一套结构硬绑在一起
  • 包之间依赖越来越密

更稳的方式通常是:

  • 让每个包拥有更贴近自己职责的数据结构
  • 只在必要边界上做转换
  • 不要为了省几个类型定义,把整个项目绑成一团

二十、一个更贴近真实工作的演进案例

假设这个巡检工具的演进分四步。

第一步:个人脚本

只有一个人临时查服务状态:

1
2
3
service_probe/
├── main.go
└── go.mod

这一步没问题。

第二步:开始重复使用

你发现这工具一周要跑很多次,于是开始拆出核心逻辑:

1
2
3
4
5
6
7
8
9
service_probe/
├── cmd/
│ └── probe/
│ └── main.go
├── internal/
│ └── probe/
│ ├── checker.go
│ └── checker_test.go
└── go.mod

第三步:增加第二个入口

现在除了命令行,还要加一个 worker 定时跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
service_probe/
├── cmd/
│ ├── probe/
│ │ └── main.go
│ └── probe-worker/
│ └── main.go
├── internal/
│ ├── app/
│ │ └── probeapp/
│ │ └── service.go
│ └── probe/
│ └── checker.go
└── go.mod

第四步:团队长期维护

开始有配置、共享测试数据、少量对外工具:

1
2
3
4
5
6
7
service_probe/
├── cmd/
├── internal/
├── pkg/
├── testdata/
├── go.mod
└── README.md

这就是更合理的演进方式:

  • 从小到大
  • 从需求出发
  • 一层一层长出来

而不是第一天就把终局目录搭满。

二十一、如果要支持多个入口,目录和组装方式该怎么定

多个入口是 cmd 最常见的价值场景。

比如:

  • cmd/probe 给开发同学手动执行
  • cmd/probe-worker 给定时任务执行
  • cmd/probe-api 给 HTTP 服务方式执行

这三个入口的共同点是:

  • 业务目标相近
  • 启动方式不同
  • 依赖组装方式可能也不同

这时最好的做法不是复制业务逻辑,而是让不同入口共享应用服务。

例如:

  • cmd/probe 组装命令行参数
  • cmd/probe-worker 组装任务列表和调度器
  • cmd/probe-api 组装 HTTP handler

但它们最终都调用 internal/app/probeapp 的核心流程。

这才是“一个仓库多个程序”的正确打开方式。

二十二、验证这套目录是否合理,可以看哪几个信号

目录好不好,不是看名字漂不漂亮,而是看你改需求时痛不痛。

可以用下面几条检查:

  1. 加一个新入口时,是否只需要新增一个 cmd/...
  2. 业务规则调整时,是否主要改 internal/app 或核心业务包
  3. 基础设施切换时,是否只需要改底层实现包
  4. 测试是否能只针对某个包快速运行
  5. 新同学是否能在几分钟内找到程序入口和核心逻辑

如果这几条都做不到,说明目录虽然拆了,边界还没真正建立。

二十三、最小验证步骤:这类工程至少要能做哪些检查

虽然这篇文章重点是目录结构,但工程要成立,至少要能支撑这些验证动作:

1
2
3
4
5
go test ./...
go test ./internal/app/probeapp
go test ./internal/config
go run ./cmd/probe -service order-api -env test -config ./testdata/config.valid.json
go run ./cmd/probe-worker -config ./testdata/config.valid.json

目录一旦清晰,这些命令就会非常自然:

  • 测哪个包,一眼就知道
  • 跑哪个入口,一眼就知道
  • 出问题先查哪层,也更清楚

这就是目录设计真正带来的回报。

二十四、常见排障:目录拆了,但项目还是不好维护,通常卡在哪

如果一个 Go 项目已经有 cmdinternalpkg,维护体验还是差,通常是这几类问题:

1. main 依旧过胖

表现:

  • 参数解析、配置读取、初始化、核心逻辑全在入口里

处理:

  • 把业务编排下沉到应用服务层

2. internal 里没有边界,只有大杂烩

表现:

  • 所有代码都塞在 internal/commoninternal/util 这类目录里

处理:

  • 按职责重新拆分,而不是按“通用”命名兜底

3. pkg 放了太多私有实现

表现:

  • 只是仓库内部代码,却被放到了公开目录

处理:

  • 收回 internal,缩小对外暴露面

4. 包循环依赖

表现:

  • 为了共享结构,多个包互相 import

处理:

  • 重画依赖方向,把高层依赖低层,而不是互相缠绕

5. 测试无法就近编写

表现:

  • 想测某个流程时,要启动整个程序

处理:

  • 把可测试逻辑从入口中抽出来,放到普通包函数或服务里

二十五、边界:不是所有 Go 项目都需要 cmd/internal/pkg 三件套

这里必须把边界讲清楚。

下面这些项目,不需要完整上这套结构:

  1. 一次性脚本
  2. 学习性质的小练习
  3. 只有几十行逻辑的内部小工具
  4. 生命周期很短的迁移脚本

这类项目最优解往往不是“工程化到位”,而是“足够简单且不误导后续维护者”。

相反,如果是下面这些场景,这套结构就很有价值:

  1. 多入口程序
  2. 长期维护的 CLI 或服务
  3. 需要多人协作
  4. 需要稳定测试和持续迭代
  5. 需要明确区分内部实现和潜在对外能力

所以结论不是“必须用标准目录”,而是:

按项目生命周期和复杂度,选择能支撑当前阶段的最小结构。

二十六、练习题

如果你想确认自己真的理解了这一篇,可以做下面几道练习:

  1. 把一个目前只有 main.go 的 Go 小工具,重构成 cmd + internal 的最小结构。
  2. 试着给它增加第二个入口,判断哪些逻辑应该共享,哪些逻辑只属于某个入口。
  3. 列出你项目里现在放在 pkg 下的包,逐个判断它们是否真的需要对外公开。
  4. 给一个配置读取流程补一组单元测试,并把样例文件放进 testdata
  5. 找出你项目里最胖的 main.go,把业务编排抽到应用服务层,再比较测试难度前后有什么变化。

这些练习做完,你对目录设计的理解会比单纯记术语牢固得多。

二十七、结语:目录不是模板,演进能力才是目标

Go 项目目录最容易被误解成一种“固定模板”:

  • 好像只要有 cmd
  • internal
  • 再来一个 pkg

项目就自动工程化了。

其实不是。

真正重要的是你能不能让项目持续维持这几个状态:

  • 新入口容易加
  • 新需求知道放哪
  • 依赖方向不乱
  • 测试能就近落地
  • 私有实现和公共能力边界清楚

所以更准确的结论应该是:

Go 项目目录不是为了追求统一长相,而是为了让代码随着需求增长时,仍然保持可理解、可测试、可替换、可协作。

从脚本走向工程,不是先学会搭大骨架。
而是先学会在正确的时机,做最小但关键的拆分。