Go 完整项目阅读实战:从 main 追到并发与数据库
# Go 完整项目阅读实战:从 main 追到并发与数据库
用一个 “生成笔记摘要” 的 Go 服务串联 main 入口、依赖组装、HTTP Handler、Service、数据库、模型客户端、Context 和优雅关闭,并形成阅读陌生 Go 项目的固定路线。
# 先记住一句话
阅读 Go 服务先从 cmd/*/main.go 找组合根,看具体实现被注入了哪些小接口,再沿一条 Handler → Service → Repository / Client 调用链追踪 Context、错误和副作用,最后检查 Goroutine 与关闭顺序。
# 项目场景与目录
项目提供 POST /notes/{id}/summary:认证中间件得到当前用户,Service 读取笔记、调用模型生成摘要,再写回 PostgreSQL。
note-api/
├─ go.mod # 模块路径、Go 版本和依赖
├─ migrations/ # 版本化数据库迁移
├─ cmd/
│ └─ api/
│ └─ main.go # 配置、依赖组装、启动与关闭
├─ internal/
│ ├─ config/
│ │ └─ config.go # 环境配置加载和校验
│ ├─ database/
│ │ └─ postgres.go # PostgreSQL 驱动与连接池
│ ├─ api/
│ │ ├─ router.go # 路由与 Middleware 组合
│ │ └─ summary_handler.go # HTTP 输入输出和错误映射
│ ├─ note/
│ │ ├─ model.go # 领域数据结构与稳定错误
│ │ └─ summary_service.go # 摘要业务用例及所需接口
│ ├─ postgres/
│ │ └─ note_repository.go # database/sql 实现
│ └─ modelclient/
│ └─ client.go # 外部模型 HTTP 实现
└─ tests/
└─ integration_test.go # 数据库与 HTTP 集成测试
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
internal 限制外部模块导入项目内部实现,但不会自动带来良好分层。真正的依赖方向是:
cmd/api/main.go
├─ 创建 postgres.Repository ─┐
├─ 创建 modelclient.Client ──┼─ 注入 note.SummaryService
└─ 创建 api.Handler ─────────┘
note 包只定义业务与小接口
postgres / modelclient 反向实现这些接口
2
3
4
5
6
7
# 第一步:看 go.mod 和启动入口
// 文件位置:go.mod
module example.com/note-api
// 声明模块使用的语言版本基线;团队应按实际工具链维护。
go 1.24
require github.com/jackc/pgx/v5 v5.7.6 // PostgreSQL 驱动,版本由 go.mod 固定。
2
3
4
5
6
7
# 从模块根目录运行 API 入口,适合本地开发。
go run ./cmd/api
# 编译为独立可执行文件,部署镜像通常执行这个产物。
go build -o bin/note-api ./cmd/api
2
3
4
5
看到 go run ./cmd/api 就打开该目录的 package main。main() 通常只处理最终日志和退出码,真正可测试的启动逻辑放在 run()。
配置文件也属于可运行项目的一部分。它只从环境读取原始值,并在进程启动时一次完成默认值设置和必填校验:
// 文件位置:internal/config/config.go
package config
import (
"errors" // 创建缺少必需配置时返回的错误。
"os" // 从当前进程环境变量读取部署配置。
"time" // 使用 Duration 表示优雅关闭期限。
)
// Config 是整个进程的启动配置,敏感值只从服务端环境读取。
type Config struct {
Address string // HTTP 监听地址,例如 :8080。
DatabaseURL string // PostgreSQL DSN,只保存在服务端环境中。
ModelURL string // 模型网关的基础地址。
ModelAPIKey string // 调用模型网关使用的密钥。
InternalAPIKey string // 保护当前内部 API 的共享密钥。
ShutdownTimeout time.Duration // 优雅关闭最多等待多久。
}
// Load 读取全部环境变量、填入默认值,并在启动阶段校验必需配置。
func Load() (Config, error) {
// 先把环境变量组装成候选 Config,后面再统一检查必需项。
settings := Config{
Address: getOrDefault("HTTP_ADDRESS", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
ModelURL: os.Getenv("MODEL_URL"),
ModelAPIKey: os.Getenv("MODEL_API_KEY"),
InternalAPIKey: os.Getenv("INTERNAL_API_KEY"),
ShutdownTimeout: 10 * time.Second,
}
// 所有关键配置在接收请求前完成校验,不能缺失时静默降级。
if settings.DatabaseURL == "" || settings.ModelURL == "" {
return Config{}, errors.New("DATABASE_URL and MODEL_URL are required")
}
if settings.ModelAPIKey == "" || settings.InternalAPIKey == "" {
return Config{}, errors.New("MODEL_API_KEY and INTERNAL_API_KEY are required")
}
return settings, nil // nil 表示所有必需配置均已提供。
}
// getOrDefault 优先返回非空环境变量,否则返回代码提供的 fallback。
func getOrDefault(key string, fallback string) string {
if value := os.Getenv(key); value != "" {
return value // value 只在当前 if 作用域中可见。
}
return fallback
}
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
# 第二步:main 是依赖组合根
// 文件位置:cmd/api/main.go
package main
import (
"context" // 创建启动、信号和关闭阶段使用的 Context。
"errors" // 判断 Server 是否只是正常关闭。
"fmt" // 包装启动和运行错误并补充操作背景。
"log/slog" // 输出结构化服务日志。
"net/http" // 创建 HTTP Client 与 Server。
"os" // 设置进程退出码,并使用 Ctrl+C 中断信号。
"os/signal" // 把操作系统信号转换成 Context 取消通知。
"syscall" // 使用容器环境常见的 SIGTERM。
"time" // 配置启动、HTTP Client、Server 和关闭超时。
"example.com/note-api/internal/api"
"example.com/note-api/internal/config"
"example.com/note-api/internal/database"
"example.com/note-api/internal/modelclient"
"example.com/note-api/internal/note"
"example.com/note-api/internal/postgres"
)
func main() {
// Go 用显式 error 返回失败;入口只负责记录最终错误并设置进程退出码。
if err := run(); err != nil {
slog.Error("service stopped", "error", err)
os.Exit(1) // 只在最外层决定进程退出码,defer 已在 run 中执行。
}
}
func run() error {
// := 声明局部变量并同时接收配置与错误两个返回值。
settings, err := config.Load()
if err != nil {
return fmt.Errorf("load config: %w", err)
}
// 启动阶段最多等待数据库连通性检查 5 秒,避免依赖不可用时永久卡住。
startupCtx, cancelStartup := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelStartup() // run 返回前释放启动 Context 的计时器资源。
// OpenPostgres 创建连接池并执行 Ping;失败时服务不应继续启动。
db, err := database.OpenPostgres(startupCtx, settings.DatabaseURL)
if err != nil {
return err
}
defer db.Close() // HTTP Server 结束后释放数据库连接池。
// HTTP Client 内部复用连接池,整个进程只创建一次;Timeout 限制一次模型调用总时长。
modelHTTP := &http.Client{Timeout: 20 * time.Second}
// 下面是组合根:具体基础设施从外向内依次注入 Repository、Service 和 Router。
repository := postgres.NewNoteRepository(db) // 数据库实现。
model := modelclient.New(settings.ModelURL, settings.ModelAPIKey, modelHTTP) // 模型 HTTP 实现。
service := note.NewSummaryService(repository, model) // 业务用例。
handler := api.NewRouter(service, settings.InternalAPIKey) // HTTP 入口。
// Server 统一配置监听地址、根 Handler 和连接级超时。
server := &http.Server{
Addr: settings.Address, // 来自已校验的启动配置。
Handler: handler, // 请求从 Router 和中间件链进入。
ReadHeaderTimeout: 5 * time.Second, // 限制读取请求头时间。
ReadTimeout: 15 * time.Second, // 限制读取完整请求时间。
WriteTimeout: 30 * time.Second, // 限制普通响应写出时间。
IdleTimeout: 60 * time.Second, // 限制 Keep-Alive 连接空闲时间。
}
// make 创建只容纳 error 的 Channel;容量 1 让监听 Goroutine 可以完成一次发送。
serverErrors := make(chan error, 1)
// go 启动匿名函数作为 Goroutine,使主流程可以同时等待服务错误或退出信号。
go func() {
// ListenAndServe 正常运行时持续阻塞;Shutdown 会让它返回 http.ErrServerClosed。
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErrors <- err // 只把真正的运行错误发送给主 Goroutine。
}
}()
// 收到 Ctrl+C 或 SIGTERM 时,signalCtx.Done() 会被关闭。
signalCtx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
// select 等待多个 Channel,先就绪的 case 决定接下来的关闭原因。
select {
case err := <-serverErrors:
// Server 自身失败时不再等待信号,直接向入口返回带上下文的错误。
return fmt.Errorf("serve HTTP: %w", err)
case <-signalCtx.Done():
slog.Info("shutdown signal received")
}
// signalCtx 已经取消,不能用于 Shutdown;这里创建独立关闭 Context。
shutdownCtx, cancelShutdown := context.WithTimeout(
context.Background(),
settings.ShutdownTimeout,
)
defer cancelShutdown()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
return nil // Server 已停止接收请求并完成有期限的在途请求等待。
}
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
读 main 时不要陷入每个实现细节,先画出对象关系:Repository 和模型 Client 被注入 Service,Service 再被注入 Router。连接池和 http.Client 都是进程级资源,请求 Context 则从 Handler 向下传递。
# 第三步:接口写在使用方附近
// 文件位置:internal/note/summary_service.go
package note
import (
"context" // 将 HTTP 请求取消和截止时间传给数据库与模型调用。
"fmt" // 使用 %w 为每一层失败补充业务操作背景。
)
// SummaryRepository 是摘要用例需要的数据访问能力,由 postgres 包实现。
type SummaryRepository interface {
// 接口声明 Service 真正需要的行为;具体类型拥有这些方法就会自动实现。
GetContent(ctx context.Context, noteID string, ownerID string) (string, error)
SaveSummary(ctx context.Context, noteID string, ownerID string, summary string) error
}
// SummaryModel 是摘要用例需要的模型能力,由 modelclient 包实现。
type SummaryModel interface {
Summarize(ctx context.Context, content string) (string, error)
}
// SummaryService 只保存业务用例依赖的两个接口,不依赖具体基础设施包。
type SummaryService struct {
repository SummaryRepository // 读取原文并保存生成的摘要。
model SummaryModel // 根据原文调用模型生成摘要。
}
// NewSummaryService 注入两个接口实现,返回可处理摘要用例的 Service。
func NewSummaryService(repository SummaryRepository, model SummaryModel) *SummaryService {
return &SummaryService{repository: repository, model: model}
}
// Summarize 串联 “读原文 → 调模型 → 保存摘要” 三个步骤。
func (s *SummaryService) Summarize(
ctx context.Context, // 来自 HTTP 请求,取消与超时贯穿整个用例。
noteID string, // 目标笔记 ID。
ownerID string, // 已认证用户 ID,用于数据权限约束。
) (string, error) {
// 同时接收内容和错误;每层都继续传递同一个请求 Context。
content, err := s.repository.GetContent(ctx, noteID, ownerID)
if err != nil {
return "", fmt.Errorf("read note content: %w", err)
}
// 把同一个请求 Context 传给模型调用,断连和截止时间可以继续向下传播。
summary, err := s.model.Summarize(ctx, content)
if err != nil {
return "", fmt.Errorf("generate summary: %w", err)
}
if err := s.repository.SaveSummary(ctx, noteID, ownerID, summary); err != nil {
return "", fmt.Errorf("save summary: %w", err)
}
return summary, nil // 三个步骤都成功后返回最终摘要。
}
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
Go 不需要显式写 implements。只要 postgres.NoteRepository 和 modelclient.Client 拥有这些方法,就自动满足接口。接口由 note 业务包按自己需要定义,因此基础设施不控制业务抽象。
这条同步链路的边界是:模型已生成但数据库写入失败时,请求会失败,重试可能再次产生模型费用。生产项目可以加入幂等键、摘要版本和任务记录;任务耗时较长时,改为先返回 task ID,再由 Worker 执行。
# 第四步:HTTP Client 不是简单 Post
// 文件位置:internal/modelclient/client.go
package modelclient
import (
"bytes" // 把 JSON 字节包装成可供 HTTP 请求读取的 Reader。
"context" // 接收上层请求的取消和截止时间。
"encoding/json" // 编码模型请求并解码模型响应。
"errors" // 创建响应内容不符合契约时返回的错误。
"fmt" // 包装不同阶段的错误并补充状态码等信息。
"io" // 限制、读取和丢弃外部 HTTP 响应体。
"net/http" // 创建带 Context 的请求并通过共享 Client 发出。
)
// Client 封装模型服务地址、认证信息和可复用的 HTTP Client。
type Client struct {
baseURL string // 模型服务基础地址,例如 https://model.example.com。
apiKey string // 写入 Authorization Header 的服务端密钥。
http *http.Client // 由 main 注入并在整个进程中复用的 Client。
}
// New 保存调用模型所需配置;它不会立刻发起网络请求。
func New(baseURL string, apiKey string, httpClient *http.Client) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey, http: httpClient}
}
// Summarize 调用模型 HTTP 接口,并返回响应中的 summary 字段。
func (c *Client) Summarize(ctx context.Context, content string) (string, error) {
// Marshal 把 Map 编码为 JSON 字节;编码失败时立即向上返回。
requestBody, err := json.Marshal(map[string]string{"content": content})
if err != nil {
return "", fmt.Errorf("encode model request: %w", err)
}
// NewRequestWithContext 创建 POST 请求;ctx 取消时,正在进行的网络调用也会收到通知。
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/summarize",
bytes.NewReader(requestBody), // 把 JSON []byte 转成请求体需要的 io.Reader。
)
if err != nil {
return "", fmt.Errorf("create model request: %w", err)
}
// 告诉模型服务请求体是 JSON,并使用 Bearer 方案传递密钥。
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+c.apiKey)
// Do 真正发送请求并等待响应头;网络错误、超时和 Context 取消都会通过 err 返回。
response, err := c.http.Do(request)
if err != nil {
return "", fmt.Errorf("call model: %w", err)
}
// defer 把关闭动作推迟到函数返回前,确保所有返回路径都会释放响应体。
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
// 丢弃有限响应体以便复用连接,但不把外部响应写入错误和日志。
// 4<<10 等于 4096 字节;两个 _ 表示主动忽略复制数量与错误。
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
return "", fmt.Errorf("model status %d", response.StatusCode)
}
// 匿名 Struct 只为当前响应声明最小结构;标签把 JSON 的 summary 映射到字段。
var payload struct {
Summary string `json:"summary"`
}
// LimitReader 最多允许解码 1 MiB 响应,避免异常服务返回无限数据占用内存。
decoder := json.NewDecoder(io.LimitReader(response.Body, 1<<20))
if err := decoder.Decode(&payload); err != nil {
return "", fmt.Errorf("decode model response: %w", err)
}
if payload.Summary == "" {
return "", errors.New("model response has empty summary")
}
return payload.Summary, nil // 契约校验通过后返回摘要。
}
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
阅读外部客户端时重点检查:是否复用 http.Client、是否带 Context、是否设置超时、是否关闭 Body、是否限制响应大小、非 2xx 怎样分类、哪些错误允许重试、日志是否泄露凭据。
# 第五步:Repository 落实权限与数据库语义
// 文件位置:internal/postgres/note_repository.go
package postgres
import (
"context" // 把请求取消和截止时间传给数据库驱动。
"database/sql" // 使用共享连接池、查询结果和 sql.ErrNoRows。
"errors" // 判断查询错误链中是否包含 sql.ErrNoRows。
"fmt" // 为查询、更新和结果检查错误补充上下文。
"example.com/note-api/internal/note"
)
// NoteRepository 是 SummaryRepository 的 PostgreSQL 实现。
type NoteRepository struct {
// sql.DB 是可并发复用的连接池句柄,而不是一条固定连接。
db *sql.DB
}
// NewNoteRepository 保存应用级 sql.DB 句柄;不会为每个 Repository 新建连接池。
func NewNoteRepository(db *sql.DB) *NoteRepository {
return &NoteRepository{db: db}
}
// GetContent 按 Note ID 和 Owner ID 查询正文,Owner 条件同时落实数据权限。
func (r *NoteRepository) GetContent(
ctx context.Context, // 来自请求的 Context。
noteID string, // 目标笔记 ID,绑定到 SQL 的 $1。
ownerID string, // 已认证 Owner ID,绑定到 SQL 的 $2。
) (string, error) {
const query = `SELECT content FROM notes WHERE id = $1 AND owner_id = $2`
var content string // Scan 成功后,数据库 content 列会写入这个变量。
// if 初始化语句把查询错误限制在当前判断作用域;&content 允许 Scan 写入变量。
if err := r.db.QueryRowContext(ctx, query, noteID, ownerID).Scan(&content); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", note.ErrNotFound
}
return "", fmt.Errorf("query note content: %w", err)
}
return content, nil // 找到记录并成功扫描正文。
}
// SaveSummary 只更新属于当前 Owner 的 Note,并确认恰好影响一行。
func (r *NoteRepository) SaveSummary(
ctx context.Context, // 控制更新操作的取消和超时。
noteID string, // SQL 中的 $2。
ownerID string, // SQL 中的 $3,同时承担权限约束。
summary string, // 要写入 summary 列的模型结果,对应 $1。
) error {
const query = `UPDATE notes SET summary = $1 WHERE id = $2 AND owner_id = $3`
// ExecContext 返回 sql.Result;参数顺序必须与 $1、$2、$3 一致。
result, err := r.db.ExecContext(ctx, query, summary, noteID, ownerID)
if err != nil {
return fmt.Errorf("update note summary: %w", err)
}
// RowsAffected 告诉我们 UPDATE 实际匹配并修改了多少行。
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("read affected rows: %w", err)
}
if rows != 1 {
return note.ErrNotFound // 更新目标不存在或已失去权限,不能静默成功。
}
return nil // 恰好更新一行,保存成功。
}
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
60
61
62
63
64
65
66
// 文件位置:internal/note/model.go
package note
import "errors" // 创建可跨层判断的领域哨兵错误。
var ErrNotFound = errors.New("note not found") // 领域稳定错误不依赖 SQL 或 HTTP。
2
3
4
5
6
Handler 可以用 errors.Is(err, note.ErrNotFound) 映射 404,因为各层都使用 %w 保留错误链。日志记录一次完整错误即可,避免每层重复打印同一失败。
# 第六步:Router 决定公开边界
// 文件位置:internal/api/router.go
package api
import (
"context" // 把认证身份写入请求子 Context。
"crypto/subtle" // 使用常量时间比较验证 API Key。
"log/slog" // 输出结构化访问日志。
"net/http" // 创建 Router、中间件和 HTTP 错误响应。
"time" // 记录请求开始时间并计算耗时。
)
// ownerIDKey 是当前包私有的 Context Key 类型,空 Struct 本身不占用业务数据空间。
type ownerIDKey struct{} // 私有类型避免与其他包的 Context Key 冲突。
// SummaryService 是 Router 和 Handler 真正依赖的业务能力。
type SummaryService interface {
// Router 依赖业务接口,而不是具体实现,便于替换和测试。
Summarize(ctx context.Context, noteID string, ownerID string) (string, error)
}
// RequireAPIKey 验证请求头中的内部密钥,成功后把可信身份写入 Context。
func RequireAPIKey(expected string, next http.Handler) http.Handler {
// HandlerFunc 把普通函数适配成 http.Handler。
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
provided := request.Header.Get("X-API-Key") // 读取客户端提交的密钥;请求头不存在时为空字符串。
if len(provided) != len(expected) ||
subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
http.Error(writer, "unauthorized", http.StatusUnauthorized)
return
}
// WithValue 返回携带认证身份的子 Context,供后续 Handler 读取。
ctx := context.WithValue(request.Context(), ownerIDKey{}, "internal-client")
next.ServeHTTP(writer, request.WithContext(ctx)) // 把认证身份传给业务 Handler。
})
}
// AccessLog 包装下游 Handler,在请求完成后记录方法和整体耗时。
func AccessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now() // 调用下游之前记录开始时间。
next.ServeHTTP(writer, request) // 执行认证、路由和最终业务 Handler。
// 下游返回后才能得到从进入中间件到响应处理完成的总耗时。
slog.Info("http request", "method", request.Method, "duration", time.Since(startedAt))
})
}
// NewRouter 组装路由和中间件,并返回可直接交给 http.Server 的根 Handler。
func NewRouter(service SummaryService, apiKey string) http.Handler {
handler := NewSummaryHandler(service) // 注入业务 Service。
mux := http.NewServeMux() // 创建标准库路由器。
mux.HandleFunc("POST /notes/{id}/summary", handler.Summarize) // 注册方法、路径模式和处理函数。
// 从内向外包装:API Key 先于业务 Handler,访问日志覆盖整条链路。
return AccessLog(RequireAPIKey(apiKey, mux))
}
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
这个内部 API Key 中间件把已认证身份写入请求 Context,Handler 不会信任请求体中的用户 ID。面向终端用户时应换成经过验证的身份令牌和资源级授权,代码结构仍然相同。
// 文件位置:internal/api/summary_handler.go
package api
import (
"encoding/json" // 把成功结果编码成 JSON 响应。
"errors" // 判断业务错误链是否属于 Note 不存在。
"log/slog" // 记录内部错误和响应编码失败。
"net/http" // 读取请求、写响应并使用标准状态码。
"example.com/note-api/internal/note"
)
// SummaryHandler 把 HTTP 输入输出适配为 SummaryService 调用。
type SummaryHandler struct {
service SummaryService // 由 Router 构造时注入的业务接口。
}
// NewSummaryHandler 保存 Service,并返回 Handler 指针。
func NewSummaryHandler(service SummaryService) *SummaryHandler {
return &SummaryHandler{service: service}
}
// Summarize 读取已认证身份和路径 ID,调用业务层,再把结果映射成 HTTP 响应。
func (h *SummaryHandler) Summarize(writer http.ResponseWriter, request *http.Request) {
// .(string) 是类型断言;ok 表示值存在且确实是 string。
ownerID, ok := request.Context().Value(ownerIDKey{}).(string)
if !ok {
http.Error(writer, "unauthorized", http.StatusUnauthorized)
return
}
// 使用原请求 Context,使浏览器断开和请求超时能继续传播到数据库与模型调用。
summary, err := h.service.Summarize(
request.Context(),
request.PathValue("id"), // Go 标准路由从路径模式中取得 id。
ownerID,
)
if errors.Is(err, note.ErrNotFound) {
http.Error(writer, "note not found", http.StatusNotFound)
return
}
if err != nil {
slog.Error("summarize note", "error", err)
http.Error(writer, "internal server error", http.StatusInternalServerError)
return
}
// Header 必须在首次写响应体之前设置;Encode 成功时默认状态码为 200。
writer.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(writer).Encode(map[string]string{"summary": summary}); err != nil {
slog.Error("encode summary response", "error", 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
53
项目中应让同一个认证中间件验证身份并写入 Context,不能相信请求 JSON 自带的用户 ID。更完整的安全和 SSE 边界见 Go 生产 HTTP 服务。
# 阅读陌生 Go 项目的十步法
- 看
README、go.mod、容器与部署命令; - 从
cmd/*/main.go找可执行入口; - 画出 main 创建的长期依赖与关闭顺序;
- 选择一条核心路由、RPC 或消息消费入口;
- 从 Handler 看输入、身份、Context 和错误映射;
- 从 Service 看业务顺序与接口;
- 搜索具体类型的方法,确认哪个 Struct 实现接口;
- 检查数据库事务、HTTP 超时和消息幂等;
- 搜索所有
go func,回答每个 Goroutine 怎样停止和等待; - 用测试、Race Detector 和 Profile 验证行为与性能判断。
查找接口实现时可直接使用 rg:
# 搜索方法名,定位哪些具体类型可能满足接口。
rg "func .*Summarize\(" .
# 搜索所有 Goroutine 启动点,逐个检查生命周期。
rg "\bgo func|\bgo [A-Za-z_]" --glob "*.go" .
# 搜索数据库事务入口,确认事务内是否误用 sql.DB。
rg "BeginTx|\.Commit\(|\.Rollback\(" --glob "*.go" .
2
3
4
5
6
7
8
# 和前端项目阅读的主要差异
- Go 的入口和依赖组装通常更显式,先看 main 很有价值;
- 接口是隐式实现,必须从方法集反查具体类型;
error通过返回值逐层包装,重点看哪里分类、哪里记录;- Context 是请求生命周期,不能被中途换成 Background;
- Goroutine 可能比 Promise 更隐蔽,必须专门搜索启动点;
sql.DB、http.Client和 Server 都是有生命周期的并发资源。
# 项目面试怎样表达
我把 Go 摘要服务按 HTTP、业务和基础设施边界拆分。main 是组合根,显式创建数据库连接池和模型 HTTP Client,再注入只依赖小接口的 Service。请求 Context 从 Handler 传到 SQL 和模型调用,资源查询同时带 owner 条件;外部响应限制大小并校验状态。Server 设置读写超时和优雅关闭,单元测试使用 Fake,CI 额外运行 Race Detector,性能问题通过 Benchmark 和 pprof 定位。
继续追问时,应能说明:
sql.DB 为什么要长期共享,连接池怎样预算?参考答案
sql.DB 本身就是并发安全的连接池,应该在启动时创建并由多个请求共享,而不是每个请求重新打开。最大连接数可按 “数据库允许连接数减去预留量,再除以服务实例数” 估算,同时设置最大空闲连接数和连接生命周期,并根据连接等待时间持续调整。
模型成功、写库失败时,怎样避免重复计费?参考答案
先为一次摘要任务分配幂等键并持久化任务状态,模型成功后保存请求 ID 和结果,写库时再做幂等更新;重试前先查任务,已有结果就直接复用。若进程恰好在模型成功但结果落库前崩溃,只有模型服务支持幂等请求或结果查询才能彻底避免再次计费,否则只能降低概率,不能保证完全消除。
什么时候应把同步请求改成持久化任务队列?参考答案
当模型调用经常超过 HTTP 等待时间,或者任务需要重试、限流、批处理并在用户断开后继续执行时,就应改成任务队列。接口只创建任务并返回任务 ID,Worker 异步处理,客户端查询状态或接收完成通知;任务执行和结果写回都要保证幂等。
怎样让流式响应在浏览器断开后停止模型生成?参考答案
浏览器断开后,请求的 Context 会被取消,因此要把同一个 Context 一直传给模型 HTTP 请求,并在读取流和写响应时监听 ctx.Done()。收到取消信号后立即停止读取、关闭响应体并退出 Goroutine;如果中途换成 context.Background(),取消链就会被切断。
为什么接口定义在业务使用方,而不是基础设施提供方?参考答案
业务使用方最清楚自己真正需要哪些方法,因此应在业务包定义最小接口,数据库或模型客户端只负责实现。这样业务不会依赖具体基础设施,也方便测试时注入 Fake;如果由提供方定义,接口容易暴露过多能力并把变化传给所有调用者。
服务关闭时怎样处理在途请求和后台 Goroutine?参考答案
先调用 Server.Shutdown 停止接收新请求并等待在途请求结束,再取消应用根 Context,通知后台 Goroutine 退出,并使用 WaitGroup 或 errgroup 等待清理完成。整个关闭过程要设置总超时,最后再关闭数据库、消息客户端等共享资源。
# 高频面试题与回答
1. 接手陌生 Go 服务先看哪里?参考答案
先从 go.mod 和启动命令找到 cmd 下的 main,画出配置、连接池、Client、Service 和 Handler 的组装关系。再选一条核心请求沿 Handler、Service、接口实现追踪 Context、错误与副作用,最后检查测试、所有 Goroutine 启动点和关闭顺序。
2. Go 的隐式接口会不会让实现难找?参考答案
需要从组合根和方法名反查。main 通常展示具体类型注入哪个接口,编辑器的实现跳转或 rg 搜索方法签名可以定位候选实现。隐式实现降低了业务包对基础设施的耦合,但接口应保持小且命名清楚。
3. 这条同步摘要链路最大的生产风险是什么?参考答案
模型调用可能很慢且产生费用,客户端重试还会重复执行;模型成功后写库失败也会形成不确定结果。需要设置超时和幂等键,记录摘要版本;耗时较长或必须重试时改成持久化任务,由 Worker 执行并提供状态查询。
# 接下来学什么
完成本篇后,选一个开源 Go API 项目按十步法写出一页调用链:入口、依赖图、一条请求、错误、事务、Goroutine 和关闭。之后可以回到 AI 全栈学习地图 选择下一模块。