Go 基础体系 · 第 38/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go 项目工程化:目录、依赖注入、代码生成与质量门禁

本文所有代码和命令均以 Go 1.26.4 为基准。Go 没有要求所有仓库采用同一套“标准目录”。目录是包和依赖边界的物理表达,不是架构本身:真正重要的是入口能否看清装配,业务规则是否内聚,基础设施是否通过明确契约接入,初始化和关闭是否有所有者,以及任何开发者能否用同一组命令重现生成、测试和发布。

本文讨论一个服务从进程入口、依赖装配到 CI 门禁的工程生命周期。package/module 的语言与版本语义、工具链缓存、具体测试 API 分别属于相邻主题;这里关注它们怎样组成可维护项目。

1. 先有职责,再有目录

小程序从 main.go 和少量包开始完全合理。只有当代码出现独立业务能力、不同变化原因、需要限制导入或多个命令共享实现时才拆包。为了看起来“企业级”预建 controller/service/repository/utils 四层,常会让一次简单修改穿过大量只做转发的文件。

一个中型服务可从下面的形状演进:

article-service/
├── cmd/article-api/main.go
├── internal/article/          # 文章业务规则与用例
├── internal/postgres/         # 存储适配器
├── internal/httpapi/          # HTTP 输入输出适配器
├── migrations/
├── tools/                     # 仓库脚本或生成工具源码
├── go.mod
└── README.md

目录名应表达能力或适配器,而不是把所有业务横切成 modelshandlersservices。同一能力的类型、规则和操作靠近,维护者才能局部理解变更。

2. cmd 是进程入口,不是业务层

cmd/<name> 通常对应一个可执行文件。main 负责进程级工作:解析配置、创建 logger、连接基础设施、装配服务、启动监听、接收停止信号和按顺序关闭。它不应包含价格计算、权限判断或 SQL。

func run(ctx context.Context, cfg Config) error {
	db, err := openDB(ctx, cfg.DatabaseURL)
	if err != nil { return fmt.Errorf("open database: %w", err) }
	defer db.Close()

	repo := postgres.NewArticleRepository(db)
	service := article.NewService(repo)
	server := httpapi.NewServer(cfg.Addr, service)
	return server.Run(ctx)
}

保留一个返回 errorrun,让 main 只决定退出码和最终日志,也让装配可测试。不要在 init 中连接数据库或启动 goroutine;隐式生命周期无法注入失败,也很难保证关闭。

3. internal 是强制边界,pkg 不是必需仪式

放在 internal 下的包只能由其父目录树内代码导入,工具链会执行限制,适合不承诺外部兼容的应用实现。仓库若没有供外部模块导入的库,就不需要为了模板完整创建 pkg

真正公开的包应放在模块中清楚命名的位置,并承担版本兼容责任。pkg 目录本身没有特殊工具链语义,也不会自动让 API 设计变好。反过来,internal 只限制导入范围,不提供安全隔离;同一树中的包仍可导入,秘密和权限问题要另行解决。

apiconfigsscripts 等目录也不是保留字。创建前写清所有者和输入输出:OpenAPI 是源文件还是生成结果?迁移由谁执行?脚本支持哪些平台?没有契约的目录最终会成为杂物区。

4. 包依赖应形成从入口指向能力的有向图

入口处于最外层,可以导入业务和基础设施;业务包不应反向导入 cmd、具体 HTTP 框架或数据库驱动。适配器可以依赖业务定义的数据类型并实现消费方接口。

cmd/article-api
   |----> internal/httpapi ----> internal/article
   |----> internal/postgres ---> internal/article
   `---------------------------> internal/article

article 为了保存数据而导入 postgres,又让 postgres 导入 article.Article,就形成循环。正确修复通常是由 article 定义它实际需要的小型 Repository 接口,上层把 postgres 实现注入;或者合并本来就是一个能力的两个包。创建万能 common 只会把循环藏成全局耦合。

使用 go list -deps ./...go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... 检查依赖事实。架构规则重要时可写静态测试或检查脚本,避免只靠图表过期后继续自我安慰。

5. 接口由使用方定义,构造函数维护不变量

依赖注入在 Go 中通常只是显式传参,不需要容器。业务包定义完成工作所需的最小接口,并通过构造函数接收:

type Repository interface {
	Find(context.Context, string) (Article, error)
	Save(context.Context, Article) error
}

type Service struct { repo Repository; clock func() time.Time }

func NewService(repo Repository, clock func() time.Time) (*Service, error) {
	if repo == nil || clock == nil { return nil, errors.New("dependencies are required") }
	return &Service{repo: repo, clock: clock}, nil
}

接口放在消费者处,使它只描述消费者需要的能力。不要为每个结构体机械创建同名接口,也不要接受 any 或 service locator 再在运行时查找依赖。构造函数应建立必需不变量;可选项较多时可使用配置结构,但 functional options 只有在确实改善调用兼容性时才值得引入。

依赖的并发安全、所有权和关闭责任必须写进契约。通常创建资源的外层负责关闭,业务服务只借用 repository;把 Close 塞进所有接口会混淆所有权。

6. 传输、领域与持久化模型可以不同

一个 HTTP 请求可能使用字符串时间和可选指针,领域对象维护强类型状态,数据库行又包含 nullable 列和扫描标签。强行共享一个“万能 Article”会把 JSON 标签、SQL null 和业务不变量耦合到一起。

type createRequest struct { Title string `json:"title"` }
type Article struct { ID ID; Title string; CreatedAt time.Time }
type articleRow struct { ID string; Title string; CreatedAt time.Time }

在边界显式转换看似多写几行,却让错误归属清晰:HTTP 层处理语法与状态码,业务层处理规则,存储层处理 schema 和驱动错误。简单 CRUD 若模型确实一致,可以共享;原则是根据变化原因决定,不是永远复制或永远复用。

7. 生命周期从创建顺序推导关闭顺序

一个服务常按配置、日志、数据库、后台 worker、HTTP server 的顺序启动,关闭通常反向进行:先停止接收新请求,等待在途请求和 worker,再关闭数据库与日志 sink。每一步都要有超时和错误策略。

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

group, ctx := errgroup.WithContext(ctx)
group.Go(func() error { return api.Run(ctx) })
group.Go(func() error { return worker.Run(ctx) })
if err := group.Wait(); err != nil { return fmt.Errorf("run service: %w", err) }

综合示例为避免外部依赖使用标准库实现同样语义。生产可使用 errgroup,但必须知道第一个错误如何取消其他组件。goroutine 必须有退出条件和所有者;禁止在构造函数里启动无法停止的后台循环。关闭超时发生时应记录未完成组件,并由入口决定退出。

8. 配置、迁移与启动探针属于不同阶段

配置加载先产生经过校验的强类型对象;数据库连接成功只证明当前网络和凭据可用,不证明 schema 已兼容。迁移可以由独立发布作业执行,也可由进程启动执行,但只能选择一种有并发与回滚策略的所有权模型。

readiness 表示实例当前能否接流量,liveness 表示进程是否需要重启。不要让 liveness 因短暂下游故障失败而制造重启风暴;readiness 可以在关键依赖不可用时摘流。启动阶段失败应返回带上下文的错误,不能无限重试掩盖配置错误。

目录布局应让运维资产可追溯:迁移脚本与应用兼容规则、示例配置与实际 schema、容器入口与 cmd 命令一一对应。秘密不能放进示例或嵌入二进制。

9. go generate 是显式开发动作

//go:generate 注释由 go generate 扫描执行;它不会在 go buildgo test 或模块下载时自动运行。这是刻意设计:生成可能需要额外工具、网络或较高权限,构建不应偷偷执行任意命令。

//go:generate go run ./internal/cmd/genstatus -in status.yaml -out status_gen.go

生成器输入、版本和输出必须可追踪。优先让生成器作为模块内 Go 程序或固定版本工具存在,避免每台机器使用 latest。生成文件顶部标明来源和“DO NOT EDIT”,内容排序稳定,不写绝对路径和当前时间。

是否提交生成物取决于消费者是否需要在普通构建中拥有生成工具。应用常提交生成结果并在 CI 重新生成后检查无差异;无论选择哪种策略,都不能让开发机残留文件成为隐式输入。

10. 生成与构建应可重复验证

一个常见检查流程是:先在干净工作树运行生成,再格式化和测试,最后确认仓库没有差异:

go generate ./...
gofmt -w .
go test ./...
git diff --exit-code

git diff --exit-code 只检查已跟踪文件变化,未跟踪输出还需 git status --porcelain 或明确输出清单。CI 不应自动提交修复结果;它应失败并告诉开发者怎样本地重现。生成器使用 map 时必须排序键,模板换行要固定,避免每次得到不同 diff。

若生成依赖外部 schema,应先把带校验和的输入固定为仓库或构建制品,而不是 CI 每次从会变化的 URL 拉“最新”。代码生成是供应链的一部分,工具本身也要升级、审查和测试。

11. 测试按边界分层,不按目录堆数量

业务包的单元测试使用消费方小接口替身,验证规则与错误;HTTP 层用 httptest 验证协议映射;数据库适配器对真实数据库或兼容测试环境验证 SQL;入口用少量集成测试验证装配和关闭。每层回答不同问题。

type memoryRepo struct { saved []article.Article }
func (m *memoryRepo) Save(_ context.Context, a article.Article) error {
	m.saved = append(m.saved, a); return nil
}

不要为了提高覆盖率让所有内部实现公开,也不要用 mock 精确断言每一次内部调用顺序,使重构变得脆弱。测试应围绕可观察契约。共享数据库、环境变量和端口会限制 t.Parallel;测试夹具必须有明确清理责任。

12. 最小质量门禁及其顺序

每个门禁都应对应具体风险,并提供本地同款命令:

gofmt -w .
go vet ./...
go test ./...
go test -race ./...
go mod tidy
git diff --exit-code

CI 中通常用格式检查而非直接修改,可先生成临时 diff或运行格式化后检查工作树。go vet 找特定可疑模式,不是完整正确性证明;race detector 只发现实际执行路径中的数据竞争,成本较高且不支持所有目标组合。go mod tidy 会修改文件,应在 CI 后检查 diff。

更大项目可加入静态检查、漏洞扫描、许可证审计、覆盖率趋势、迁移验证和制品扫描,但规则必须有人维护。把警告永久忽略比没有工具更危险;新增门禁时说明失败归属、例外机制与升级节奏。

13. CI 流水线应分离快速反馈与高成本验证

提交阶段先执行格式、生成一致性、vet 和单元测试,快速暴露确定性错误。race、集成测试、跨平台构建和安全扫描可以并行或放到后续作业,但合并前必须达到项目定义的门槛。发布流水线使用已通过验证的提交构建一次制品,再在环境间提升同一制品,而不是每个环境重新编译。

缓存模块和构建结果可以加速 CI,但缓存键必须包含 Go 版本和依赖文件。失败时先保留日志与环境,再考虑清缓存。外部服务测试应固定镜像版本、等待健康而非 sleep,并为失败输出连接信息和容器日志。

分支保护只说明某些作业成功,不保证门禁覆盖所有发布路径。定期审计工作流触发条件、矩阵和允许跳过的规则,特别是标签发布与紧急修复路径。

14. 可观测性和诊断也是项目结构的一部分

HTTP、worker 和存储适配器应传播 context.Context,但不要把 context 存进长期结构体。入口建立日志、指标和追踪基础设施,边界添加稳定字段;业务包返回带语义的错误,由传输层映射状态并在拥有处理结论的位置记录一次。

诊断端点放在独立管理服务,健康检查、指标和 pprof 有各自暴露策略。版本信息由构建系统注入并通过命令或端点输出。README 应给出启动、配置、迁移、测试、生成和故障采集的真实命令;文档中的命令最好由 CI 验证或调用仓库脚本,避免漂移。

15. 常见反模式与重构信号

  • utils/common 被所有包导入,变成无所有者的耦合中心。
  • 全局可变单例和 service locator 隐藏依赖,测试互相污染。
  • 每个数据类型一个包或接口,改一个字段需要穿过十层转发。
  • 业务包导入 HTTP 或数据库具体类型,无法在其他入口复用。
  • 构造函数启动后台 goroutine,却不返回关闭方式。
  • 生成器版本未固定,CI 自动改文件但仍继续发布。
  • README 命令与流水线不同,本机问题无法复现。

重构应由真实疼痛驱动:包频繁因不同原因修改、依赖循环、测试必须启动整个系统、一个团队无法独立拥有能力。先移动最小稳定边界并保持测试绿色,不要一次性套用全新目录模板。目录变化会影响 import 和公共 API,发布库时尤其要考虑兼容性。

16. 性能与生产实践

更多包和接口本身通常不是主要运行时成本,错误抽象造成的额外序列化、细粒度远程调用和无界 goroutine 才是。先通过 profile 证明瓶颈,再决定合并层次、批处理或缓存。依赖注入让性能实验更容易,因为可以替换存储和时钟,但不要在热路径滥用反射容器。

生产项目应明确资源预算:HTTP 超时、数据库连接池、worker 上限、队列背压、内存软限制和关闭时间。所有默认值集中配置并被负载测试验证。制品包含提交、Go 版本和模块信息,部署记录配置版本与迁移版本,事故才能从实例追溯到源码。

17. 可运行综合示例:显式装配与优雅关闭

下面的最小服务把业务、内存适配器和进程入口分包。业务接口由消费者定义,入口拥有资源生命周期,HTTP 层只做协议转换。完整模块和并发测试已放入验证目录。

// internal/greeting/service.go
package greeting

import (
	"context"
	"errors"
	"strings"
)

type Store interface { Put(context.Context, string) error }
type Service struct { store Store }

func New(store Store) (*Service, error) {
	if store == nil { return nil, errors.New("store is required") }
	return &Service{store: store}, nil
}

func (s *Service) Greet(ctx context.Context, name string) (string, error) {
	name = strings.TrimSpace(name)
	if name == "" { return "", errors.New("name is required") }
	if err := s.store.Put(ctx, name); err != nil { return "", err }
	return "hello, " + name, nil
}
// internal/memory/store.go
package memory

import (
	"context"
	"sync"
)

type Store struct { mu sync.Mutex; names []string }

func (s *Store) Put(ctx context.Context, name string) error {
	if err := ctx.Err(); err != nil { return err }
	s.mu.Lock(); defer s.mu.Unlock()
	s.names = append(s.names, name)
	return nil
}
func (s *Store) Count() int { s.mu.Lock(); defer s.mu.Unlock(); return len(s.names) }
// cmd/greeting/main.go
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"example.com/layout/internal/greeting"
	"example.com/layout/internal/memory"
)

func run(ctx context.Context) error {
	store := &memory.Store{}
	service, err := greeting.New(store)
	if err != nil { return err }
	mux := http.NewServeMux()
	mux.HandleFunc("GET /greet/{name}", func(w http.ResponseWriter, r *http.Request) {
		message, err := service.Greet(r.Context(), r.PathValue("name"))
		if err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return }
		_, _ = fmt.Fprintln(w, message)
	})
	srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 3 * time.Second}
	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()
	select {
	case err := <-errCh:
		if errors.Is(err, http.ErrServerClosed) { return nil }; return err
	case <-ctx.Done():
		shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		return srv.Shutdown(shutdown)
	}
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	if err := run(ctx); err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
}
gofmt -w .
go vet ./...
go test ./...
go test -race ./...
go build -trimpath -o dist/greeting ./cmd/greeting

这个例子刻意没有框架和注入容器:依赖图在 main 中一眼可见,业务测试不需要端口,存储并发契约有 race 测试,进程接到信号后有界关闭。项目扩大时可以增加适配器和命令,但这些生命周期原则不需要改变。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。