← [ BACK_TO_NOTES ]

Go 项目结构与分层

组织 Go 项目时关键的不是背目录模板,而是包边界、依赖方向与变化隔离;cmd/internal 与分层只是实现这些原则的手段。

#synthesis #growing #dev #backend #go

[!info] Related notes

Go 项目结构与分层

这个概念为什么会出现

进入真实服务后,问题不再是“能不能编译”,而是:

  • 改 HTTP 协议会不会牵动数据库代码
  • 业务规则能不能在 CLI、定时任务、HTTP 间复用
  • 新同事能否在 10 分钟内找到“用例从哪进、数据从哪出”
  • 测试能否只测业务而不起整站

目录名(pkg/、internal/、domain/)只是可见的结果。真正要设计的是:包边界、依赖方向、变化来源。很多人先抄一套“标准布局”,结果小项目被脚手架压垮,大项目却仍循环依赖。

[!abstract] 一句话理解 先按职责与依赖方向切包,再选目录:接口层依赖业务,业务不依赖具体框架/DB 驱动细节;cmd 组装、internal 封装、测试与部署围绕边界展开。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:订单服务骨架——cmd 组装,用例依赖仓储接口

新开一个订单微服务。小服务不必上 DDD 全家桶,但要先定依赖方向:

  • cmd 只组装
  • HTTP 调用例
  • 用例依赖 OrderRepo 接口,不绑 Postgres

下面目录树表示拆包后的组织;随后的单文件程序仅演示依赖注入与 HTTP 错误映射,并未验证跨包边界。仓储是无存储行为的 stub,返回 201 不代表订单已持久化。

myapp/
  go.mod
  cmd/myapp/main.go          # 组装:读配置、建依赖、起服务
  internal/
    app/order/               # 用例:创建订单、取消订单
      service.go
    domain/order/            # 领域模型与规则(可选独立)
      order.go
    httpapi/                 # 传输:HTTP 路由与 DTO
      handler.go
      routes.go
    store/postgres/          # 基础设施:实现仓储接口
      order_repo.go
package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"time"
)

// OrderRepo:仓储端口——用例不关心 postgres 细节
type OrderRepo interface {
	Save(ctx context.Context, id string) error
}

var ErrEmptyID = errors.New("empty id")

// OrderService:创建订单用例
type OrderService struct {
	Repo OrderRepo
}

func (s *OrderService) Create(ctx context.Context, id string) error {
	// 领域规则:id 必填(可再扩展库存、幂等等)
	if id == "" {
		return ErrEmptyID
	}
	return s.Repo.Save(ctx, id)
}

// stubRepo:只检查调用时的取消状态,不保存任何订单
type stubRepo struct{}

func (stubRepo) Save(ctx context.Context, id string) error {
	// 这里只做一次取消检查;真实数据库操作应传递 ctx,取消也不保证回滚
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
		_ = id
		return nil
	}
}

func orderHandler(svc *OrderService) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
		id := r.URL.Query().Get("id")
		if err := svc.Create(r.Context(), id); err != nil {
			if errors.Is(err, ErrEmptyID) {
				http.Error(w, "empty id", http.StatusBadRequest)
			} else {
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
			return
		}
		w.WriteHeader(http.StatusCreated)
	})
	return mux
}

func main() {
	svc := &OrderService{Repo: stubRepo{}}
	mux := orderHandler(svc)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	log.Fatal(srv.ListenAndServe())
}

此路由写法要求 Go 1.22+。将单文件示例保存为 main.go 后运行:

go run main.go
# 终端 2:
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8080/orders?id=o1"
curl -s -o /dev/null -w "%{http_code}\n" -X POST "http://localhost:8080/orders"

期望:

201
400

结合场景再看三个关注点

  1. main 只做组装;业务不 import 具体框架全局。
  2. OrderService 依赖 OrderRepo 接口,存储可替换。
  3. 包名表达职责(order、httpapi),不是技术时髦词堆砌。

核心概念与准确模型

1. 包是边界,目录是投影

Go 的编译与可见性以包为单位(见 包与可见性)。好结构首先回答:

问题好答案的特征
这个包对外承诺什么?少而稳的导出 API
谁可以依赖谁?单向、无环
变化从哪进来?协议、DB、第三方 SDK 被边界挡住
测试如何站在边界外?对接口打桩,不必起全栈

目录只是让人眼与 go list 舒服的投影。

2. 常见分层(逻辑层,不是强制文件夹)

flowchart TB
    T["传输层 HTTP/RPC/CLI"] --> A["应用/用例层"]
    A --> D["领域规则与模型"]
    A --> P["端口/接口"]
    I["基础设施 DB/MQ/HTTP Client"] --> P
层职责典型依赖
传输编解码、状态码、鉴权中间件用例 API、DTO
应用/用例事务边界、编排、权限检查领域、仓储接口
领域不变量、状态迁移尽量零基础设施
基础设施SQL、SDK、文件系统实现端口接口

小项目可把“领域 + 用例”合在一个 internal/app/foo 包;不要为了分层而分层。

3. 社区惯例目录的真实含义

路径含义误用
cmd/<name>/可执行入口,薄 main塞满业务
internal/仅允许其父路径范围内的代码导入(Go 工具链强制;不等于同 module)把一切塞进一个 internal/pkg 大杂烩
pkg/有意对外库式 API 的可选惯例;目录名无特殊可见性规则假装“更专业”而空转
api/OpenAPI/proto 等契约源与实现循环引用
configs/、deploy/、scripts/运维与工具运行时强依赖未嵌入的路径(见 [[go-embed

官方并未规定唯一标准树;Organizing a Go module 强调 module 内清晰与 internal 封装。golang-standards/project-layout 是社区模板,不是语言规范。

4. 依赖方向规则

  1. 稳定方向内指:框架适配器 → 用例 → 领域。
  2. 具体实现指向抽象:postgres.OrderRepo 实现 order.Repository,而不是 service import 驱动细节到处飞。
  3. 本分层约定禁止反向:domain 不依赖 httpapi 或 ORM;无环的反向 import 仍可能通过 Go 编译,需架构检查另行约束。
  4. 跨包共享:用小 API 或消费者接口连接边界;传输 DTO 在边界转换,避免共享后把协议细节带进业务。慎建“什么都有的 util”。

5. 按业务切片 vs 按技术分层

方式优点风险
技术层 handlers/ services/ repos/上手快大了后跨目录改一个用例
业务竖切 order/ billing/ 内含各层用例内聚公共内核要克制
混合:internal/order 竖切 + 薄 platform/多数中型服务甜点需约定清晰

经验:边界以用例变化频率为准,不是以“目录对称美观”为准。

6. 配置、日志、遥测放哪

  • 构造期注入:Logger、Meter、Config 作为依赖传入。
  • 避免每个包 init() 读全局文件。
  • main 负责:读环境变量/文件 → 构造图 → Run(ctx)。

设计动机

  1. 变化隔离
    HTTP 换 gRPC、Postgres 换别家,不应重写业务规则。
  2. 可测性
    用例对接口测试;传输层用 httptest;存储层用集成测。
  3. 团队导航
    新人按“入口 → 用例 → 存储”三条线找代码。
  4. 与 Go 工具链契合
    包级可见性、internal 强制封装、module 边界清晰(Modules)。

边界情况与反直觉行为

1. 过早微服务 / 多 module

多 module 或多仓本身不会把函数调用变成网络调用,但会增加版本协调;拆成独立部署的微服务才引入分布式调用与失败模式。先明确包边界,再决定版本和部署边界。

2. utils / common 黑洞

无领域语义的大包会成为循环依赖与隐性耦合中心。优先:就近私有辅助;真共享再提 internal/x。

3. 接口定义在错误的一侧

Go 接口隐式满足,接口放在实现侧并不必然导致不兼容;问题通常是接口过大、由实现细节驱动。惯用:接口由消费者按所需能力定义,或放在稳定的领域端口包。

4. 把 DTO 当领域模型

JSON tag、DB tag 堆在同一结构上,传输变更撕裂持久化。见 JSON 与序列化。

5. pkg/ 对外承诺过重

一旦有外部使用者依赖导出 API,兼容成本就会上升,不论目录是否叫 pkg、源码是否开源。服务实现可先放 internal;需要公开复用时再设计稳定 API。

常见误区

[!warning] 常见误区:先抄完整 project-layout 错误:空目录二十个,业务仍糊在 main。
正确:三五个包把依赖方向做对,再随痛点加目录。

[!warning] 常见误区:分层 = 每层一个目录必须对称 错误:10 行逻辑也硬拆 domain/application/infrastructure。
正确:复杂度不够时合并;箭头正确优先。

[!warning] 常见误区:业务包 import 框架全局 错误:service 里直接依赖某 Web 框架 Context 类型。
正确:传输层适配;业务用 context.Context 与自己的类型。

[!warning] 常见误区:循环依赖用“再抽一个包”无限后移 错误:a → b → a 靠第三个糊墙。
正确:重新划分所有权或引入接口断环。

与相邻概念对比

概念差异
包与可见性语言机制;本篇是机制上的结构策略
Modules依赖版本与仓边界;layout 是仓内组织
H/S/RWeb 场景的一种具体分层落地
构建部署产物与交叉编译;入口多在 cmd/
整洁/六边形架构思想同源;Go 落地要克制仪式感

工程实践

  1. cmd 保持瘦:组装 + 信号 + 退出码。
  2. 默认 internal:除非明确要对外库。
  3. 接口小而稳:Save/Find 而非上帝 Repository。
  4. 一个用例一个主路径文件,复杂再拆 private 文件(同包)。
  5. 测试文件以 _test.go 结尾;声明 package foo 可测包内实现,package foo_test 从外部 API 测试。
  6. 文档化依赖图:在 README/MOC 画一张“允许 import”表。
  7. CI:go test ./...、go test -race ./...、go vet ./...;导入环在包加载/构建时就会失败,自定义分层方向需另加检查。
  8. 演进:从单体包 → 按痛点拆,而不是按设想一次拆完。
// 允许的依赖方向(约定示例)
// httpapi → app → domain
// store   → app/order(本例 OrderRepo 接口归用例包;若移到 domain 则依赖 domain)
// cmd     → 所有具体实现(唯一组装点)

可验证实验

2026-10-07 使用 Go 1.26.8 与 httptest 验证单文件示例的成功、缺少 id、仓储失败、取消和方法不匹配路径;架构目录树仍是拆包建议。

实验 1:画依赖

对现有小项目跑 go list -f '{{.ImportPath}} {{.Imports}}' ./...,标出是否有“领域 → 传输”。

实验 2:替换存储

为 OrderRepo 增加 fakeRepo,不改 service 跑单测。

实验 3:第二入口

加 cmd/myapp-cli 调用同一 OrderService,验证业务不绑 HTTP。

实验 4:故意反向 import

让 domain import httpapi:若形成环,Go 构建失败;若未形成环,只有团队配置的依赖方向检查会阻止它,go vet 不会自动理解目录分层约定。

本节总结

  • 本质:结构 = 包边界 + 依赖方向 + 变化隔离;目录是投影。
  • 手段:cmd 组装、internal 封装、用例与基础设施分离。
  • 克制:小项目简单清晰 > 仪式化分层。
  • 下一步:用 net/http 与 优雅关闭 填满 cmd 生命周期;用测试钉住端口。

自测题

概念题

  1. 为什么说“目录模板不是核心”?
  2. internal/ 提供了什么语言级保证?
  3. 接口应更常由谁定义?

代码推理题

package domain 中 import "myapp/internal/httpapi" 意味着什么架构问题?

工程思考题

10 人团队、单仓、HTTP+异步消费者共用下单逻辑,你如何切包,避免两套业务分叉?

参考答案

展开
  1. 真正约束可维护性的是依赖方向与职责边界;目录名可替换。
  2. 工具链限制 internal 父路径范围之外的导入;嵌套 internal 可限制同 module 的兄弟包,满足父 import path 范围的另一 module 也可能被允许。
  3. 更常由消费者(用例)定义小接口,实现放在基础设施。
    代码题:领域依赖传输,依赖反转失败,业务被 HTTP 细节污染。
    工程题:把下单用例放 internal/app/order,HTTP 与 consumer 都只做适配并调用同一 service;共享领域规则,不共享框架类型。

延伸阅读与资料来源

资料类型支撑
Organizing a Go module官方文档module 内组织建议
Package names官方博客命名与职责
Go Modules Reference规范module 边界
Standard Library参考标准库自身的包划分风格
Code Review CommentsWiki包与 API 评审习惯

笔记元信息

  • 建议文件名:go-project-layout-and-layering.md
  • 所属阶段:服务工程化
  • 学习顺序:Modules 与包可见性之后
  • 建议下一篇:分层落地 或 优雅关闭
  • 本篇状态:已深化(结构完整;强调依赖方向而非目录教条)
创建于 2026/6/20 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本