vault backup: 2026-04-29 09:45:07
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
---
|
||||
tags: [go, dependency-injection, di, architecture, testing, refactoring]
|
||||
create time: 2026-04-29 15:30
|
||||
---
|
||||
|
||||
# Go 依赖注入(Dependency Injection)
|
||||
|
||||
## 概述
|
||||
|
||||
系统梳理 Go 语言中依赖注入的实践模式、选型决策和常见陷阱。Go 的 DI 哲学与 Java Spring 等框架截然不同——它推崇显式构造器注入,不依赖反射、IoC 容器或运行时魔法。
|
||||
|
||||
## 一、为什么 Go 不需要 IoC 容器?
|
||||
|
||||
> [!question] 思考:Spring 的 BeanFactory 在 Go 里是什么?
|
||||
>
|
||||
> 在 Go 中,**构造函数就是 IoC 容器**。调用者一眼就能看到类型需要哪些依赖,不需要查看注册表、配置文件或注解。这就是 Go **"显式优于隐式"** 的设计哲学。
|
||||
|
||||
Java/Spring 的典型风格 vs Go 风格对比:
|
||||
|
||||
```java
|
||||
// ❌ Java Spring —— 隐式绑定,运行时查找
|
||||
@Service
|
||||
public class OrderService {
|
||||
@Autowired
|
||||
private OrderRepository repository; // 谁也不知道从哪里注入来的
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// ✅ Go —— 显式构造,编译期检查
|
||||
type OrderService struct {
|
||||
repo OrderRepository // 结构体字段只是声明
|
||||
}
|
||||
|
||||
func NewOrderService(repo OrderRepository) *OrderService {
|
||||
return &OrderService{repo: repo} // 依赖关系写得一清二楚
|
||||
}
|
||||
```
|
||||
|
||||
| 维度 | Spring IoC 容器 | Go 手动注入 |
|
||||
|------|----------------|------------|
|
||||
| **依赖可见性** | 通过注解 + 容器配置 | 构造函数签名直接暴露 |
|
||||
| **编译时检查** | 部分(缺 Bean 可能运行时才报错) | 全部(少传参数直接编译失败) |
|
||||
| **调试难度** | 启动失败时 stack trace 深不见底 | 普通函数调用,panic 信息直观 |
|
||||
| **性能开销** | 启动时大量反射初始化 | 零额外开销 |
|
||||
| **测试友好度** | 需 MockBean/@SpringBootTest 配合 | 直接传 mock 构造即可 |
|
||||
|
||||
## 二、构造器注入:DI 的最基本形式
|
||||
|
||||
核心原则:**结构体不自己创建依赖,而是通过构造函数接收**。
|
||||
|
||||
### 2.1 反模式 vs 推荐模式
|
||||
|
||||
```go
|
||||
// ❌ 反模式:服务内部自行创建依赖(紧耦合,不可替换)
|
||||
type OrderService struct {
|
||||
db *sql.DB // 硬编码的具体类型
|
||||
}
|
||||
|
||||
func NewOrderService() *OrderService {
|
||||
conn, _ := sql.Open("postgres", dsn) // 隐藏了副作用和错误处理
|
||||
return &OrderService{db: conn}
|
||||
}
|
||||
```
|
||||
|
||||
问题:
|
||||
1. `dsn` 从哪来?对调用方来说是隐形依赖。
|
||||
2. 单元测试必须启动真实数据库,无法 mock。
|
||||
3. 想换成 Redis 做缓存?需要改源码。
|
||||
|
||||
```go
|
||||
// ✅ 推荐:外部注入依赖,依赖抽象化
|
||||
type OrderService struct {
|
||||
repo OrderRepository
|
||||
logger *slog.Logger
|
||||
cache Cache
|
||||
}
|
||||
|
||||
func NewOrderService(
|
||||
repo OrderRepository,
|
||||
logger *slog.Logger,
|
||||
cache Cache,
|
||||
) *OrderService {
|
||||
return &OrderService{repo: repo, logger: logger, cache: cache}
|
||||
}
|
||||
```
|
||||
|
||||
三个好处:
|
||||
- **可测试**:传入 mock Repo,无需启动 DB。
|
||||
- **可见**:构造函数签名 = 完整的依赖清单,IDE 自动补全提示。
|
||||
- **灵活**:同一 service 可按场景注入不同组合。
|
||||
|
||||
### 2.2 为什么用接口而非具体类型?
|
||||
|
||||
```go
|
||||
// Service 依赖的是接口,不是具体实现
|
||||
type OrderRepository interface {
|
||||
ListByUserID(ctx context.Context, userID int64) ([]*Order, error)
|
||||
Create(ctx context.Context, o *Order) error
|
||||
}
|
||||
```
|
||||
|
||||
这实现了 **[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)**:
|
||||
- 高层模块(service)不依赖低层模块(repository 的具体实现)。
|
||||
- 两者都依赖于**抽象**(interface)。
|
||||
- 切换实现只需改 Composition Root 一行代码,业务逻辑零改动。
|
||||
|
||||
> [!tip] YAGNI 接口原则
|
||||
>
|
||||
> 不要一开始就给所有 struct 加接口。**只在必要时定义接口**:
|
||||
>
|
||||
> | 情况 | 是否定义接口 |
|
||||
> |------|-------------|
|
||||
> | 需要跨包引用 | ✅ 必须 |
|
||||
> | 需要用 mock 做单元测试 | ✅ 必须 |
|
||||
> | 只在一个包内使用,无独立测试 | ❌ 不需要 |
|
||||
|
||||
## 三、可选依赖:Options 模式
|
||||
|
||||
当依赖增多到 4~5 个以上时,构造函数参数变得臃肿。Go 社区的标准解法是**函数选项模式**:
|
||||
|
||||
```go
|
||||
type UserService struct {
|
||||
repo UserRepository
|
||||
logger *slog.Logger
|
||||
cache Cache
|
||||
enableLog bool
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
// Option 是修改 UserService 字段的函数
|
||||
type Option func(*UserService)
|
||||
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(s *UserService) { s.logger = logger }
|
||||
}
|
||||
|
||||
func WithCache(cache Cache) Option {
|
||||
return func(s *UserService) { s.cache = cache }
|
||||
}
|
||||
|
||||
func WithMaxRetries(n int) Option {
|
||||
return func(s *UserService) { s.maxRetries = n }
|
||||
}
|
||||
|
||||
// 必选 + 可选分离
|
||||
func NewUserService(repo UserRepository, opts ...Option) *UserService {
|
||||
s := &UserService{
|
||||
repo: repo,
|
||||
enableLog: true, // 合理默认值
|
||||
maxRetries: 3,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 调用点清晰可读
|
||||
svc := NewUserService(repo,
|
||||
WithLogger(logger),
|
||||
WithCache(newRedisCache()),
|
||||
)
|
||||
```
|
||||
|
||||
> [!info] Options 模式的权衡
|
||||
>
|
||||
> - **优点**:向后兼容(新增选项不影响旧调用方),调用点像声明式 DSL。
|
||||
> - **缺点**:每个选项多写一个函数;超过 6~8 个选项时需考虑拆分 service。
|
||||
|
||||
## 四、Composition Root:在哪里组装依赖?
|
||||
|
||||
**Composition Root**(组合根目录)是 DI 的核心概念——在整个应用入口处将依赖组装在一起。Go 项目中它就是 `cmd/main.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
// ── 基础设施层 ──
|
||||
db := initPostgres(cfg.DSN)
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
redisClient := initRedis(cfg.RedisURL)
|
||||
|
||||
// ── 数据访问层(接口 ←→ 实现对接)──
|
||||
userRepo := repository.NewPostgresUserRepo(db)
|
||||
orderRepo := repository.NewPostgresOrderRepo(db)
|
||||
|
||||
// ── 业务逻辑层 ──
|
||||
userSvc := service.NewUserService(userRepo,
|
||||
service.WithLogger(logger),
|
||||
)
|
||||
orderSvc := service.NewOrderService(orderRepo, userRepo,
|
||||
service.WithLogger(logger),
|
||||
service.WithCache(redisClient),
|
||||
)
|
||||
|
||||
// ── 表现层 ──
|
||||
router := handler.NewRouter(userSvc, orderSvc)
|
||||
|
||||
log.Printf("server starting on :%s", cfg.Port)
|
||||
http.ListenAndServe(":"+cfg.Port, router)
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 常见陷阱
|
||||
>
|
||||
> - **不要在 service 内部 new 依赖**:一旦某个地方绕过了构造器,整个 DI 链条就断了。
|
||||
> - **不要全局变量存依赖**:`var db *sql.DB` 会导致包级状态污染,并发问题和测试隔离问题接踵而来。
|
||||
> - **不要在 service 之间循环调用**:orderSvc 注入了 userRepo,那 userSvc 就不应该再注入 orderSvc。
|
||||
> - **不要为了 DI 而 DI**:工具函数包不需要接口+注入——过度设计也是反模式。
|
||||
|
||||
## 五、Wire:手动 DI vs 代码生成
|
||||
|
||||
Go 生态中有 fx(Uber)、wire 等 DI 工具,但**社区普遍推荐小中型项目手动组装**。
|
||||
|
||||
| 维度 | 手动组装 | wire(编译时代码生成) | fx(运行时间单解析) |
|
||||
|------|---------|----------------------|-------------------|
|
||||
| 学习成本 | 零 | 中等(理解 build tags + 注释标注) | 高(App 生命周期 + 回调规则) |
|
||||
| 调试难度 | 低 | 低(生成的代码即普通 Go 代码) | 中高(运行时 panic,堆栈深) |
|
||||
| 启动速度 | 无开销 | 无开销 | 有反射开销 |
|
||||
| 适用规模 | < 50 个组件 | 50+ 组件,频繁变更依赖图 | 较少推荐,社区认可度低于 wire |
|
||||
|
||||
### 5.1 Wire 的工作原理
|
||||
|
||||
Wire 不是运行时框架,它在**编译期生成初始化代码**,生成的文件和手写的没有任何区别:
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
go install github.com/google/wire/cmd/wire@latest
|
||||
```
|
||||
|
||||
编写 injector(注意 `//go:build wireinject` build tag):
|
||||
|
||||
```go
|
||||
// go:build wireinject
|
||||
package main
|
||||
|
||||
// InitializeApp 组装完整的 App 依赖图
|
||||
func InitializeApp(cfg Config) (*App, error) {
|
||||
wire.Build(
|
||||
initPostgres,
|
||||
initRedis,
|
||||
slog.New,
|
||||
|
||||
repository.NewPostgresUserRepo,
|
||||
repository.NewPostgresOrderRepo,
|
||||
|
||||
service.NewUserService,
|
||||
service.NewOrderService,
|
||||
|
||||
handler.NewRouter,
|
||||
|
||||
App.New,
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
```
|
||||
|
||||
运行 wire:
|
||||
|
||||
```bash
|
||||
$ wire ./cmd/app
|
||||
# → 生成 cmd/app/inject.wire_gen.go
|
||||
```
|
||||
|
||||
生成的文件大致是:
|
||||
|
||||
```go
|
||||
// Code generated by wire; DO NOT EDIT.
|
||||
package main
|
||||
|
||||
func InitializeApp(cfg Config) (*App, error) {
|
||||
db := initPostgres(cfg.DSN)
|
||||
redisClient := initRedis(cfg.RedisURL)
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
userRepo := repository.NewPostgresUserRepo(db)
|
||||
orderRepo := repository.NewPostgresOrderRepo(db)
|
||||
userSvc := service.NewUserService(userRepo, nil /* no logger option */)
|
||||
orderSvc := service.NewOrderService(orderRepo, userRepo, nil, nil)
|
||||
router := handler.NewRouter(userSvc, orderSvc)
|
||||
app := NewApp(router)
|
||||
return app, nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!summary] Wire 的使用时机
|
||||
>
|
||||
> - 依赖图 **< 20 个组件** → 手写,不需要 wire。
|
||||
> - 依赖图 **20 ~ 50 个组件** → 手写 + options 模式完全够用。
|
||||
> - 依赖图 **> 50 个组件** 且团队多人协作 → 考虑 wire 自动生成。
|
||||
> - 不想引入任何额外工具链 → 手写,永远是最安全的选择。
|
||||
|
||||
## 六、DI 在测试中的价值
|
||||
|
||||
DI 的最大受益者是**单元测试**——mock 替代真实依赖,测试变成快速、确定性的纯内存操作:
|
||||
|
||||
```go
|
||||
// ── Mock 实现 ──
|
||||
type mockUserRepo struct {
|
||||
mu sync.Mutex
|
||||
users map[int64]*User
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) ByID(ctx context.Context, id int64) (*User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
// 返回副本,防止测试间相互篡改
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Save(ctx context.Context, u *User) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.users[u.ID] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── 单元测试:毫秒级执行,不依赖外部环境 ──
|
||||
func TestCreateOrder(t *testing.T) {
|
||||
repo := &mockUserRepo{
|
||||
users: map[int64]*User{1: {ID: 1, Name: "alice"}},
|
||||
}
|
||||
svc := NewOrderService(repo)
|
||||
|
||||
order, err := svc.CreateOrder(context.Background(), 1, "widget", 2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "alice", order.OwnerName)
|
||||
assert.Equal(t, 2, order.Quantity)
|
||||
}
|
||||
```
|
||||
|
||||
> [!success] 最佳实践
|
||||
>
|
||||
> - **单元测试**:全部用 mock,确保快速(ms 级)、确定性、CI 稳定。
|
||||
> - **集成测试**:用真实 DB + [`testcontainers-go`](https://github.com/testcontainers/testcontainers-go),覆盖端到端链路。
|
||||
> - **不要混用**:同一个包的测试要么全 mock,要么全真实——混合会带来难以复现的 flaky test。
|
||||
|
||||
## 七、DI 在避免循环依赖中的作用
|
||||
|
||||
依赖注入不仅是装配技巧,还是解决**循环依赖**的核心手段(详见 [[Go 工程模块化]] 第四节):
|
||||
|
||||
```
|
||||
handler → service → repository → model → handler ❌
|
||||
```
|
||||
|
||||
传统写法中,`model` 被多层共用导致循环 import。通过 DI 可以打破:
|
||||
|
||||
```go
|
||||
// 1. domain 层只定义接口
|
||||
type UserRepository interface {
|
||||
Save(ctx context.Context, u *User) error
|
||||
}
|
||||
|
||||
// 2. service 依赖接口而非具体 repo
|
||||
type UserService struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
// 3. infrastructure 层实现接口
|
||||
type PostgresUserRepo struct { db *sqlx.DB }
|
||||
|
||||
// 4. main.go 中桥接接口与实现
|
||||
// (没有任何一层产生循环 import)
|
||||
userRepo := &PostgresUserRepo{db: db}
|
||||
userSvc := NewUserService(userRepo)
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[Go 工程模块化]]
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
tags: [go, testing, integration-test, project-structure, engineering]
|
||||
create time: 2026-04-29 15:45
|
||||
---
|
||||
|
||||
# 集成测试目录结构
|
||||
|
||||
## 概述
|
||||
|
||||
梳理 Go 项目中测试目录的组织方式,聚焦集成测试的定位、目录划分和最佳实践。明确单测与集成测试的边界,帮助团队在工程规模增长时保持测试可维护性。
|
||||
|
||||
## 为什么需要独立测试目录?
|
||||
|
||||
> [!question] 思考:单测不够吗?为什么还要单独建 tests/?
|
||||
|
||||
Go 的社区惯例是**单测和源码放一起**(`xxx_test.go`),这已经覆盖了绝大多数场景。但当测试需要真实基础设施(数据库、Redis、HTTP Server)时,就需要引入独立的集成测试目录。
|
||||
|
||||
| 维度 | 包内单测 | `tests/` 集成测试 |
|
||||
|------|---------|-------------------|
|
||||
| **视角** | 白盒——能看到 unexported | 黑盒——只调公开 API |
|
||||
| **速度** | 快(纯内存操作) | 慢(需启动依赖) |
|
||||
| **职责** | 验证函数行为 | 验证组件协作 |
|
||||
| **CI 触发** | 每次 commit 必跑 | 可单独控制,按需跑 |
|
||||
|
||||
## tests/ 的典型结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── integration/ # 集成测试:组件之间正确协作
|
||||
│ ├── user_test.go # 用户注册全流程(handler → service → repo → DB)
|
||||
│ └── order_test.go # 下单流程(含事务、并发安全校验)
|
||||
├── e2e/ # 端到端测试:整个服务对外可见的行为
|
||||
│ └── api_smoke_test.go # 启动 server,发 HTTP 请求做冒烟测试
|
||||
├── fixtures/ # 测试数据fixtures
|
||||
│ ├── users.json # 预置数据
|
||||
│ └── orders.json
|
||||
└── testutil/ # 集成测试专用辅助包
|
||||
└── db.go # TestDB() 封装:创建测试库 + 自动清理
|
||||
```
|
||||
|
||||
### 各子目录的职责
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["tests/"] --> B["integration/ — 集成测试<br/>组件协作 + 真实依赖"]
|
||||
A --> C["e2e/ — 端到端测试<br/>完整服务 + HTTP 调用"]
|
||||
A --> D["fixtures/ — 测试数据"]
|
||||
A --> E["testutil/ — 基础设施辅助"]
|
||||
|
||||
style B fill:#bfb,stroke:#333,stroke-width:3px
|
||||
style C fill:#fbf,stroke:#333
|
||||
style D fill:#fff,stroke:#333
|
||||
style E fill:#fff,stroke:#333
|
||||
```
|
||||
|
||||
> [!tip] 何时需要哪个?
|
||||
>
|
||||
> - **只有 unit test**:项目初期、CRUD 为主 → 不需要 `tests/`,全放包内
|
||||
> - **加了外部依赖后**:有数据库/缓存/消息队列 → 引入 `tests/integration/`
|
||||
> - **上线前质量保障**:微服务、对外 API → 增加 `tests/e2e/`
|
||||
|
||||
## 集成测试的最佳实践
|
||||
|
||||
### 1. 测试数据隔离
|
||||
|
||||
每个测试用例应使用独立的数据命名空间,避免相互污染:
|
||||
|
||||
```go
|
||||
func TestCreateOrder(t *testing.T) {
|
||||
// 为当前测试准备干净的数据库状态
|
||||
db := testutil.NewTestDB(t)
|
||||
defer db.Cleanup() // 事务回滚或 truncate
|
||||
|
||||
svc := service.NewOrderService(db.Conn())
|
||||
res, err := svc.Create(ctx, payload)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 断言基于干净状态的预期结果
|
||||
}
|
||||
```
|
||||
|
||||
> [!info] 两种隔离策略
|
||||
|
||||
| 策略 | 做法 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| **事务回滚** | 每件事务包裹在 BEGIN/ROLLBACK 中 | PostgreSQL/MySQL,速度快 |
|
||||
| **临时 Schema** | 每个 test 创建独立 schema | 需要测试迁移、DDL 等场景 |
|
||||
|
||||
### 2. 避免硬编码端口
|
||||
|
||||
集成测试需要启动服务,端口冲突是 CI 最常见的失败原因:
|
||||
|
||||
```go
|
||||
func TestAPIIntegration(t *testing.T) {
|
||||
// ✅ 动态分配可用端口
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := l.Addr().String()
|
||||
l.Close()
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: router}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
> [!warning] 常见坑
|
||||
>
|
||||
> - 不要写死 `8080`——本地开发可能已经在跑别的服务
|
||||
> - `net.Listener` 用 `"127.0.0.1:0"` 让内核分配空闲端口是最稳妥的方式
|
||||
|
||||
### 3. CI 中的分级执行
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml 示例思路
|
||||
- run: go test ./... # 所有单测(每次 commit)
|
||||
- run: go test ./tests/integration/... # 集成测试(push to main 或手动触发)
|
||||
- run: go test ./tests/e2e/... # E2E(release 前)
|
||||
```
|
||||
|
||||
> [!summary] 分级执行的理由
|
||||
>
|
||||
> 集成测试和 E2E 跑起来可能几分钟甚至更久,混在常规 CI 里会拖慢开发节奏。按阶段分级,既能保证质量又不牺牲效率。
|
||||
|
||||
## 与内部包的配合
|
||||
|
||||
集成测试虽然放在项目根级的 `tests/` 下,但它访问的是你 `internal/` 里的代码。这里有个重要细节:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── internal/user/service/
|
||||
│ └── service.go # package service — 对外不可见
|
||||
├── tests/integration/
|
||||
│ └── user_test.go # import "my-project/internal/user/service" — 合法!
|
||||
```
|
||||
|
||||
> [!tip] 关键点
|
||||
>
|
||||
> - `internal/` 的限制是**对其它 module 而言**的,同一 module 内的任何位置都可以 import
|
||||
> - `tests/` 和 `internal/` 属于同一个 module,所以可以毫无阻碍地访问私有包
|
||||
> - 这意味着集成测试其实是**半白盒**的——你能看到 internal 但看不到第三方 module 的代码
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[Go 工程模块化]]
|
||||
Reference in New Issue
Block a user