《Go 语言编程入门》15.2 依赖注入与项目分层

配置有了,谁来把它组装成运行中的对象?本节讲 TaskAPI 的分层:handler 只做协议转换、service 承载业务规则、store 负责持久化,三者用接口解耦。然后演示 Go 惯用的手工依赖注入——在 main 这个 composition root 里把所有依赖 new 出来、逐层注入,不引入任何 DI 框架,并用 fake store 证明层与层之间可替换。

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 统一错误响应与输入校验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练