Go 从 0 到精通 · 第 33 课:项目结构与工程组织

学习定位:这是整套 Go 教程的第 33 课,也是阶段五(并发与工程阶段)的第九课。
前置要求:已经完成第 32 课,掌握了 go.mod、包管理、错误处理、测试与 Benchmark 的基础能力。
本课目标:理解 Go 项目从“代码能跑”到“结构清晰、易维护”的组织方法,掌握常见目录结构、包拆分原则、cmd/internal 的用途,学会把配置、日志和错误包装纳入统一工程结构,并能从零搭出一个小型 Go 工程骨架。


1. 本课你要解决的核心问题

写到现在,你已经会写函数、结构体、接口、并发、测试和性能测试了。

但真正开始做项目时,你很快会碰到另一个层面的问题:

  • main.go 越写越长,几百行甚至上千行
  • 文件越来越多,却不知道该怎么拆包
  • 配置到处读,环境变量满天飞
  • 日志有的用 fmt.Println,有的用 log.Println,风格不统一
  • 错误只会 return err,一旦报错根本不知道是哪一层出的事
  • 包之间互相 import,最后出现循环依赖

这时候你会发现:会写 Go 代码,不等于会组织 Go 项目。

项目结构与工程组织,解决的是这几个问题:

  • 代码应该按什么维度拆分
  • 哪些东西放在根目录,哪些放到子目录
  • 业务逻辑、配置、日志、存储逻辑分别放哪
  • 怎么让包依赖关系保持清晰
  • 怎么让项目以后继续长大时不失控

这一课的重点不是“背目录模板”,而是理解为什么要这样组织


2. 先建立一个正确认识:Go 工程结构不是越复杂越高级

很多人刚接触工程化,就喜欢先抄一个很大的目录模板:

1
2
3
4
5
6
7
8
9
10
11
cmd/
internal/
pkg/
api/
configs/
scripts/
build/
deploy/
docs/
tools/
third_party/

然后项目里只有 3 个 .go 文件。

这就是典型的过度设计

Go 社区对项目结构有一个很重要的共识:

结构要服务于规模,而不是为了看起来专业。

2.1 小项目可以很简单

如果你只是写一个练手的小工具,这样完全没问题:

1
2
3
4
hello-go/
├── go.mod
├── main.go
└── config.json

只要代码不多、职责不乱,这就是合理结构。

2.2 项目一长大,结构问题就会暴露

假设你写了一个任务管理 CLI,最开始只有 1 个文件:

1
2
3
4
5
6
7
8
9
10
11
package main

func main() {
// 解析参数
// 读取配置
// 初始化日志
// 读文件
// 解析 JSON
// 增删改查任务
// 打印结果
}

一开始还行,三天后就会变成这样:

  • main 负责所有事情
  • 文件读写和业务逻辑混在一起
  • 输出、日志、错误处理混在一起
  • 想加测试时发现逻辑都在 main 里,很难测

所以,项目结构的真正目标不是“好看”,而是:

  1. 让职责分开
  2. 让依赖方向清晰
  3. 让代码方便测试
  4. 让后续迭代不容易失控

2.3 一个实用判断标准

什么时候应该开始拆结构?

当你出现下面任意一种情况时,就该动手了:

  • 一个文件超过 200~300 行,还在继续增长
  • 一个包里既有业务逻辑,又有存储逻辑,又有命令行解析
  • 多个功能开始共享同一段逻辑
  • 需要给某一层单独写测试
  • 你自己隔一周回来已经不容易快速定位代码

记住一句话:

先让代码跑起来,再在增长点上做结构化拆分。


3. Go 项目结构的三种常见层级

这部分不要死记,重点是理解“项目规模变化,结构怎么跟着演进”。

3.1 第一层:单文件项目

适合场景:

  • 学习示例
  • 一次性脚本
  • 非常小的 CLI 工具

结构:

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

优点:

  • 最简单,零心智负担
  • 新手最容易启动

缺点:

  • 很快堆成“大杂烩”
  • 几乎没有可维护性

3.2 第二层:多文件、同包项目

适合场景:

  • 小型练手项目
  • 功能开始变多,但还没复杂到拆多个包

结构:

1
2
3
4
5
6
7
project/
├── go.mod
├── main.go
├── config.go
├── task.go
├── store.go
└── output.go

这些文件都属于同一个 package main

优点:

  • 比单文件清晰
  • 不需要处理跨包依赖

缺点:

  • 仍然没有真正的边界
  • 逻辑很容易继续耦合
  • 测试和复用能力有限

3.3 第三层:多包工程项目

适合场景:

  • 需要长期维护
  • 业务逻辑、存储逻辑、配置、日志开始分层
  • 希望结构清晰、可测、可扩展

结构示意:

1
2
3
4
5
6
7
8
9
10
11
project/
├── go.mod
├── cmd/
│ └── app/
│ └── main.go
└── internal/
├── config/
├── logger/
├── service/
├── store/
└── model/

这就是我们这课要重点掌握的层级。


4. 常见目录到底是干什么的

下面这些目录是 Go 项目里最常见的。注意:常见,不等于必须全用。

4.1 根目录

根目录通常放项目级别的信息:

1
2
3
4
5
project/
├── go.mod
├── go.sum
├── README.md
└── .gitignore

常见文件作用:

文件 作用
go.mod 模块名、Go 版本、依赖声明
go.sum 依赖校验信息
README.md 项目说明、运行方式
.gitignore Git 忽略规则

根目录通常不应该堆很多业务代码。如果项目进入工程化阶段,业务代码最好放进更明确的目录中。

4.2 cmd/

cmd 用来放可执行程序入口

例如:

1
2
3
4
5
cmd/
├── todo/
│ └── main.go
└── migrate/
└── main.go

说明:

  • cmd/todo/main.go 是任务管理 CLI 的入口
  • cmd/migrate/main.go 是数据库迁移工具入口

如果一个仓库里有多个可执行程序,cmd 特别好用。

一个很重要的实践是:

main.go 负责组装依赖和启动程序,不负责承载业务细节。

也就是说,main.go 应该更像“接线员”,而不是“业务实现中心”。

4.3 internal/

internal 是 Go 很有特色的一个目录。

放进 internal 的包,只允许当前模块内部导入,外部模块不能导入。

例如项目结构:

1
2
3
example.com/todo-cli/
├── cmd/todo/main.go
└── internal/task/service.go

当前模块内部可以导入:

1
import "example.com/todo-cli/internal/task"

但其他项目如果想导入这个路径,会直接编译失败。

这意味着:

  • internal 适合放你的内部实现细节
  • 可以避免别人把你的内部包当公共 API 使用
  • 能强制建立“这是内部实现,不对外承诺稳定性”的边界

4.4 pkg/

pkg 不是 Go 语言强制规定的目录,只是一种约定。

常见理解是:

  • internal/ 放内部实现
  • pkg/ 放希望被外部复用的公共包

但要注意,pkg/ 并不是必须的,很多项目根本不用它。

什么时候适合用 pkg/

  • 你明确要提供一个可复用库
  • 你希望外部项目可以 import 你的某些包

什么时候不建议一上来就用 pkg/

  • 你还不确定哪些代码会被外部复用
  • 项目本质是应用,而不是库
  • 你只是为了“看起来像大项目”而加 pkg/

对初学者来说,更推荐的原则是:

应用型项目优先考虑 cmd + internal,不要机械套 pkg

4.5 configs/scripts/testdata/

这些目录也是常见的,但都是“按需使用”。

目录 常见用途
configs/ 示例配置文件、模板配置
scripts/ 构建、发布、初始化脚本
testdata/ 测试输入文件,Go 测试约定会忽略它

例如:

1
2
3
4
5
6
7
project/
├── internal/
├── cmd/
├── testdata/
│ └── sample.json
└── scripts/
└── release.sh

如果你当前项目根本没有这些需求,就不要先建空目录。


5. 包怎么拆?先看两种常见思路

这是工程组织里最关键的地方。

同一个项目,包拆法可能完全不同。没有绝对唯一答案,但有优劣。

5.1 方案 A:按技术职责拆包

例如:

1
2
3
4
5
6
internal/
├── config/
├── logger/
├── model/
├── service/
└── store/

特点:

  • config 管配置
  • logger 管日志
  • model 放数据结构
  • service 放业务逻辑
  • store 放文件、数据库等存储实现

优点:

  • 对初学者非常直观
  • 依赖关系容易理解
  • 很适合单一业务的小项目

缺点:

  • 项目一大,和同一业务相关的代码会分散在很多目录里
  • 找一个功能需要跨多个目录跳来跳去

5.2 方案 B:按业务领域拆包

例如:

1
2
3
4
5
6
internal/
├── config/
├── logger/
├── task/
├── user/
└── report/

其中 task/ 里可以同时放:

  • 任务数据结构
  • 任务业务逻辑
  • 任务相关接口定义

优点:

  • 高内聚,围绕业务组织
  • 功能扩展时更自然
  • 中大型项目更常见

缺点:

  • 对新手来说不如“按职责拆包”直观
  • 共享基础能力需要额外抽象

5.3 这一课推荐哪种

对于你当前这个阶段,我建议这样理解:

  1. 单一业务、体量不大:按技术职责拆包更容易上手
  2. 业务开始明显分块:按业务领域拆包更利于长期维护

这一课的完整示例会采用一种折中方式:

  • configloggerstore 这类明显的基础设施单独拆出
  • 核心业务收拢到 task 包里

这比“纯技术分层”更贴近真实项目,也比“完全按领域”更容易理解。


6. 包拆分的五条实用原则

不管你按哪种思路拆,下面这五条都非常重要。

6.1 一包一职责

一个包最好有明确边界。

例如:

  • config:只负责配置加载和校验
  • logger:只负责日志初始化
  • task:只负责任务领域逻辑
  • store:只负责持久化实现

反例是这种“万能包”:

1
2
internal/
└── util/

几年经验总结下来,utilcommonhelper 这类名字往往意味着:

  • 边界模糊
  • 什么都能往里塞
  • 后面一定越来越乱

6.2 依赖方向要单向

好的依赖关系通常是:

1
2
3
main -> config/logger/store/task
store -> task
task -> 标准库

坏的依赖关系通常是互相 import:

1
2
task -> store
store -> task

一旦这样写,就很容易出现循环依赖。

Go 明确禁止循环 import,这是件好事,因为它逼着你把结构整理清楚。

6.3 接口尽量定义在使用方

比如任务服务需要一个“能加载和保存任务”的存储能力,那么接口更适合定义在 task 包中:

1
2
3
4
type Repository interface {
Load() ([]Task, error)
Save([]Task) error
}

为什么?

因为是 task 服务在“消费”这项能力,它最清楚自己需要什么方法。

这样做的好处:

  • 接口更小、更聚焦
  • 存储层更容易替换
  • 更方便测试时做 mock

6.4 包名要短、准、自然

推荐:

  • task
  • store
  • config
  • logger

不推荐:

  • taskservice
  • taskmanager
  • commonutils
  • task_model_package

Go 包名强调简洁,因为调用时会带上包名前缀:

1
2
3
task.NewService()
config.Load()
logger.New()

如果包名太长,代码会非常啰嗦。

6.5 导出越少越好

能不导出的就别导出。

例如:

1
2
3
4
type Service struct {}

func NewService(...) *Service { ... }
func (s *Service) Add(...) error { ... }

nextTaskID 这种纯内部辅助函数,就保持小写:

1
func nextTaskID(tasks []Task) int { ... }

导出太多,会让包的表面 API 变大,维护成本上升。


7. 一个完整的小型工程示例

下面我们用“任务管理 CLI”作为例子,搭一个结构清晰的小项目。

7.1 最终目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
todo-cli/
├── go.mod
├── README.md
├── cmd/
│ └── todo/
│ └── main.go
└── internal/
├── config/
│ └── config.go
├── logger/
│ └── logger.go
├── store/
│ └── file_store.go
└── task/
├── task.go
└── service.go

这个结构里,每一层职责都比较清晰:

  • cmd/todo/main.go:程序入口,解析参数,组装依赖
  • internal/config:读取配置
  • internal/logger:初始化日志
  • internal/store:任务数据的文件存储实现
  • internal/task:任务领域模型与业务逻辑

7.2 初始化模块

1
go mod init example.com/todo-cli

这里的模块名只是示意,你实际项目可以换成自己的仓库地址。


8. 先定义业务包:internal/task

业务包通常应该先建,因为它代表项目最核心的能力。

8.1 任务模型

1
2
3
4
5
6
7
8
9
10
11
12
// internal/task/task.go
package task

import "time"

// Task 表示一条任务记录
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}

8.2 服务层

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// internal/task/service.go
package task

import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
)

var ErrTaskNotFound = errors.New("任务不存在")

// Repository 定义任务服务依赖的存储能力
type Repository interface {
Load() ([]Task, error)
Save([]Task) error
}

// Service 封装任务相关业务逻辑
type Service struct {
repo Repository
logger *slog.Logger
}

// NewService 创建任务服务
func NewService(repo Repository, logger *slog.Logger) *Service {
return &Service{
repo: repo,
logger: logger,
}
}

// Add 新增任务
func (s *Service) Add(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, errors.New("任务标题不能为空")
}

tasks, err := s.repo.Load()
if err != nil {
return Task{}, fmt.Errorf("加载任务列表失败: %w", err)
}

item := Task{
ID: nextTaskID(tasks),
Title: title,
Done: false,
CreatedAt: time.Now(),
}

tasks = append(tasks, item)
if err := s.repo.Save(tasks); err != nil {
return Task{}, fmt.Errorf("保存任务失败: %w", err)
}

s.logger.Info("新增任务成功", "id", item.ID, "title", item.Title)
return item, nil
}

// List 返回所有任务
func (s *Service) List() ([]Task, error) {
tasks, err := s.repo.Load()
if err != nil {
return nil, fmt.Errorf("读取任务列表失败: %w", err)
}
return tasks, nil
}

// Done 将指定任务标记为已完成
func (s *Service) Done(id int) error {
if id <= 0 {
return errors.New("任务 ID 必须大于 0")
}

tasks, err := s.repo.Load()
if err != nil {
return fmt.Errorf("读取任务列表失败: %w", err)
}

found := false
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = true
found = true
break
}
}

if !found {
return fmt.Errorf("标记任务完成失败: %w", ErrTaskNotFound)
}

if err := s.repo.Save(tasks); err != nil {
return fmt.Errorf("写回任务数据失败: %w", err)
}

s.logger.Info("任务已完成", "id", id)
return nil
}

func nextTaskID(tasks []Task) int {
maxID := 0
for _, item := range tasks {
if item.ID > maxID {
maxID = item.ID
}
}
return maxID + 1
}

这个包体现了几个关键点:

  • 业务逻辑集中在 task
  • 存储能力通过接口抽象
  • 错误在当前语义层补充上下文
  • 日志记录业务事件,但不吞掉错误

9. 再实现存储层:internal/store

现在我们给 task.Service 提供一个文件存储实现。

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
61
62
63
// internal/store/file_store.go
package store

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"

"example.com/todo-cli/internal/task"
)

// FileStore 使用 JSON 文件保存任务数据
type FileStore struct {
path string
}

// NewFileStore 创建文件存储
func NewFileStore(path string) *FileStore {
return &FileStore{path: path}
}

// Load 读取所有任务
func (s *FileStore) Load() ([]task.Task, error) {
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []task.Task{}, nil
}
return nil, fmt.Errorf("读取文件 %s 失败: %w", s.path, err)
}

if len(data) == 0 {
return []task.Task{}, nil
}

var tasks []task.Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, fmt.Errorf("解析文件 %s 失败: %w", s.path, err)
}
return tasks, nil
}

// Save 保存所有任务
func (s *FileStore) Save(tasks []task.Task) error {
dir := filepath.Dir(s.path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dir, err)
}
}

data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return fmt.Errorf("编码任务数据失败: %w", err)
}

if err := os.WriteFile(s.path, data, 0644); err != nil {
return fmt.Errorf("写入文件 %s 失败: %w", s.path, err)
}
return nil
}

这里你可以看到一个很重要的依赖方向:

1
2
store -> task
task 不依赖 store

也就是说:

  • task 只知道自己需要一个 Repository
  • store 来适配这个接口

这是非常经典、非常实用的工程组织方式。


10. 配置不要到处读:统一放进 internal/config

新手常见写法是这样的:

1
2
3
dbHost := os.Getenv("DB_HOST")
dataFile := os.Getenv("TODO_DATA_FILE")
logLevel := os.Getenv("TODO_LOG_LEVEL")

然后这些逻辑散落在:

  • main.go
  • service.go
  • store.go
  • handler.go

这会导致两个问题:

  1. 配置来源不集中,改起来很痛苦
  2. 很难知道某个环境变量到底在哪里生效

更好的做法是:统一加载,统一校验,统一传递。

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
// internal/config/config.go
package config

import (
"errors"
"os"
)

// Config 表示程序运行配置
type Config struct {
DataFile string
LogLevel string
}

// Load 从环境变量加载配置
func Load() (Config, error) {
cfg := Config{
DataFile: getEnv("TODO_DATA_FILE", "data/tasks.json"),
LogLevel: getEnv("TODO_LOG_LEVEL", "INFO"),
}

if cfg.DataFile == "" {
return Config{}, errors.New("TODO_DATA_FILE 不能为空")
}

return cfg, nil
}

func getEnv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}

10.1 配置设计的三条原则

原则 1:配置集中定义

所有运行配置尽量收敛到一个 Config 结构体中。

这样你只要看这个结构体,就知道程序依赖哪些配置。

原则 2:配置加载和业务逻辑分开

task.Service 不应该自己去读环境变量。

因为:

  • 它只关心“我要一个数据文件路径”
  • 不关心这个路径是从环境变量、命令行还是配置文件来的

原则 3:配置要有默认值和校验

比如:

  • LogLevel 默认 INFO
  • DataFile 默认 data/tasks.json
  • 特别关键的字段可以显式校验

这会让项目在开发环境里更容易启动。


11. 日志不要乱打:统一放进 internal/logger

在小练习里你可以到处 fmt.Println,但工程里最好尽快建立统一日志方式。

11.1 为什么日志要统一

如果项目里同时存在:

  • fmt.Println
  • log.Println
  • panic
  • logger.Info

那后面排查问题会非常痛苦。

统一日志至少能带来这些好处:

  • 输出格式一致
  • 可以控制日志级别
  • 更容易定位问题
  • 以后切换日志实现代价更小

11.2 用标准库 log/slog

Go 1.21 开始,标准库提供了 log/slog,很适合工程项目的结构化日志需求。

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
// internal/logger/logger.go
package logger

import (
"log/slog"
"os"
"strings"
)

// New 根据日志级别创建 logger
func New(level string) *slog.Logger {
var logLevel slog.Level

switch strings.ToUpper(level) {
case "DEBUG":
logLevel = slog.LevelDebug
case "WARN":
logLevel = slog.LevelWarn
case "ERROR":
logLevel = slog.LevelError
default:
logLevel = slog.LevelInfo
}

handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: logLevel,
})
return slog.New(handler)
}

11.3 日志应该记什么

好的日志通常记录:

  • 关键业务事件
  • 错误发生点
  • 重要上下文信息

例如:

1
s.logger.Info("新增任务成功", "id", item.ID, "title", item.Title)

这比单纯打印:

1
fmt.Println("add ok")

信息量大很多。

11.4 日志的一个常见误区

不要每一层都打一遍同一个错误日志。

错误链路如果是:

1
2
3
4
os.ReadFile 失败
-> store.Load 返回错误
-> task.List 返回错误
-> main 打印错误

通常只需要:

  • 中间层补充错误上下文
  • 最外层决定是否打印日志或退出程序

否则你会看到同一个错误被打印 3 次。


12. 错误包装是工程组织的重要部分

这节课的“工程感”很大一部分来自错误处理方式。

12.1 为什么不能只写 return err

假设底层报错:

1
open data/tasks.json: no such file or directory

如果每一层都只是 return err,到了最外层你只能看到这个原始错误。

你不知道:

  • 是加载配置失败
  • 还是读取任务失败
  • 还是保存任务失败

12.2 正确做法:在每一层补充语义

1
2
3
4
tasks, err := s.repo.Load()
if err != nil {
return nil, fmt.Errorf("读取任务列表失败: %w", err)
}

再往上一层:

1
2
3
4
if err := run(service, action, title, id); err != nil {
fmt.Fprintln(os.Stderr, "操作失败:", err)
os.Exit(1)
}

最后得到的错误链会更有语义,例如:

1
操作失败: 读取任务列表失败: 读取文件 data/tasks.json 失败: 权限不足

这就非常容易定位了。

12.3 %werrors.Is

错误包装时要用 %w

1
return fmt.Errorf("标记任务完成失败: %w", ErrTaskNotFound)

这样最外层才能用 errors.Is 判断:

1
2
3
4
if errors.Is(err, task.ErrTaskNotFound) {
fmt.Fprintln(os.Stderr, "任务不存在")
os.Exit(2)
}

这就是工程里常说的:

  • 中间层负责补充上下文
  • 边界层负责决定如何处理

12.4 什么时候不该包装

如果当前层没有增加任何新语义,单纯套一层:

1
return fmt.Errorf("error: %w", err)

其实价值不大。

包装的关键不是“多包一层”,而是“补充定位信息”。


13. 程序入口应该长什么样

现在把前面的配置、日志、存储、业务拼起来。

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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// cmd/todo/main.go
package main

import (
"errors"
"flag"
"fmt"
"os"

"example.com/todo-cli/internal/config"
"example.com/todo-cli/internal/logger"
"example.com/todo-cli/internal/store"
"example.com/todo-cli/internal/task"
)

func main() {
var action string
var title string
var id int

flag.StringVar(&action, "action", "list", "操作:list/add/done")
flag.StringVar(&title, "title", "", "任务标题")
flag.IntVar(&id, "id", 0, "任务 ID")
flag.Parse()

cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "加载配置失败:", err)
os.Exit(1)
}

log := logger.New(cfg.LogLevel)
repo := store.NewFileStore(cfg.DataFile)
service := task.NewService(repo, log)

if err := run(service, action, title, id); err != nil {
if errors.Is(err, task.ErrTaskNotFound) {
fmt.Fprintln(os.Stderr, "操作失败:", err)
os.Exit(2)
}

fmt.Fprintln(os.Stderr, "操作失败:", err)
os.Exit(1)
}
}

func run(service *task.Service, action, title string, id int) error {
switch action {
case "list":
tasks, err := service.List()
if err != nil {
return err
}

if len(tasks) == 0 {
fmt.Println("当前没有任务")
return nil
}

for _, item := range tasks {
status := "未完成"
if item.Done {
status = "已完成"
}
fmt.Printf("[%d] %s (%s)\n", item.ID, item.Title, status)
}
return nil

case "add":
item, err := service.Add(title)
if err != nil {
return err
}
fmt.Printf("已新增任务:[%d] %s\n", item.ID, item.Title)
return nil

case "done":
if err := service.Done(id); err != nil {
return err
}
fmt.Printf("任务 %d 已标记完成\n", id)
return nil

default:
return fmt.Errorf("不支持的操作: %s", action)
}
}

你会发现这个 main.go 有一个明显特征:

它几乎不写业务细节,只做四件事:

  1. 解析输入
  2. 初始化依赖
  3. 调用业务服务
  4. 处理边界输出和退出码

这就是一个很健康的入口结构。


14. 从这个示例里你应该学到什么

这个小项目虽然不大,但已经有了比较完整的工程轮廓。

14.1 业务逻辑不放在 main

main 不是写业务规则的地方。

如果以后你要:

  • 写单元测试
  • 增加 HTTP 接口
  • 改成 GUI 或 Web 后台

只要 task.Service 还在,核心逻辑就能复用。

14.2 存储实现可以替换

今天是 FileStore,明天可以换成 DBStore

1
type DBStore struct {}

只要它实现了:

1
2
Load() ([]task.Task, error)
Save([]task.Task) error

就能无缝接进 task.Service

这就是接口带来的工程价值。

14.3 配置、日志、错误处理都有统一入口

工程组织不只是目录拆分,更重要的是:

  • 配置统一加载
  • 日志统一初始化
  • 错误统一包装和落地处理

这三件事做好了,项目维护成本会明显下降。


15. internal 的价值,再单独讲一次

很多初学者知道 internal,但不知道它真正的工程意义。

15.1 它不是“普通目录”,而是语言级边界

这和你随便建个 private/ 目录完全不同。

private/ 只是命名约定。

internal/ 则是 Go 编译器真的会帮你拦住外部导入。

这意味着:

  • 你可以放心把内部实现放进去
  • 外部项目无法依赖你的“脆弱内部细节”
  • 包边界更稳定

15.2 什么时候应该放进 internal

适合放进 internal 的内容:

  • 业务服务实现
  • 存储实现
  • 配置加载
  • 日志初始化
  • 只服务于当前项目的工具代码

不太适合放进 internal 的内容:

  • 你明确想开放给外部复用的公共库
  • 你希望其他项目也能 import 的稳定 API

15.3 一个经验判断

如果你在想:

“这段代码以后可能给别人复用吗?”

如果答案是“不确定”或“多半不会”,先放 internal 通常是更稳妥的选择。


16. 应用型项目和库型项目,结构不一样

这部分很容易混淆,必须单独说清楚。

16.1 应用型项目

比如:

  • CLI 工具
  • Web 服务
  • 后台任务程序

这类项目的目标是“运行起来提供能力”,常见结构是:

1
cmd + internal

16.2 库型项目

比如:

  • 你写了一个通用的字符串处理库
  • 你实现了一个别人可复用的缓存组件

这类项目的目标是“被别的项目 import”,结构往往更简单:

1
2
3
4
5
my-lib/
├── go.mod
├── cache.go
├── cache_test.go
└── option.go

或者:

1
2
3
4
5
my-lib/
├── go.mod
└── cache/
├── cache.go
└── cache_test.go

此时未必需要 cmd/,也未必需要 internal/

16.3 推荐判断方式

项目类型 更常见的结构
应用型 cmd + internal
库型 根包或少量公开包
混合型 对外库 + 内部应用入口,按需组合

所以不要拿“库型项目的结构标准”去要求应用项目,也不要反过来。


17. 测试文件该放哪

这一课虽然主讲结构,但测试位置也属于工程组织的一部分。

17.1 最常见做法:测试和被测代码放一起

例如:

1
2
3
internal/task/
├── service.go
└── service_test.go

这是 Go 最常见、最推荐的做法。

好处:

  • 测试和实现离得近
  • 重构时不容易漏
  • 更符合 Go 社区习惯

17.2 测试数据放 testdata/

如果测试需要样例文件:

1
2
3
4
5
6
internal/store/
├── file_store.go
├── file_store_test.go
└── testdata/
├── valid_tasks.json
└── broken_tasks.json

testdata 是 Go 生态里很常见的约定目录。

17.3 不建议单独建一个巨大的 tests/ 目录

除非你在做:

  • 集成测试
  • 端到端测试
  • 多模块联调测试

否则把所有测试都扔进一个总 tests/ 目录,通常反而更难维护。


18. 从零搭项目时的推荐步骤

如果你以后要自己起一个 Go 小项目,建议按下面顺序来。

18.1 第一步:先起模块

1
go mod init example.com/myapp

18.2 第二步:只建必要目录

对于一个小型应用型项目,可以先建:

1
2
3
4
5
6
7
myapp/
├── go.mod
├── cmd/
│ └── myapp/
│ └── main.go
└── internal/
└── ...

18.3 第三步:先确定核心业务包

先问自己:

  • 这个项目最核心的业务对象是什么?
  • 哪些能力是基础设施?

比如任务工具:

  • 核心业务:task
  • 基础设施:configloggerstore

18.4 第四步:把 main 变薄

任何时候只要发现 main.go 开始变胖,就考虑把逻辑往包里挪。

18.5 第五步:边写边补测试

工程化不是最后一口气整理目录,而是边增长边控制结构。

尤其是:

  • 核心业务包
  • 存储实现
  • 配置解析

这些都非常值得尽早加测试。


19. 常见坑总结

19.1 一上来照抄“大而全模板”

1
2
3
4
5
6
7
8
9
cmd/
internal/
pkg/
api/
build/
deploy/
hack/
tools/
third_party/

问题不在这些目录本身,而在于:

你当前项目根本不需要它们。

解决思路:

  • 从最小结构开始
  • 目录随着需求增长

19.2 main.go 里写满业务逻辑

1
2
3
4
5
6
7
8
9
func main() {
// 解析参数
// 验证参数
// 读配置
// 查任务
// 改任务
// 写文件
// 输出结果
}

这会让:

  • 测试困难
  • 复用困难
  • 修改困难

正确思路:

  • main 负责组装
  • 业务逻辑放进领域包

19.3 乱建 util/common/helper

这是工程结构最容易腐化的入口。

如果你发现某段代码想放进 util,先反问一句:

它到底属于哪个明确职责?

大多数时候,它其实应该归属于某个现有包。

19.4 包之间互相 import

1
2
task -> store
store -> task

这通常说明职责划分不清。

修复思路通常有两种:

  1. 抽出更稳定的公共抽象
  2. 把接口挪到真正的使用方

19.5 配置读取散落各处

坏处:

  • 不知道配置总入口在哪
  • 改默认值很麻烦
  • 测试也难做

正确做法:

  • 用一个 Config 结构体统一承载
  • 在启动阶段一次性加载和校验

19.6 错误既记录又层层打印

例如:

  • store 打一遍错误日志
  • service 再打一遍
  • main 再打一遍

最后控制台全是重复信息。

正确做法:

  • 中间层补充上下文并返回
  • 最外层决定记录和展示

19.7 导出太多无关 API

如果一个包对外暴露了很多不必要的标识符:

  • 使用者不知道该用哪个
  • 重构时不敢动
  • 包边界会越来越糊

原则还是那句:

默认不导出,只有确实需要给包外访问的才导出。


20. 本课练习

练习 1:重构第 24 课的命令行项目

把第 24 课的命令行小项目从“单包结构”重构为:

  • cmd/xxx/main.go
  • internal/config
  • internal/<核心业务包>
  • internal/store

要求:

  • main 只负责参数解析和依赖组装
  • 核心业务逻辑移出 main

练习 2:给项目加统一配置入口

为一个已有的小项目增加 Config 结构体,统一承载以下配置:

  • 数据文件路径
  • 日志级别
  • 超时时间

要求:

  • 给出默认值
  • 对关键字段做校验
  • 业务层不能直接调用 os.Getenv

练习 3:给项目加统一日志入口

log/slog 为一个小项目创建统一日志初始化函数,要求:

  • 支持 DEBUGINFOWARNERROR
  • 在业务层记录至少 3 条结构化日志
  • 不再使用 fmt.Println 做调试输出

练习 4:错误包装练习

模拟三层调用:

  1. 文件读取层
  2. 业务处理层
  3. 程序入口层

要求:

  • 底层返回原始错误
  • 中间层用 %w 包装
  • 最外层用 errors.Is 判断特定错误并输出友好提示

练习 5:画出你的项目依赖图

任选一个你写过的 Go 小项目,画出包依赖关系图,例如:

1
2
3
main -> service -> repository
main -> config
main -> logger

然后检查:

  • 有没有双向依赖
  • 有没有职责不清的包
  • 有没有 util/common 这类模糊目录

21. 自测题

21.1 概念题

  1. cmd/ 目录通常放什么?它和 internal/ 的职责差别是什么?
  2. 为什么说 main.go 应该尽量“薄”?
  3. internal 相比普通目录最大的价值是什么?
  4. pkg/ 是必须的吗?什么时候适合用,什么时候不适合?
  5. 为什么不推荐滥用 utilcommonhelper 这类包名?
  6. 配置为什么应该集中加载,而不是在各层到处 os.Getenv
  7. 错误包装里的 %w 有什么作用?为什么工程里要重视它?
  8. 应用型项目和库型项目在结构上有什么典型区别?

21.2 代码阅读题

下面这个结构和代码有几个明显问题,试着找出来:

1
2
3
4
5
6
todo-app/
├── main.go
├── util.go
├── task.go
├── store.go
└── logger.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
package main

import (
"fmt"
"os"
)

func main() {
dataFile := os.Getenv("TODO_DATA_FILE")
if dataFile == "" {
dataFile = "tasks.json"
}

tasks, err := LoadTasks(dataFile)
if err != nil {
fmt.Println("load error:", err)
return
}

if err := AddTask(tasks, "学习 Go 工程结构"); err != nil {
fmt.Println("add error:", err)
return
}

if err := SaveTasks(dataFile, tasks); err != nil {
fmt.Println("save error:", err)
return
}
}
点击查看答案

至少有这些问题:

问题 1:所有逻辑都堆在 main 包里,没有明确边界。

  • 配置读取、数据读取、业务处理、输出都混在入口里
  • 后续很难测试和扩展

问题 2:目录和文件按“随手拆文件”组织,不是按职责或业务边界组织。

  • util.go 是典型模糊命名
  • 看目录结构无法快速判断职责

问题 3:配置没有集中管理。

  • 入口直接读环境变量
  • 如果其他地方也读配置,后面会越来越乱

问题 4:错误处理只有简单打印,没有错误上下文。

  • fmt.Println("load error:", err) 无法形成清晰错误链
  • 应该在各层包装上下文,在边界层统一输出

问题 5:没有统一日志方案。

  • 直接 fmt.Println 不能控制级别,也没有结构化上下文

更合理的重构方向是:

1
2
3
4
5
6
7
8
9
10
todo-app/
├── go.mod
├── cmd/
│ └── todo/
│ └── main.go
└── internal/
├── config/
├── logger/
├── store/
└── task/

22. 本课总结

这一课你真正要带走的,不是某个固定模板,而是工程组织的判断能力。

知识点 要点
结构目标 职责清晰、依赖单向、便于测试、便于扩展
常见目录 cmd 放入口,internal 放内部实现,pkg 按需使用
拆包思路 小项目可按职责拆,大一些的项目更适合按业务领域收拢
配置管理 统一加载、统一校验、统一传递
日志管理 统一初始化、统一格式、避免到处乱打
错误处理 %w 包装上下文,用 errors.Is 做边界判断

最重要的四件事:

  1. 工程结构不是越复杂越高级,而是越匹配当前规模越好
  2. main 负责组装依赖,业务逻辑应该放进明确的包
  3. 配置、日志、错误处理必须统一入口,否则项目会越来越乱
  4. 先建立清晰边界,再考虑扩展能力,internal 是非常实用的边界工具

23. 下一课预告

到这里,阶段五就完整了。你已经掌握了并发、测试、Benchmark 和基础工程组织,接下来要进入 Go 的高级能力。

下一课:泛型入门

会重点讲:

  • 为什么 Go 直到后期才加入泛型
  • 类型参数是什么
  • 约束是什么,怎么写
  • 泛型函数和泛型结构怎么定义
  • 泛型适合解决什么问题,不适合解决什么问题

学完下一课,你会真正进入 Go 语言“能看懂更现代代码”的阶段。