15.2 依赖注入与项目分层
上一节结束时,Config 已经能正确加载。但 Repo 在哪创建、Service 该拿 *sql.DB 还是拿 Repo、HTTP handler 怎么拿到 Service——这些问题还没有答案。如果任由每个组件自己 new 它需要的东西,依赖关系会很快变成一团乱麻。本节解决「谁创建谁、谁依赖谁」。
本节把 TaskAPI 推进到:划分 handler / service / store 三层,用接口把它们的依赖倒置,并在
main里手工完成全部装配。这套装配方式不需要任何框架,却足以支撑一个中等规模的服务。
15.2.1 分层的动机
先看一个「不分层」的写法:HTTP handler 里直接写 SQL。
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
var t Task
err := db.QueryRowContext(r.Context(),
"SELECT id, title, done FROM tasks WHERE id = ?", id).
Scan(&t.ID, &t.Title, &t.Done)
// ...
}
它有三个问题。第一,业务规则无处安放——「标题不能为空」这种校验写在 handler 里,别的入口(比如后台 worker、CLI 命令)就用不上了。第二,不可测试——测这个 handler 必须连数据库。第三,职责混乱——一个函数同时管 HTTP 协议、业务规则、SQL 三件事。
分层就是把这三件事拆开,每层只做一件事。
15.2.2 三层的职责
TaskAPI 分成三层,各自边界清晰:
| 层 | 职责 | 依赖 | 不做什么 |
|---|---|---|---|
| handler(HTTP) | 解析请求、写响应、状态码 | service 接口 | 不写业务规则、不碰 SQL |
| service(业务) | 校验、编排、事务边界 | store 接口 | 不知道 HTTP、不知道 SQL |
| store(持久化) | CRUD、SQL | 数据库 | 不做业务判断 |
关键约束是依赖方向单一:handler 依赖 service,service 依赖 store,绝不反向。store 不知道 service 存在,service 不知道 handler 存在。这样改上层不会牵动下层,测试下层也不用拉起上层。
15.2.3 用接口定义「端口」
service 需要「存取任务」的能力,但它不该知道这能力是 SQL 还是内存实现的。于是定义一个接口:
type TaskStore interface {
Create(ctx context.Context, title string) (Task, error)
Get(ctx context.Context, id int64) (Task, error)
List(ctx context.Context) ([]Task, error)
}
这个接口由使用者(service)定义,而不是由实现者(store)定义——这是 Go 接口哲学的核心:接口描述「我需要什么」,而不是「我提供什么」。第 5 章讲「接口由使用方定义、隐式实现」,这里就是它的实战。
任何实现了这三个方法的类型,都能被注入 service。第 14 章的 Repo 可以,内存版 MemStore 也可以,测试用的 fake 更可以。
15.2.4 service 层:业务规则的唯一住所
service 持有 TaskStore 接口,专注业务:
type Service struct {
store TaskStore
}
func NewService(s TaskStore) *Service { return &Service{store: s} }
func (s *Service) CreateTask(ctx context.Context, title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, fmt.Errorf("title: %w", ErrEmptyTitle)
}
return s.store.Create(ctx, title)
}
func (s *Service) GetTask(ctx context.Context, id int64) (Task, error) {
return s.store.Get(ctx, id)
}
注意 CreateTask 里的「去空白 + 非空校验」是业务规则,它放在 service 而不是 handler,所以无论请求来自 HTTP、CLI 还是后台任务,规则都一致地生效。ErrEmptyTitle 是领域哨兵错误(第 6 章),会被第 15.3 节的错误映射翻译成 422。
service 也不关心 store.Create 背后是 SQL 还是 map。它只依赖接口,这就是「依赖倒置」——高层模块(service)不依赖低层实现,两者都依赖抽象。
15.2.5 手工依赖注入:composition root
「依赖注入」这个词听起来很重,在 Go 里其实就是一个动作:在程序入口处,把所有对象 new 出来,再层层传下去。这个唯一的装配点叫 composition root:
type App struct {
Cfg Config
Service *Service
Store TaskStore
}
func NewApp(cfg Config) *App {
store := NewMemStore() // 1. 建最底层:适配器
svc := NewService(store) // 2. 注入接口
return &App{Cfg: cfg, Service: svc, Store: store}
}
顺序是「从下往上」:先建 store,再把 store 注入 service,最后 service 交给 handler。每个组件的依赖都由外部提供,组件自己从不 new 它的依赖:
type Service struct {
store TaskStore // 由构造函数注入,不是自己 new
}
func NewService(s TaskStore) *Service { return &Service{store: s} }
对比「自己 new 依赖」的反面教材:
// 反例:service 自己 new store,依赖被焊死
func NewService() *Service {
return &Service{store: NewSQLStore()} // 想换成内存版或 fake?改这里,还牵动编译
}
自己 new 的版本让 service 强绑定具体实现,测试时无法替换,也无法在不改 service 代码的情况下切换存储。注入版本把选择权交给了 composition root。
15.2.6 为什么不用 DI 框架
Java 生态里,依赖注入往往意味着 Spring 那样的容器 + 注解 + 反射。Go 社区的主流是手工装配,原因有三:
| 维度 | 手工装配 | DI 框架 |
|---|---|---|
| 可读性 | main 里一眼看全依赖关系 | 依赖靠注解/配置,跳来跳去 |
| 编译期检查 | 类型不匹配直接编译失败 | 常到运行时才发现 |
| 依赖 | 零 | 引入第三方库与学习成本 |
一个中等规模的服务,依赖对象通常也就几十个,main 里写几十行 New... 完全可接受,而且最直白。Go 的显式哲学(第 1 章讲的「显式优于隐式」)在这里体现得淋漓尽致:依赖关系不藏在框架里,就摊在 composition root 里。只有当你真的有上百个组件、装配逻辑极其复杂时,才值得考虑代码生成类的工具。
15.2.7 完整装配与实测
把内存 store、service、App 串起来,实测整个链路:
func main() {
app := NewApp(Config{Addr: ":8080"})
ctx := context.Background()
t, err := app.Service.CreateTask(ctx, "写 Go 书")
fmt.Printf("create: %+v err=%v\n", t, err)
_, err = app.Service.CreateTask(ctx, "写 Go 书")
fmt.Println("dup is ErrConflict:", errors.Is(err, ErrConflict))
_, err = app.Service.CreateTask(ctx, " ")
fmt.Println("empty is ErrEmptyTitle:", errors.Is(err, ErrEmptyTitle))
got, err := app.Service.GetTask(ctx, t.ID)
fmt.Printf("get: %+v err=%v\n", got, err)
_, err = app.Service.GetTask(ctx, 999)
fmt.Println("missing is ErrNotFound:", errors.Is(err, ErrNotFound))
list, _ := app.Service.ListTasks(ctx)
fmt.Println("list:", len(list))
}
实测输出:
create: {ID:1 Title:写 Go 书 Done:false} err=<nil>
dup is ErrConflict: true
empty is ErrEmptyTitle: true
get: {ID:1 Title:写 Go 书 Done:false} err=<nil>
missing is ErrNotFound: true
list: 1
六条断言全部符合预期:创建成功、重复标题回 ErrConflict、空标题回 ErrEmptyTitle、按 ID 查到、不存在回 ErrNotFound、列表长度为 1。注意这些错误全部是领域错误,service 层完全没有 import database/sql——这正是分层的价值。
15.2.8 用 fake store 证明可替换
接口最大的好处是可替换。写一个只在内存里记录调用的 fake,就能在没有数据库的情况下测 service:
type fakeStore struct {
created []string
err error
}
func (f *fakeStore) Create(_ context.Context, title string) (Task, error) {
if f.err != nil {
return Task{}, f.err
}
f.created = append(f.created, title)
return Task{ID: int64(len(f.created)), Title: title}, nil
}
func (f *fakeStore) Get(context.Context, int64) (Task, error) { return Task{}, ErrNotFound }
func (f *fakeStore) List(context.Context) ([]Task, error) { return nil, nil }
注入它:svc := NewService(&fakeStore{}),然后断言 fakeStore.created 里有没有期望的标题。这就是第 8 章「测试替身」在分层架构里的落地——因为 service 依赖的是接口,替换实现不用改 service 一行代码。
一个编译期的小技巧,确保实现真的满足接口:
var _ TaskStore = (*MemStore)(nil) // 编译期断言 MemStore 实现了 TaskStore
var _ TaskStore = (*fakeStore)(nil)
这行空变量声明没有运行时开销,却能在实现漏了某个方法时立刻编译失败。
15.2.9 依赖方向:向内
最后强调一个容易被忽视的原则。分层里真正稳定的东西是领域模型与业务规则(Task、Service),它们不依赖任何外部细节;易变的细节(HTTP、SQL、具体数据库)在最外层。依赖箭头永远从外指向内:
handler ──▶ service ──▶ TaskStore(接口)
▲
│ 实现
MemStore / SQLRepo / fakeStore
内层不认识外层。所以换 HTTP 框架、换数据库、加一个 CLI 入口,都不会改动 service。这就是所谓「整洁架构」或「六边形架构」的核心,而在 Go 里它不需要任何框架,接口 + 手工装配就够了。
15.2.10 小结
- 三层职责:handler 管协议、service 管业务、store 管持久化,依赖单向向内。
- 接口由使用者定义(
TaskStore属于 service),实现者隐式满足它。 - 依赖注入 = 在 composition root(
main)里 new 出所有对象并逐层注入。 - 组件自己
new依赖会焊死实现,破坏可替换性与可测试性。 - Go 主流是手工装配:可读、编译期检查、零依赖;DI 框架通常不值得。
var _ TaskStore = (*MemStore)(nil)做编译期接口断言。- 用 fake store 证明 service 可以在无数据库的情况下被完整测试。
分层和装配都就位了,但还有一个横切问题:service 抛出的领域错误(ErrNotFound、ErrEmptyTitle)怎么变成正确的 HTTP 状态码?下一节讲统一错误响应与输入校验。
阅读导航:上一节:15.1 配置加载(flag/env/文件) · 下一节:15.3 统一错误响应与输入校验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。