项目架构与依赖注入
项目架构与依赖注入
你见过多少个 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:为什么项目结构重要
软件的生命周期不是"写完即止"
大多数被抛弃的代码库不是因为技术落后,而是因为修改成本变得无法承受。
修改成本 = 找到正确位置的时间 + 理解影响范围的时间 + 测试验证的时间 + 修复意外破坏的时间
好的项目结构通过以下机制降低修改成本:
- 局部性(Locality):相关的东西放在一起,不相关的东西分开放
- 单一职责(Single Responsibility):每个文件/包只做一件事,改动不会扩散
- 显式依赖(Explicit Dependencies):依赖关系从代码结构上可见,而不是隐藏在全局变量里
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/handler,internal/service) - 大型:多个独立服务,monorepo 或各自独立仓库
核心原则: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}
}
为什么这很重要?
- 可测试性:测试时可以传入 mock 数据库,不需要真实 DB
- 可替换性:换一种数据库驱动,只需改
main.go里的一行 - 显式性:函数签名直接告诉你这个 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 的优势:
- 编译时验证:依赖缺失在编译阶段就会报错,不是运行时 panic
- 无反射:生成的代码是普通 Go 代码,没有运行时开销
- 可读的生成代码:
wire_gen.go清晰地展示了整个初始化顺序
Wire 的劣势:
- 需要额外的构建步骤(
wire gen) - 学习曲线较陡(Provider、Set、Injector 概念需要适应)
- 循环依赖在编译时才发现(但至少发现了)
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.In+optional:"true"tag) - 与 Uber FX(应用框架)无缝集成
Dig 的劣势:
- 运行时反射,有少量性能开销(通常可以忽略,只在启动时发生)
- 错误信息在复杂依赖图中可能难以理解
- 依赖缺失是运行时错误,不是编译错误
选择建议:
- 小型项目:手动构造函数注入(不需要任何框架)
- 中型项目,团队熟悉 Go 工具链:Wire(编译时安全)
- 大型项目,需要动态扩展:Dig + FX
接口与插件架构
好的项目结构应该允许"换掉一个实现而不改上层代码"。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")
}
})
}
这个测试:
- 不需要数据库:
fakeUserStore在内存里完成所有操作 - 不需要网络:纯函数,不发任何请求
- 速度极快:毫秒级,可以在 CI 每次提交时运行
- 覆盖边界情况:正常路径、未找到、存储错误——三种情况各一个测试
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 立即在所有服务中生效。
小结
项目架构的本质不是选择一种目录结构,而是回答三个问题:
- 在哪里改?(可定位性:每类改动都有明确的归属位置)
- 改了会影响什么?(可预测性:依赖方向清晰,改动影响范围可见)
- 怎么验证改对了?(可测试性:依赖可注入,可以用假实现替换)
Go 的工具为回答这三个问题提供了具体的机制:
internal/包 → 强制边界,防止包滥用- 构造函数注入 → 依赖关系在代码上可见
- 接口 → 实现可替换,测试可隔离
context.Context→ 跨层的取消和超时控制fmt.Errorf("%w")→ 跨层的错误链传递
把这些机制组合起来,就是一个能承受时间考验的 Go 项目。不需要框架,不需要魔法——只需要刻意的设计和对这些原则的坚持。