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                 # 启动方式、配置和接口说明
1
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)
	}
}
1
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)
			}
		})
	}
}
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 什么时候才拆微服务

微服务不是 “用 Go 写 HTTP 接口” 的同义词。只有当业务边界、独立伸缩、故障隔离或团队所有权带来的收益,大于网络调用、数据一致性、部署和观测成本时,拆分才有价值。

一个刚起步的笔记服务通常先采用模块化单体:

一个部署单元
├─ 清楚的 note 业务包
├─ 清楚的 user 业务包
└─ 显式接口隔离基础设施

当某个边界被真实压力证明需要独立部署时
  └─ 再拆成网络服务,并补齐超时、重试、幂等、认证和追踪
1
2
3
4
5
6
7

# 项目表达

我用 Go 标准库实现 JSON API,入口负责依赖组装和优雅关闭,业务包只依赖自己定义的小接口。核心逻辑使用表驱动测试,HTTP 边界使用 httptest,并在 CI 中运行 Race Detector。服务之间只有在独立伸缩或故障隔离有明确收益时才拆分。

# 高频面试题与回答

1. 什么是优雅关闭?参考答案

优雅关闭是在收到退出信号后停止接收新工作,给正在执行的请求和后台任务一个有限时间完成,然后释放连接等资源。必须设置截止时间,否则有问题的任务可能让进程永远无法退出。

2. 为什么不建议一开始就拆微服务?参考答案

微服务会引入网络失败、分布式数据一致性、部署、鉴权和追踪成本。如果业务边界和独立伸缩需求还不明确,模块化单体通常更简单;内部边界设计清楚后,未来仍可按证据拆分。

# 接下来学什么

下一篇学习 Go 测试、Benchmark 与 pprof,用测试、基准和性能剖析验证服务的正确性与性能判断。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27