2. Go 项目结构基础:标准布局与模块化设计
在构建业务模块与领域边界之前,必须先建立一套稳定、可扩展的项目骨架。Go 社区经过多年实践,形成了以 cmd/internal/pkg 为核心的标准项目布局,配合模块化设计原则,能够有效支撑 DDD 战术设计落地。本章将拆解这套骨架的职责边界与设计规则。
2.1 标准 Go 项目布局总览
标准布局并非官方强制规范,而是社区共识的最佳实践。它通过目录命名与层级关系,清晰表达了“入口-内部实现-可复用库”的三层结构。下图展示了最常用的布局形态:
关键原则:cmd 目录下的每个子目录都是一个独立的 main 包,只负责组装依赖、启动进程;internal 目录受 Go 编译器保护,外部项目无法导入;pkg 目录存放与业务无关的通用工具,可被外部项目引用。
2.2 三大核心目录职责对比
| 目录 | 核心职责 | 导入限制 | 典型内容 | 与 DDD 的关系 |
|---|---|---|---|---|
| cmd/ | 可执行程序入口,依赖组装与启动 | 仅导入 internal 和 pkg | main.go、依赖注入、配置加载、服务器启动 | 对应 DDD 的“基础设施层”启动点 |
| internal/ | 私有应用逻辑,领域核心 | 禁止外部项目导入 | 领域模型、应用服务、仓储接口、Handler | 承载领域层与应用层核心代码 |
| pkg/ | 可复用公共库,与业务无关 | 无限制,对外暴露 | 日志、验证器、错误处理、HTTP 中间件 | 支撑基础设施层,不包含领域知识 |
常见误区:将业务逻辑直接写在 cmd/main.go 中,或将领域模型放在 pkg 目录下。cmd 应保持“薄入口”,pkg 不应包含任何业务语义。
2.3 cmd 目录:薄入口与依赖组装
cmd 目录下的每个子目录对应一个独立的二进制程序。每个 cmd 子目录只包含一个 main.go 文件,职责仅限于:
- 读取配置(环境变量、配置文件)
- 初始化基础设施连接(数据库、消息队列、缓存)
- 组装依赖并启动应用服务
- 注册信号处理实现优雅关闭
// cmd/api/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"your-project/internal/handler"
"your-project/internal/repository"
"your-project/internal/service"
"your-project/pkg/logger"
"your-project/pkg/middleware"
)
func main() {
// 1. 加载配置
cfg := loadConfig()
// 2. 初始化基础设施
db := initDatabase(cfg.DSN)
log := logger.New(cfg.LogLevel)
// 3. 组装依赖
userRepo := repository.NewUserRepo(db)
userSvc := service.NewUserService(userRepo, log)
userHandler := handler.NewUserHandler(userSvc)
// 4. 注册路由
mux := http.NewServeMux()
mux.HandleFunc("POST /users", userHandler.CreateUser)
mux.HandleFunc("GET /users/{id}", userHandler.GetUser)
// 5. 启动 HTTP 服务器
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: middleware.Chain(mux, middleware.Recovery(log), middleware.RequestID),
}
// 6. 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
log.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal("server error", "error", err)
}
}
设计要点:cmd 目录不包含任何业务逻辑判断。所有业务决策都委托给 internal 中的 service 层。如果多个 cmd 子程序需要共享启动逻辑,可以抽取到 internal/pkg/server 中,但不要放在 cmd 目录下。
2.4 internal 目录:领域核心的保护区
internal 目录是 Go 语言提供的编译级隔离机制。任何外部项目都无法导入 internal 包及其子包,这为领域模型提供了天然的封装边界。在 DDD 实践中,internal 通常按业务领域或架构层次组织:
| 组织模式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 按领域组织 | 高内聚,领域边界清晰,便于微服务拆分 | 跨领域调用需定义接口 | DDD 项目、中大型业务系统 |
| 按层次组织 | 结构简单,团队熟悉度高 | 领域逻辑分散,不利于演进 | 小型项目、CRUD 为主的系统 |
推荐做法:对于 DDD 项目,优先采用“按领域组织”模式。每个领域包内部再按层次划分子包(如 entity、service、repository),实现“领域内分层,领域间隔离”。
2.5 pkg 目录:可复用公共库
pkg 目录存放与具体业务无关的通用组件。这些组件可以被当前项目和其他项目直接导入。典型内容包括:
- 日志库:封装统一的日志接口,支持结构化输出
- 验证器:通用的请求参数校验工具
- 错误处理:自定义错误类型与错误码规范
- HTTP 中间件:跨域、限流、鉴权、请求追踪
- 数据库工具:连接池管理、事务辅助函数
// pkg/errors/errors.go
package errors
import "fmt"
type AppError struct {
Code int `json:"code"`
Message string `json:"message"`
Err error `json:"-"`
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("code=%d, message=%s, err=%v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}
func New(code int, message string) *AppError {
return &AppError{Code: code, Message: message}
}
func Wrap(code int, message string, err error) *AppError {
return &AppError{Code: code, Message: message, Err: err}
}
重要约束:pkg 目录中的代码不能依赖 internal 目录中的任何包。如果某个工具函数需要引用领域模型,它应该放在 internal 中,而不是 pkg 中。pkg 是“无业务语义”的公共库。
2.6 模块化设计原则
Go 项目的模块化设计遵循以下核心原则,这些原则与 DDD 的限界上下文思想高度一致:
| 原则 | 说明 | Go 项目落地方式 |
|---|---|---|
| 高内聚 | 一个包内的代码应围绕同一职责变化 | 每个 internal 子包只处理一个领域概念 |
| 低耦合 | 包之间通过接口依赖,而非具体实现 | 仓储接口定义在领域包,实现在 repository 子包 |
| 依赖反转 | 高层模块不依赖低层模块,双方依赖抽象 | service 依赖 repository 接口,而非具体数据库实现 |
| 显式依赖 | 依赖通过构造函数显式注入 | NewUserService(repo Repository, log Logger) |
| 包隔离 | internal 阻止外部意外依赖 | 编译器强制保护领域核心 |
2.7 模块化设计实战:用户注册流程
以下代码展示了模块化原则在用户注册场景中的完整应用:
// internal/user/entity.go — 领域实体
package user
import "your-project/pkg/errors"
type User struct {
ID string
Email string
Password string
Nickname string
}
// internal/user/repository.go — 仓储接口(依赖反转)
type Repository interface {
Save(ctx context.Context, user *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
}
// internal/user/service.go — 应用服务
type Service struct {
repo Repository
log Logger
}
func NewService(repo Repository, log Logger) *Service {
return &Service{repo: repo, log: log}
}
func (s *Service) Register(ctx context.Context, email, password, nickname string) (*User, error) {
// 1. 检查邮箱是否已注册
existing, _ := s.repo.FindByEmail(ctx, email)
if existing != nil {
return nil, errors.New(409, "email already registered")
}
// 2. 创建用户实体
user := &User{
ID: generateID(),
Email: email,
Password: hashPassword(password),
Nickname: nickname,
}
// 3. 持久化
if err := s.repo.Save(ctx, user); err != nil {
s.log.Error("failed to save user", "error", err)
return nil, errors.Wrap(500, "internal server error", err)
}
return user, nil
}
// internal/user/repository/mysql.go — 仓储实现(基础设施层)
package repository
import (
"context"
"database/sql"
"your-project/internal/user"
)
type MySQLUserRepo struct {
db *sql.DB
}
func NewMySQLUserRepo(db *sql.DB) *MySQLUserRepo {
return &MySQLUserRepo{db: db}
}
func (r *MySQLUserRepo) Save(ctx context.Context, u *user.User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, email, password, nickname) VALUES (?, ?, ?, ?)",
u.ID, u.Email, u.Password, u.Nickname)
return err
}
func (r *MySQLUserRepo) FindByEmail(ctx context.Context, email string) (*user.User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, email, password, nickname FROM users WHERE email = ?", email)
u := &user.User{}
err := row.Scan(&u.ID, &u.Email, &u.Password, &u.Nickname)
if err == sql.ErrNoRows {
return nil, nil
}
return u, err
}
模块化收益:当需要将 MySQL 替换为 PostgreSQL 时,只需新增一个 repository/postgres.go 实现 Repository 接口,领域层 service 无需任何修改。这就是依赖反转与接口隔离带来的可测试性和可扩展性。
2.8 本章小结
标准 Go 项目布局通过 cmd/internal/pkg 三层结构,为业务模块提供了清晰的物理边界:
- cmd:薄入口,只做依赖组装与启动
- internal:领域核心保护区,按业务或层次组织
- pkg:可复用公共库,无业务语义
模块化设计原则(高内聚、低耦合、依赖反转、显式依赖)与 DDD 的限界上下文思想一脉相承,是后续章节中领域建模与边界设计的基石。下一章将深入探讨如何利用 Go 的接口机制实现领域层的依赖反转与仓储模式。