Go 测试、工程化与微服务边界
# Go 测试、工程化与微服务边界
把 Go 基础组合成一个可测试、能优雅关闭的服务,理解目录职责、依赖注入、表驱动测试和微服务拆分边界。
# 先记住一句话
Go 工程化的重点不是套目录模板,而是让依赖从入口显式组装、业务逻辑可独立测试、请求生命周期可取消、进程退出不会丢下正在处理的工作。
# 一个常见目录
note-api/
├─ go.mod # 模块路径、Go 版本和依赖
├─ cmd/
│ └─ api/
│ └─ main.go # 读取配置、组装依赖、启动和关闭服务
├─ internal/
│ ├─ note/
│ │ ├─ model.go # 领域数据结构
│ │ ├─ service.go # 业务规则与所需接口
│ │ └─ service_test.go # 不启动 HTTP 的快速单元测试
│ ├─ memory/
│ │ └─ note_store.go # 内存 Store,生产可替换为数据库
│ └─ api/
│ ├─ handler.go # HTTP 输入、输出和错误映射
│ └─ handler_test.go # 使用 httptest 验证接口
└─ README.md # 启动方式、配置和接口说明
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
internal 目录中的包不能被其父目录树之外的模块导入,适合表达项目内部实现边界。它不是分层架构的强制模板,目录仍应跟随真实职责调整。
# 依赖从入口向内组装
// 文件位置:cmd/api/main.go
package main
import (
"context" // 创建服务生命周期和关闭超时 Context。
"errors" // 判断 Server 返回值是否只是正常关闭信号。
"log" // 输出启动、运行和关闭日志。
"net/http" // 创建并关闭 HTTP Server。
"os" // 使用 os.Interrupt 表示 Ctrl+C 信号。
"os/signal" // 把操作系统信号转换成 Context 取消通知。
"syscall" // 使用容器和进程管理器常见的 SIGTERM。
"time" // 配置请求头和优雅关闭超时。
"example.com/note-api/internal/api"
"example.com/note-api/internal/memory"
"example.com/note-api/internal/note"
)
func main() {
// 具体实现只在组合根出现,业务层依赖自己定义的小接口。
store := memory.NewNoteStore()
service := note.NewService(store)
handler := api.NewHandler(service)
// Server 只负责连接和 Handler 调度;业务依赖已经在上面组装完成。
server := &http.Server{
Addr: ":8080", // 在 8080 端口监听。
Handler: handler.Routes(), // 所有请求进入的根 Handler。
ReadHeaderTimeout: 5 * time.Second, // 防止客户端长时间占用连接却不发完请求头。
}
// 收到退出信号后取消 Context,开始有期限的优雅关闭。
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
// 匿名函数末尾的 () 表示立即调用;go 让它在新 Goroutine 中执行。
log.Printf("listening on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
}()
<-ctx.Done() // 主 Goroutine 在这里等待 Ctrl+C 或 SIGTERM。
// 原 ctx 已经取消,必须创建新的 Context 给 Shutdown 留出最多 10 秒清理时间。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
优雅关闭会停止接收新连接,并等待活跃请求完成,直到截止时间。队列消费者、数据库连接和后台 Goroutine 也要纳入关闭顺序,不能只关闭 HTTP Server。
# 表驱动测试
// 文件位置:internal/note/service_test.go
package note_test
import (
"context"
"errors"
"testing"
"example.com/note-api/internal/note"
)
type fakeStore struct {
result note.Note // Get 应返回的预设 Note。
err error // Get 应返回的预设错误。
}
func (f fakeStore) Get(_ context.Context, _ string) (note.Note, error) {
// 假实现不访问数据库,使测试只验证 Service 行为。
return f.result, f.err
}
func TestServiceGetTitle(t *testing.T) {
// 匿名 Struct 表格集中描述输入和期望结果,方便扩展测试案例。
tests := []struct {
name string // 子测试名称。
store fakeStore // 当前案例注入的存储行为。
want string // 期望标题。
wantError error // 期望错误类别;nil 表示应成功。
}{
{
name: "existing note",
store: fakeStore{result: note.Note{Title: "Go"}},
want: "Go",
},
{
name: "missing note",
store: fakeStore{err: note.ErrNotFound},
wantError: note.ErrNotFound,
},
}
// range 遍历每个案例;_ 表示不需要当前索引。
for _, test := range tests {
// t.Run 为每个案例建立独立子测试,失败信息会包含案例名。
t.Run(test.name, func(t *testing.T) {
// 每个案例都重新创建 Service,避免案例之间共享状态。
service := note.NewService(test.store)
// Background 足以满足这个无超时、无 I/O 的 Fake 测试。
got, err := service.GetTitle(context.Background(), "id")
if !errors.Is(err, test.wantError) {
t.Fatalf("error = %v, want %v", err, test.wantError)
}
if got != test.want {
t.Fatalf("title = %q, want %q", got, test.want)
}
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
表驱动测试把输入和期望集中在测试表中,同一行为的边界案例更容易补充。不要把所有测试都写成一张巨表;当准备逻辑和断言明显不同,应拆成独立测试。
# 提交前的最小检查链
# 自动统一格式,避免代码风格争论。
gofmt -w .
# 运行当前模块所有包的测试。
go test ./...
# 并发代码额外运行 Race Detector;它只能发现实际执行到的竞争路径。
go test -race ./...
# vet 检查 printf 参数、复制锁等常见可疑代码。
go vet ./...
# 确认最终可执行入口能够编译。
go build ./cmd/api
2
3
4
5
6
7
8
9
10
11
12
13
14
# 什么时候才拆微服务
微服务不是 “用 Go 写 HTTP 接口” 的同义词。只有当业务边界、独立伸缩、故障隔离或团队所有权带来的收益,大于网络调用、数据一致性、部署和观测成本时,拆分才有价值。
一个刚起步的笔记服务通常先采用模块化单体:
一个部署单元
├─ 清楚的 note 业务包
├─ 清楚的 user 业务包
└─ 显式接口隔离基础设施
当某个边界被真实压力证明需要独立部署时
└─ 再拆成网络服务,并补齐超时、重试、幂等、认证和追踪
2
3
4
5
6
7
# 项目表达
我用 Go 标准库实现 JSON API,入口负责依赖组装和优雅关闭,业务包只依赖自己定义的小接口。核心逻辑使用表驱动测试,HTTP 边界使用 httptest,并在 CI 中运行 Race Detector。服务之间只有在独立伸缩或故障隔离有明确收益时才拆分。
# 高频面试题与回答
1. 什么是优雅关闭?参考答案
优雅关闭是在收到退出信号后停止接收新工作,给正在执行的请求和后台任务一个有限时间完成,然后释放连接等资源。必须设置截止时间,否则有问题的任务可能让进程永远无法退出。
2. 为什么不建议一开始就拆微服务?参考答案
微服务会引入网络失败、分布式数据一致性、部署、鉴权和追踪成本。如果业务边界和独立伸缩需求还不明确,模块化单体通常更简单;内部边界设计清楚后,未来仍可按证据拆分。
# 接下来学什么
下一篇学习 Go 测试、Benchmark 与 pprof,用测试、基准和性能剖析验证服务的正确性与性能判断。