Files
cs-note/hzh/GO/工程模块化.md
2026-05-24 11:42:38 +08:00

12 KiB
Raw Permalink Blame History

tags, create time
tags create time
go
engineering
modularization
architecture
project-structure
2026-04-29 15:30

Go 工程模块化

概述

系统梳理 Go 项目的模块化和目录结构设计,从项目级别的架构模式到包粒度的拆分策略,涵盖工程化实践中最常见的结构选型和陷阱。

一、Go 模块与包的基础概念

[!question] 思考:Module 和 Package 有什么区别?

  • Module(模块):go.mod 定义的最小版本管理单元,发布、依赖管理的边界。一个 Module 可以包含多个 Packages。
  • Package(包):代码组织和编译的单位,同一目录下所有文件必须属于同一个 Package。import 的路径粒度是 Package 级别。

核心关系:Module → 多个 Package → 多个 .go 文件

module github.com/user/my-project    // go.mod — 模块标识

// import 路径
github.com/user/my-project/pkg/user  // Package: pkg/user
github.com/user/my-project/internal/handler  // Package: internal/handler

[!tip] 关键规则

  • internal/ 下的包只能被其父目录及以内的代码导入,外部 module 无法引用。这是 Go 语言层面强制的可见性约束,比 private 关键字更可靠。
  • 不要在 internal/ 里放第三方库或公共工具——它的语义就是"内部使用"。

二、项目级结构选型

2.1 标准 Go 项目布局(Standard Go Project Layout)

这是社区最广泛认可的项目结构规范,由 golang-standards/project-layout 提出并维护:

my-project/
├── cmd/                    # 入口点,每个子目录是一个可执行命令
│   └── myapp/
│       └── main.go         # 入口极薄,只做初始化调用
├── internal/               # 私有代码,外部无法 import
│   ├── handler/            # HTTP/gRPC 处理器
│   ├── service/            # 业务逻辑层
│   ├── repository/         # 数据访问层
│   ├── model/              # 领域模型
│   └── config/             # 配置加载
├── pkg/                    # 可对外发布的公共包
│   ├── middleware/
│   └── util/
├── api/                    # API 定义(proto/openapi/grpc-gateway)
├── web/                    # 前端静态资源
├── configs/                # 配置文件示例
├── scripts/                # 构建/部署脚本
├── docs/                   # 文档
├── tests/                  # 外部集成测试
├── go.mod
├── go.sum
└── README.md

[!success] 适用场景

  • 需要开源或向其他团队分发 pkg/ 中的代码
  • 项目会持续增长,需要明确的组织规范
  • 团队协作规模较大(5+ 人)

2.2 整洁架构(Clean Architecture / Onion Architecture)

以业务核心为中心,外层依赖内层,所有方向指向内:

my-project/
├── cmd/app/main.go
├── internal/
│   ├── domain/             # 【最内层】实体、值对象、领域接口
│   │   ├── entity/         # User, Order 等核心实体
│   │   ├── valueobject/    # Money, EmailAddress 等值对象
│   │   └── repository/     # 领域接口定义(契约)
│   ├── application/        # 用例/应用服务
│   │   └── usecase/        # CreateUserOrder 等业务编排
│   ├── infrastructure/     # 【外一层】实现细节
│   │   ├── persistence/    # 数据库驱动实现
│   │   ├── externalapi/    # 第三方支付、邮件等
│   │   └── auth/           # JWT/OAuth 实现
│   └── interfaces/         # 【最外层】接入方式
│       ├── http/           # HTTP handlers + router 注册
│       ├── grpc/           # gRPC server
│       └── cli/            # CLI 命令行
└── go.mod

依赖方向图示:

graph LR
    A["interfaces (HTTP/gRPC)"] --> B["application (usecases)"]
    B --> C["domain (entities)"]
    D["infrastructure (DB/API)"] --> C
    D -.->|implements| B
    style C fill:#f9f,stroke:#333,stroke-width:3px
    style B fill:#bbf,stroke:#333
    style D fill:#bfb,stroke:#333
    style A fill:#fbf,stroke:#333

[!info] Clean Architecture 核心原则

  1. 依赖倒置:内层定义接口,外层提供实现。这样换数据库、换框架不需要改动业务逻辑。
  2. 域独立:domain/entity 不依赖任何框架或外部库,就是纯 struct。
  3. 切换成本低:MySQL → PostgreSQL 只改 infrastructure/persistence 一个目录。

[!warning] 何时不用

简单 CRUD 项目引入 Clean Architecture 会带来过度抽象。按以下标准判断:

条件 推荐方案
单体、单一数据库、快速迭代 分层简单结构(见下方)
多团队、多数据源、长周期 Clean Architecture
领域复杂(金融、电商交易) Clean Architecture 或 DDD
MVP / PoC 越简单越好

2.3 轻量分层结构(适合大多数中小型项目)

如果 Clean Architecture 太笨重,三层是最小可用划分:

my-project/
├── cmd/app/main.go
├── internal/
│   ├── handler/      # 接收请求、参数校验、调 service
│   ├── service/      # 业务逻辑、流程编排
│   └── repo/         # SQL 查询、缓存读写
├── pkg/              # 可复用组件
└── go.mod

黄金法则:请求流向是单向的 handler → service → repo,禁止反向调用。

三、包粒度与拆分策略

3.1 拆到什么粒度合适?

[!question] 包的合理大小是多少?

没有一个精确的数字,但有以下参考基准:

维度 建议范围 说明
文件数 3 ~ 15 个 少于 3 可能该合并;超过 15 需检查是否职责过多
LOC 200 ~ 1500 行 纯接口/类型声明可适当放宽
职责 围绕一个概念 user、order、payment——用领域名词命名

[!failure] 常见反模式

  • utils 包地狱:把所有工具函数扔进 pkg/utils,包越来越大、越来越难找东西。
    • 解法:按功能域拆分——stringutil、datetimeutil、httputil。或者直接在使用的地方新建包。
  • 一个包对应一个文件:如果文件之间强相关,应该放在同一个包里,避免跨包循环引用。
  • 过深的包路径:internal/a/b/c/d/e 超过 4 层通常意味着分类有问题。

3.2 按什么维度拆包?

三种主流维度,实际项目中常组合使用:

flowchart TD
    A["包拆分维度"] --> B["按职责分层<br/>handler/service/repo"]
    A --> C["按业务域分组<br/>user/order/payment"]
    A --> D["混合模式"]

    D --> E["internal/user/handler<br/>internal/user/service<br/>internal/user/repo"]

    style E fill:#f9f,stroke:#333,stroke-width:3px
模式 结构示意 适合
分层式 handler/, service/, repo/ 单一聚合根、领域不太复杂
域驱动式 user/, order/, payment/ 多领域边界清晰、大团队
混合式 user/handler, user/service 多数项目的最佳平衡点

3.3 共享包 vs internal 包的决策树

某个包要不要暴露出去?
├── 其他 module 会需要吗?
│   ├── 会 → pkg/xxx
│   └── 不会 → internal/xxx
└── 整个项目都不需要 import 呢?
    └── 直接放在所属业务包下,不要单独建包

[!tip] pkg/ 的使用建议

  • 放进 pkg/ 等于承诺这是一个稳定的公开 API——其他人会依赖它。
  • 常见的放 pkg/ 的内容:日志封装、数据库连接池初始化、通用中间件、配置结构体。
  • 如果不打算开源且没有外部消费者,优先放 internal/。

四、避免循环依赖

Go 编译器在编译期就会拒绝循环 import,所以不会像 Java 那样出现运行时问题。但在编码阶段频繁遇到,以下是高频场景和解法:

4.1 常见循环依赖路径

handler → service → repository → model → handler ❌

[!failure] 根因:model 定义了需要在 handler 中序列化的字段(如 LastLoginTime),repository 也用到 model。

4.2 解法矩阵

quadrantChart
    title 循环依赖解法选择
    x-axis "低复杂度" --> "高复杂度"
    y-axis "侵入性小" --> "侵入性大"
    "提取接口": [0.2, 0.15]
    "下沉共享包": [0.4, 0.35]
    "引入 DTO": [0.5, 0.55]
    "依赖注入": [0.7, 0.75]
解法 做法 适用场景
接口下沉 把接口移到更通用的包(如 internal/repository/contract) service 和 repo 之间的依赖混乱
共享模型包 创建 internal/model 存放双方共用的类型 model 确实被多层共用
DTO 隔离 handler 和 domain 用不同的类型,通过转换函数对齐 序列化格式和领域模型不一致
依赖注入 用构造器传入依赖而非全局变量或直接 import(详见 依赖注入) 大型项目、测试友好

五、Go 项目的目录设计 Checklist

初始化新项目时按此顺序思考:

flowchart LR
    A["确定项目性质"] --> B{"需要对外发布包?"}
    B -->|是| C["使用标准布局 + pkg/"]
    B -->|否| D["精简布局,全放 internal/"]
    D --> E{"领域复杂度?"}
    E -->|简单 CRUD| F["轻量三层结构"]
    E -->|复杂业务| G["Clean Architecture"]
    F --> H["按单一路径拆分: handler/service/repo"]
    G --> I["按域分组 + 分层"]
    C --> J["开始编码"]
    H --> J
    I --> J

逐项检查清单:

  • go.mod 的 module name 已定好(建议用完整 import path,如 github.com/org/repo)
  • cmd/ 下有 main.go,且逻辑不超过 20 行(只做依赖组装和应用启动)
  • internal/ 包含了所有一级私有包
  • 每个包的 package 名和目录名一致(Go 强制要求,但容易在移动目录时遗忘)
  • 没有 utils、common、tools 这类万金油包名
  • 跨包引用是单向的,无环(可用 goimports -l 辅助检查)
  • 测试文件跟源文件放在一起,同包测试用 _test.go,白盒外部测试用 x_test.go(package xxx_test)

六、模块化管理实战要点

6.1 单 Module vs 多 Module

graph LR
    A["项目依赖关系"] --> B{"各部分版本号需要独立控制?"}
    B -->|是| C["Monorepo + 多 go.mod"]
    B -->|否| D["单个 go.mod"]

    C --> E["各自独立版本发布"]
    D --> F["统一版本,最简单"]

    style D fill:#bfb,stroke:#333,stroke-width:3px
    style C fill:#fbf,stroke:#333

[!summary] 决策建议

  • 绝大多数项目:一个 go.mod 就够了。Go 的包系统已经足够做模块化解耦。
  • 需要多 Module 的场景:多个团队独立发布、不同生命周期(一个高频发版一个低频)、或作为独立 library 提供给他人。
  • Monorepo 多 Module 时,用 replace 指令在本地开发时指向 sibling module,CI 时移除。

6.2 Import 路径风格约定

✅ 推荐
internal/user/service
internal/user/service/user.go          // 文件名不必与包名重复

❌ 应避免
internal/user-service/service          // 连字符不符合 Go 命名惯例
internal/user_service/                 // 下划线也不常用
pkg/util/helper.go                     // 嵌套过深,helper 本身没有信息量

基本原则:import 路径应该是可读的英文短语,用 / 分隔单词,全小写。

关联笔记