第 25 章

项目架构与依赖注入

项目架构与依赖注入

你见过多少个 Go 项目,main 函数里有三百行代码?

也许你见过这样的:

func main() {
    db, _ := sql.Open("mysql", os.Getenv("DSN"))
    redisClient := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    cfg := Config{
        Port:    os.Getenv("PORT"),
        Secret:  os.Getenv("SECRET"),
        Debug:   os.Getenv("DEBUG") == "true",
    }
    userHandler := &UserHandler{db: db, redis: redisClient, cfg: cfg}
    orderHandler := &OrderHandler{db: db, cfg: cfg}
    r := chi.NewRouter()
    r.Get("/users/{id}", userHandler.GetUser)
    r.Post("/orders", orderHandler.CreateOrder)
    // ... 50 更多行路由注册
    http.ListenAndServe(":"+cfg.Port, r)
}

这段代码能跑,但它有一个隐藏的结构性问题:所有东西都耦合在一起

改一个配置项,你需要搜索整个 main 函数找到在哪里用了它。测试某个 handler,你必须真的启动数据库和 Redis。换一个 HTTP 路由库,你需要改遍所有 handler。这种耦合不是故意的坏设计——它是没有刻意思考结构的自然结果。

项目架构不是花架子,它是代码的"力学结构":决定了在哪里施加改动能量,改动会传播到哪里,以及系统能承受多大规模的演化而不坍塌。

这一章,我们从第一性原理出发,讨论 Go 项目的结构、依赖管理、以及如何构建一个能够独立测试、独立演化各个组件的启动链。


Level 1:为什么项目结构重要

软件的生命周期不是"写完即止"

大多数被抛弃的代码库不是因为技术落后,而是因为修改成本变得无法承受

修改成本 = 找到正确位置的时间 + 理解影响范围的时间 + 测试验证的时间 + 修复意外破坏的时间

好的项目结构通过以下机制降低修改成本:

Go 项目布局的争论

Go 社区有一个长期争论:应该遵循哪种项目布局?

方案一:golang-standards/project-layout

myproject/
├── cmd/           # 主程序入口
│   └── server/
│       └── main.go
├── internal/      # 私有包(外部无法导入)
│   ├── handler/
│   ├── service/
│   └── repository/
├── pkg/           # 公共可复用包(可被外部导入)
│   └── utils/
├── configs/       # 配置文件
├── scripts/       # 脚本
└── docs/          # 文档

方案二:扁平布局(Rob Pike 等核心团队倾向于此)

myproject/
├── main.go
├── server.go
├── handler.go
├── config.go
└── db.go

真实答案:没有万能的正确答案。布局应该跟随项目规模演进:

核心原则internal/ 是 Go 1.4 引入的语言特性,internal/ 目录下的包只能被同一模块内的代码导入。用它把"内部实现"和"公共接口"分开,是一个具体的、有编译器保证的边界。

实际项目:yiteai-dev-server 的结构模式

我们的服务器项目采用了 Config → Server → Handler 的初始化链:

main.go
  └── 加载 config(从 YAML + 环境变量)
        └── 初始化 DB 连接池
              └── 初始化 Redis
                    └── 创建各种 Handler(注入 DB/Redis/Config)
                          └── 创建 Router(注册 Handler 路由)
                                └── 创建 HTTP Server
                                      └── 启动监听

这条链的每一步都是显式的,依赖关系从代码上看得清清楚楚。没有全局变量,没有 init() 里的黑魔法,没有"这个依赖是从哪里来的?"的迷惑。


Level 2:分层架构与依赖注入

三层架构:cmd / internal / pkg

Go 项目的标准分层模型:

cmd/              ← 程序入口(main 包),薄薄的一层,只负责组装和启动
  server/
    main.go

internal/         ← 核心业务逻辑(不对外暴露)
  config/         ← 配置加载与验证
  server/         ← HTTP 服务器的生命周期管理
  handler/        ← HTTP 请求处理(路由处理函数)
  service/        ← 业务逻辑层
  repository/     ← 数据访问层(DB 查询)
  model/          ← 数据结构定义

pkg/              ← 可复用工具包(对外暴露,可被其他项目导入)
  errcode/        ← 统一错误码定义
  middleware/     ← HTTP 中间件
  pagination/     ← 分页工具

依赖方向规则(不可违反):

cmd → internal(允许)
internal/handler → internal/service(允许)
internal/service → internal/repository(允许)
internal/repository → internal/model(允许)

internal/repository → internal/handler(禁止!下层不能依赖上层)
internal/service → internal/handler(禁止!)

这个规则叫做**依赖倒置原则(Dependency Inversion Principle)**的具体实践。下层(repository)不知道上层(handler)的存在;上层通过接口依赖下层,而不是直接依赖具体实现。

构造函数注入:不用框架的依赖注入

"依赖注入"听起来很高级,在 Go 里,最常见的形式就是:通过构造函数参数传入依赖,而不是在函数内部创建依赖

// 错误方式:在函数内部创建依赖(隐式依赖)
func NewUserHandler() *UserHandler {
    db, _ := sql.Open("mysql", os.Getenv("DSN"))  // 依赖从天而降
    return &UserHandler{db: db}
}

// 正确方式:通过参数传入依赖(显式依赖)
func NewUserHandler(db *sql.DB, cfg *config.Config) *UserHandler {
    return &UserHandler{db: db, cfg: cfg}
}

为什么这很重要?

  1. 可测试性:测试时可以传入 mock 数据库,不需要真实 DB
  2. 可替换性:换一种数据库驱动,只需改 main.go 里的一行
  3. 显式性:函数签名直接告诉你这个 handler 需要什么
// 使用接口,而不是具体类型——进一步解耦
type UserRepository interface {
    GetByID(ctx context.Context, id int64) (*model.User, error)
    Create(ctx context.Context, user *model.User) error
}

type UserHandler struct {
    repo   UserRepository  // 接口,不是 *sql.DB
    cfg    *config.Config
    logger *slog.Logger
}

func NewUserHandler(repo UserRepository, cfg *config.Config, logger *slog.Logger) *UserHandler {
    return &UserHandler{repo: repo, cfg: cfg, logger: logger}
}

配置加载模式

配置加载是每个项目都要做的事,有几种常见模式:

模式一:纯环境变量(12-Factor App 风格)

type Config struct {
    Port     string `env:"PORT,default=8080"`
    DSN      string `env:"DATABASE_DSN,required"`
    Debug    bool   `env:"DEBUG,default=false"`
}

// 使用 github.com/caarlos0/env 库
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
    log.Fatal(err)
}

模式二:YAML + 环境变量覆盖(更灵活)

// config/config.go
package config

import (
    "fmt"
    "os"
    "github.com/spf13/viper"
)

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Redis    RedisConfig    `mapstructure:"redis"`
}

type ServerConfig struct {
    Port         int    `mapstructure:"port"`
    ReadTimeout  int    `mapstructure:"read_timeout"`
    WriteTimeout int    `mapstructure:"write_timeout"`
    Debug        bool   `mapstructure:"debug"`
}

type DatabaseConfig struct {
    DSN         string `mapstructure:"dsn"`
    MaxOpenConn int    `mapstructure:"max_open_conn"`
    MaxIdleConn int    `mapstructure:"max_idle_conn"`
}

func Load(path string) (*Config, error) {
    v := viper.New()
    v.SetConfigFile(path)
    v.SetConfigType("yaml")

    // 允许环境变量覆盖配置文件值
    v.AutomaticEnv()
    // 环境变量 DATABASE_DSN 覆盖 database.dsn
    v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

    if err := v.ReadInConfig(); err != nil {
        // 配置文件不存在时,只使用环境变量和默认值
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, fmt.Errorf("read config %s: %w", path, err)
        }
    }

    var cfg Config
    if err := v.Unmarshal(&cfg); err != nil {
        return nil, fmt.Errorf("unmarshal config: %w", err)
    }

    return &cfg, nil
}

对应的 YAML 配置文件 config/app.yaml

server:
  port: 8080
  read_timeout: 30
  write_timeout: 30
  debug: false

database:
  dsn: "user:pass@tcp(localhost:3306)/mydb?parseTime=true"
  max_open_conn: 25
  max_idle_conn: 10

redis:
  addr: "localhost:6379"
  password: ""
  db: 0

Level 3:构建启动链

完整的启动链实现

下面是一个完整的、生产可用的启动链实现:

// cmd/server/main.go
package main

import (
    "context"
    "fmt"
    "log/slog"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/myorg/myapp/internal/config"
    "github.com/myorg/myapp/internal/database"
    "github.com/myorg/myapp/internal/handler"
    "github.com/myorg/myapp/internal/repository"
    "github.com/myorg/myapp/internal/server"
    "github.com/myorg/myapp/internal/service"
)

func main() {
    // 初始化结构化日志
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    if err := run(logger); err != nil {
        logger.Error("server exited with error", "err", err)
        os.Exit(1)
    }
}

func run(logger *slog.Logger) error {
    // Step 1: 加载配置
    cfg, err := config.Load("config/app.yaml")
    if err != nil {
        return fmt.Errorf("load config: %w", err)
    }
    logger.Info("config loaded", "port", cfg.Server.Port)

    // Step 2: 初始化数据库
    db, err := database.NewPostgres(cfg.Database)
    if err != nil {
        return fmt.Errorf("connect database: %w", err)
    }
    defer db.Close()
    logger.Info("database connected")

    // Step 3: 初始化 Redis
    redisClient, err := database.NewRedis(cfg.Redis)
    if err != nil {
        return fmt.Errorf("connect redis: %w", err)
    }
    defer redisClient.Close()
    logger.Info("redis connected")

    // Step 4: 初始化 Repository 层
    userRepo := repository.NewUserRepository(db)
    orderRepo := repository.NewOrderRepository(db)

    // Step 5: 初始化 Service 层(业务逻辑)
    userService := service.NewUserService(userRepo, redisClient, logger)
    orderService := service.NewOrderService(orderRepo, userService, logger)

    // Step 6: 初始化 Handler 层
    userHandler := handler.NewUserHandler(userService, cfg, logger)
    orderHandler := handler.NewOrderHandler(orderService, cfg, logger)
    healthHandler := handler.NewHealthHandler(db, redisClient)

    // Step 7: 创建 HTTP Server(包含路由注册)
    srv := server.New(cfg.Server, logger,
        server.WithHandlers(userHandler, orderHandler, healthHandler),
    )

    // Step 8: 优雅关闭
    return runWithGracefulShutdown(srv, logger)
}

优雅关闭

// internal/server/server.go
package server

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

type Server struct {
    httpServer *http.Server
    logger     *slog.Logger
}

func New(cfg config.ServerConfig, logger *slog.Logger, opts ...Option) *Server {
    mux := http.NewServeMux()

    s := &Server{
        httpServer: &http.Server{
            Addr:         fmt.Sprintf(":%d", cfg.Port),
            Handler:      mux,
            ReadTimeout:  time.Duration(cfg.ReadTimeout) * time.Second,
            WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Second,
            IdleTimeout:  120 * time.Second,
        },
        logger: logger,
    }

    for _, opt := range opts {
        opt(s, mux)
    }

    return s
}

func (s *Server) Run() error {
    // 在独立 goroutine 里启动 HTTP 服务
    errCh := make(chan error, 1)
    go func() {
        s.logger.Info("server starting", "addr", s.httpServer.Addr)
        if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            errCh <- fmt.Errorf("http server: %w", err)
        }
        close(errCh)
    }()

    // 等待 OS 信号或服务器错误
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

    select {
    case err := <-errCh:
        return err  // 服务器自己出错了
    case sig := <-quit:
        s.logger.Info("shutdown signal received", "signal", sig.String())
    }

    // 优雅关闭:给现有请求最多 30 秒完成
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := s.httpServer.Shutdown(ctx); err != nil {
        return fmt.Errorf("graceful shutdown: %w", err)
    }

    s.logger.Info("server shutdown complete")
    return nil
}

跨层错误包装

在多层架构中,错误需要在每一层被正确包装,既保留根因,又添加上下文:

// repository 层:原始错误 + 上下文
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*model.User, error) {
    var user model.User
    err := r.db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ?", id,
    ).Scan(&user.ID, &user.Name, &user.Email)

    if errors.Is(err, sql.ErrNoRows) {
        return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
    }
    if err != nil {
        return nil, fmt.Errorf("query user %d: %w", id, err)
    }
    return &user, nil
}

// service 层:添加业务上下文
func (s *UserService) GetUser(ctx context.Context, id int64) (*model.User, error) {
    user, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    // ... 业务逻辑
    return user, nil
}

// handler 层:转换为 HTTP 响应
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)

    user, err := h.service.GetUser(r.Context(), id)
    if err != nil {
        if errors.Is(err, repository.ErrNotFound) {
            http.Error(w, "user not found", http.StatusNotFound)
            return
        }
        h.logger.Error("get user failed", "err", err, "id", id)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(user)
}

fmt.Errorf("...: %w", err)%w 动词将错误包装成一个链,errors.Is() 可以穿透整个链检查根因。这是 Go 1.13 引入的标准错误处理模式。

完整的错误调用链:

handler: "get user: get user: query user 42: sql: no rows in result set"
                                                      ↑
                                           errors.Is(err, repository.ErrNotFound) = true

Level 4:进阶主题

Wire:编译时依赖注入

随着服务规模增大,手动编写依赖注入代码(NewA(NewB(NewC(...))))会变得繁琐且容易出错。Google 开源的 Wire 通过代码生成解决这个问题。

Wire 的核心概念是 Provider(提供者)和 Injector(注入器):

// wire_provider.go
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "github.com/myorg/myapp/internal/config"
    "github.com/myorg/myapp/internal/handler"
    "github.com/myorg/myapp/internal/repository"
    "github.com/myorg/myapp/internal/service"
)

// 声明各组件的构造函数为 Provider
var RepositorySet = wire.NewSet(
    repository.NewUserRepository,
    repository.NewOrderRepository,
)

var ServiceSet = wire.NewSet(
    service.NewUserService,
    service.NewOrderService,
)

var HandlerSet = wire.NewSet(
    handler.NewUserHandler,
    handler.NewOrderHandler,
)

// Injector:Wire 根据依赖关系自动生成实现
func InitializeServer(cfg *config.Config) (*server.Server, error) {
    wire.Build(
        database.NewPostgres,
        database.NewRedis,
        RepositorySet,
        ServiceSet,
        HandlerSet,
        server.New,
    )
    return nil, nil  // Wire 会替换这行
}

运行 wire gen ./... 后,Wire 分析依赖图,生成 wire_gen.go

// wire_gen.go(自动生成,不要手动编辑)
func InitializeServer(cfg *config.Config) (*server.Server, error) {
    db, err := database.NewPostgres(cfg.Database)
    if err != nil {
        return nil, err
    }
    redisClient, err := database.NewRedis(cfg.Redis)
    if err != nil {
        return nil, err
    }
    userRepository := repository.NewUserRepository(db)
    orderRepository := repository.NewOrderRepository(db)
    userService := service.NewUserService(userRepository, redisClient)
    orderService := service.NewOrderService(orderRepository, userService)
    userHandler := handler.NewUserHandler(userService, cfg)
    orderHandler := handler.NewOrderHandler(orderService, cfg)
    srv := server.New(cfg.Server, userHandler, orderHandler)
    return srv, nil
}

Wire 的优势:

Wire 的劣势:

Uber Dig:运行时依赖注入

Uber Dig 是运行时依赖注入框架,基于反射:

import "go.uber.org/dig"

func buildContainer() *dig.Container {
    c := dig.New()

    c.Provide(config.Load)
    c.Provide(database.NewPostgres)
    c.Provide(database.NewRedis)
    c.Provide(repository.NewUserRepository)
    c.Provide(service.NewUserService)
    c.Provide(handler.NewUserHandler)
    c.Provide(server.New)

    return c
}

func main() {
    c := buildContainer()

    // Invoke 触发整个依赖图的解析和初始化
    if err := c.Invoke(func(srv *server.Server) error {
        return srv.Run()
    }); err != nil {
        log.Fatal(err)
    }
}

Dig 的优势:

Dig 的劣势:

选择建议:

接口与插件架构

好的项目结构应该允许"换掉一个实现而不改上层代码"。Go 的接口是实现这一点的工具:

// 定义存储接口——上层依赖这个,不依赖具体实现
type UserStore interface {
    GetByID(ctx context.Context, id int64) (*model.User, error)
    Save(ctx context.Context, user *model.User) error
    Delete(ctx context.Context, id int64) error
}

// MySQL 实现
type mysqlUserStore struct {
    db *sql.DB
}

func NewMySQLUserStore(db *sql.DB) UserStore {
    return &mysqlUserStore{db: db}
}

// PostgreSQL 实现(切换时只改 main.go 的一行)
type postgresUserStore struct {
    db *sql.DB
}

func NewPostgresUserStore(db *sql.DB) UserStore {
    return &postgresUserStore{db: db}
}

// 内存实现(用于测试)
type inMemoryUserStore struct {
    mu    sync.RWMutex
    users map[int64]*model.User
    seq   int64
}

func NewInMemoryUserStore() UserStore {
    return &inMemoryUserStore{users: make(map[int64]*model.User)}
}

注意:接口应该定义在使用方(消费者)这一侧,而不是实现方。这是 Go 的接口设计惯例:

// 正确:接口定义在 service 包(使用方)
package service

type UserStore interface {
    GetByID(ctx context.Context, id int64) (*model.User, error)
    Save(ctx context.Context, user *model.User) error
}

// repository 包不需要显式"实现"这个接口
// 只要 repository.UserRepository 的方法集满足,Go 编译器自动认可

这与 Java/C# 的"显式声明 implements"形成鲜明对比,让接口更加灵活——即使在事后,也可以给已有类型"赋予"新的接口,而不需要修改它。

用假依赖测试

有了构造函数注入 + 接口,测试变得极为简单:

// user_service_test.go
package service_test

import (
    "context"
    "testing"

    "github.com/myorg/myapp/internal/model"
    "github.com/myorg/myapp/internal/service"
)

// 假的 UserStore 实现
type fakeUserStore struct {
    users map[int64]*model.User
    err   error  // 注入错误,测试错误路径
}

func (f *fakeUserStore) GetByID(_ context.Context, id int64) (*model.User, error) {
    if f.err != nil {
        return nil, f.err
    }
    u, ok := f.users[id]
    if !ok {
        return nil, repository.ErrNotFound
    }
    return u, nil
}

func (f *fakeUserStore) Save(_ context.Context, user *model.User) error {
    if f.err != nil {
        return f.err
    }
    f.users[user.ID] = user
    return nil
}

func TestUserService_GetUser(t *testing.T) {
    t.Run("user exists", func(t *testing.T) {
        store := &fakeUserStore{
            users: map[int64]*model.User{
                1: {ID: 1, Name: "Alice", Email: "[email protected]"},
            },
        }
        svc := service.NewUserService(store, nil, slog.Default())

        user, err := svc.GetUser(context.Background(), 1)
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("want Alice, got %s", user.Name)
        }
    })

    t.Run("user not found", func(t *testing.T) {
        store := &fakeUserStore{users: map[int64]*model.User{}}
        svc := service.NewUserService(store, nil, slog.Default())

        _, err := svc.GetUser(context.Background(), 999)
        if !errors.Is(err, repository.ErrNotFound) {
            t.Errorf("want ErrNotFound, got %v", err)
        }
    })

    t.Run("store error", func(t *testing.T) {
        store := &fakeUserStore{err: errors.New("connection reset")}
        svc := service.NewUserService(store, nil, slog.Default())

        _, err := svc.GetUser(context.Background(), 1)
        if err == nil {
            t.Fatal("expected error, got nil")
        }
    })
}

这个测试:

Monorepo 布局

如果你的组织有多个 Go 服务,可以考虑 monorepo 布局:

company/
├── go.work              ← Go workspace 文件
├── services/
│   ├── user-service/
│   │   ├── go.mod
│   │   ├── cmd/server/main.go
│   │   └── internal/
│   ├── order-service/
│   │   ├── go.mod
│   │   └── ...
│   └── notification-service/
│       ├── go.mod
│       └── ...
└── shared/              ← 各服务共用的包
    ├── go.mod
    ├── errcode/
    ├── middleware/
    └── pagination/

go.work 文件(Go 1.18+ workspace 特性):

go 1.21

use (
    ./services/user-service
    ./services/order-service
    ./services/notification-service
    ./shared
)

有了 workspace,各服务可以引用 shared 包的本地版本而不是发布的版本,开发时改动 shared 立即在所有服务中生效。


小结

项目架构的本质不是选择一种目录结构,而是回答三个问题:

  1. 在哪里改?(可定位性:每类改动都有明确的归属位置)
  2. 改了会影响什么?(可预测性:依赖方向清晰,改动影响范围可见)
  3. 怎么验证改对了?(可测试性:依赖可注入,可以用假实现替换)

Go 的工具为回答这三个问题提供了具体的机制:

把这些机制组合起来,就是一个能承受时间考验的 Go 项目。不需要框架,不需要魔法——只需要刻意的设计和对这些原则的坚持。

本章评分
4.7  / 5  (6 评分)

💬 留言讨论