Web 服务:从 net/http 到 context 再到一整套服务¶
这一章把上一章的 socket 地基盖成楼房。 HTTP 服务器骨架小得惊人: 一个路由表 + 一组处理函数 + 一行 ListenAndServe;middleware 是「返回函数的函数」, 在调用前后夹行为;context 是请求的生命周期管家——超时、取消、随请求传递的小数据。 后半章全是成品:会优雅关停的代理、带 CLI 客户端的便签 REST 服务、 以及把第 8 章推给 Prometheus 的指标再读回来的工具。 读完你会拿到「写一个生产级 Go 服务」所需的全部标准件。
1. 这一章在原书里的位置¶
原书第 9 章,第二部分的压轴。第 8 章对着 socket 裸编,理解了 TCP/UDP; 本章进入 99% 的 Go 后端真实所在的层:HTTP + 路由 + 中间件 + context + 可观测性。 第一部分的语言知识(goroutine、接口、defer、错误处理)在这里全部汇拢。
2. 顶层全景¶
服务器 ServeMux + handler 函数 + middleware 包装(§3)
测量 net/http/pprof(浏览器直看)+ ab 灌流 + go tool pprof(§4)
测试 httptest:不发真服务器的 HTTP 单元测试(§5)
生命周期 context:WithTimeout/WithCancel/WithValue(§6)
成品 ×3 代理(§7) / 便签 REST + Cobra CLI(§8–§9) / Prometheus 回读(§10)
图说:前四块是「任何一个服务都该有的器官」,后三块是器官拼成的三个活物。
3. HTTP 服务器:middleware 一个模式打天下¶
http.ListenAndServe + http.NewServeMux 是全部仪式。Go 1.22 起路由支持
方法前缀:mux.HandleFunc("GET /echo", …) 与 "POST /echo" 是两条独立路由,
不用在 handler 里手写 if r.Method != "GET"1。
net/http 和并发模型焊在一起:每个请求自动一条 goroutine,第 10 章
「每连接一 goroutine」在这里连那个 go 都替你写了2。
middleware:返回 handler 的函数¶
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
fmt.Printf("[%s] %s %s\n", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r) // ← 把控制权交给真正的 handler
fmt.Printf("Completed in %v\n", time.Since(start))
}
}
// 注册时包装:mux.HandleFunc("GET /time", loggingMiddleware(handleTime))
图说:middleware 不改 handler 一行代码,只在它前后夹行为——
日志、认证、限流、panic 恢复全是这一个形状,可无限嵌套。
这是「横切关注点」四个字在 Go 里的全部实现成本。实测:curl 打六个端点,
服务器侧逐行 [GET] /time [::1]:51440 + Completed in 513.042µs3。
服务器演示的五个端点各教一件事:/(纯文本)、/time(JSON 响应的
writeJSON 套路——设 Content-Type + json.NewEncoder(w).Encode)、
/echo?msg=…(URL 查询参数)、POST /echo(请求体 JSON 解码,坏体给 400)、
/headers(回显请求头)、/health(负载均衡器探活)4。
HTTP 客户端¶
服务器写好先用 curl 测,客户端课用 Go 自写。三档API:
http.Get/http.Post(一步式)、http.NewRequest + http.Client.Do
(能加自定义头、控超时)、io.ReadAll(resp.Body) 收身。
铁律:defer resp.Body.Close()——响应体是连接池的通行证,不关会耗尽连接5。
4. 测量:pprof 告诉你服务器在等什么¶
加性能观测只需要两件事:匿名 import _ "net/http/pprof"(自动在
DefaultServeMux 挂上 /debug/pprof/ 全家桶),再开第二个端口(6060)专供剖析,
别和业务抢路6。七个常驻 profile:allocs、block、goroutine、heap、mutex、
threadcreate,CPU profile 要显式开启采样会话7。
实测剧本(值得抄):服务器跑着,go tool pprof -seconds=30
http://localhost:6060/debug/pprof/profile 开始采样;另一终端
ab -n 5000000 -c 10 "…/echo?msg=test" 灌流。结果两个看点:
Duration: 30.15s, Total samples = 42.65s (141.45%)
← 采样时间超过墙钟时间 = goroutine 真的在多核上并行(第 5 号调度器的可视化)
syscall.syscall 19.31s 45.28%
runtime.kevent 7.80s 18.29% ← 等内核事件(kqueue)
runtime.pthread_cond_wait 4.64s 10.88% ← 等条件变量
图说:HTTP 服务器的天性是等——时间大头在系统调用和线程睡眠, 「自己的计算」占比极小。这是给「性能优化先 profile」最生动的注脚: 优化业务逻辑前,先确认瓶颈真在业务逻辑8。
5. 测试:httptest 不发真服务器¶
net/http/httptest 让 handler 测试不需要端口:httptest.NewRequest 造假请求,
httptest.NewRecorder 当假 ResponseWriter,直接调 handler(可以带着 middleware
一起调),断言状态码和响应体。书上把六个端点做成表格驱动测试——和 06 号拆解的
basename 测试同一形状,只是「函数」换成了「HTTP 往返」9。
测试输出里客户端地址变成 192.0.2.1:1234——文档保留段 IP,一眼认出是测试流量。
6. context:请求的生命周期管家¶
context 解决一个问题:一条请求触发的所有下游工作(数据库查询、外部 API、 子 goroutine),怎么一起「喊停」。三种用具:
| 函数 | 干什么 | 触发时机 |
|---|---|---|
WithCancel |
返回 cancel 函数 | 你手动调 cancel() |
WithTimeout(d) |
2 秒后到点 | 倒计时结束 |
WithDeadline(t) |
到指定时刻 | 时钟到点 |
使用合同:ctx.Done() 是个 channel,select 等它;拿到 cancel 必 defer cancel()
(管家的打卡下班,防泄漏);ctx.Err() 告诉你死因(deadline exceeded / canceled)。
书里教练吹哨的比喻:时限一到,所有人停手,不管干到哪10。
HTTP 集成的最小演示:handler 里 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second),
慢操作要 3 秒——ctx.Done() 先到,返回 408 Request Timeout。
真服务的对应动作:客户端断开或超时,数据库查询和后台任务随之取消11。
WithValue 是第三条腿(传请求级小数据:用户 ID、trace ID),
但书里给了重要的克制令:数据逻辑上属于应用(配置、连接池、大对象)就放结构体;
只有「跟着这次请求走」的元数据才进 context。背包 vs 随任务传阅的剪贴板12。
7. 成品一:代理服务器¶
代理 = 客户端和目标服务器之间的中间人:转发、可缓存、可过滤、可隐藏客户端 IP。 书上的实现因太长没贴代码,但架构描述完整,值得当设计范本13:
- Proxy 结构封装全部状态:listener、logger、quit channel、WaitGroup;
NewProxy里建一个响应 OS 信号的 context(SIGINT/SIGTERM → cancel)—— 第 8 章 signal.Notify + 本章 context 的合龙;Start():Accept 循环把每条连接交给 goroutine,同时盯 context 的取消;handleClient():解析 HTTP 请求、净化头、普通 HTTP 直接转发; HTTPS 走 CONNECT 隧道(CONNECT 是 HTTP 代理转加密流量的标准方法)—— 双向数据拷贝各一条 goroutine,共享一个 WithCancel:一边关,两边散;Shutdown():关 listener、WaitGroup.Wait() 等所有活跃会话收场,日志确认。
实测:curl -x http://127.0.0.1:8080 https://www.mtsoukalos.eu/,
日志完整演出 accept → CONNECT 隧道 → 隧道关闭;Ctrl+C 后三行关停日志
Shutting down proxy server... / shut down gracefully. / Listener closed14。
「优雅关停 = context 取消 + WaitGroup 等待」这对组合是生产服务器的标准答案,
第 8 章的 dd(1) 用了信号,这里补全了「等完再走」的另一半。
8. 成品二:便签 REST 服务¶
教学版 REST:便签有 id/title/content,五个端点(POST /notes 建、 GET /notes 列(可 ?title= 过滤)、GET/PUT/DELETE /notes/{id}), 数据落盘 notes.json(loadNotes/saveNotes 同步内存 map 与文件)15。 curl 全旅程:
POST /notes {"title":"First Note",…} → {"id":2,…} ← 201
GET /notes → [ {id:1,…}, {id:2,…} ]
DELETE /notes/1 → (空,成功)
GET /notes/1 → "Note not found" ← 404
设计要点书里明说:先设计再写码——路由约定、数据格式、错误形态, 以及把业务逻辑、网络、持久化分层(这个服务还小,三层的轮廓已经在)16。
9. 成品三:Cobra/Viper CLI 客户端¶
第 2 号拆解只给了 cobra/viper 概念,这里第一次真用。
cobra init + cobra add list/get/add/update/delete 脚手架出 cmd/ 目录;
root.go 定义持久 flag --url(子命令全继承),add.go 的 RunE 里就是
上一节的 HTTP 客户端代码。关键迁移:先把 API 交互写成纯 Go 函数
(testServer.go),再原封不动塞进 Cobra 命令——HTTP 逻辑与 CLI 壳分离17:
$ go run main.go add -t "Book Idea" -c "Write about Go middleware"
Note created successfully!
$ go run main.go get 1
Error: server returned: 404 Not Found
必选项用 MarkFlagRequired 声明,缺了 Cobra 自己拦18。
(服务与客户端共用 Note 结构体与 baseURL——这是单一事实来源的小示范。)
10. 成品四:从 Prometheus 把指标读回来¶
第 8 章是「推出去」,本节是「读回来」:Prometheus 自己也有 HTTP API,
/api/v1/query(瞬时值)与 /api/v1/query_range(区间序列),
查询语言 PromQL 就长在 URL 参数里19。
readProm.go 的教学点有两个。① 响应结构建模:Instant 与 Range 分开两个
结构体,值的类型用 []interface{}(JSON 里时间和值类型不一)——
宁可解码时做类型断言,也不让结构体撒谎。② label 过滤的用户界面:
--labels cpu=0,mode=idle 程序负责拼成 {cpu="0",mode="idle"} 接在指标名后
——CLI 参数到查询语言的翻译层,一层就够,不让用户写 PromQL20。
配套还有一个造数程序(sampleData.go):正弦曲线模拟 CPU 昼夜起伏、
HTTP 请求随机突发,回填 7 天历史后 POST 给 Pushgateway
(第 10 号说过 Pushgateway 缺席,这里补上了:不可被拉的短任务批次走推送)。
--list 列全部指标、--metric 查瞬时值、--start/--end 定区间——
一个最小可用的 PromQL 客户端21。
11. 作者的判断与证据¶
| 判断 | 性质 | 依据 |
|---|---|---|
| middleware 是 HTTP 横切关注点的完整答案 | 设计事实 + 作者立场 | 「just a function returning another http.HandlerFunc」3 |
| 剖析端口与业务端口分开 | 工程惯例(作者采用) | 6060 独立服务的说明6 |
| HTTP 服务器的时间大头在「等」 | pprof 实证 | 141.45% 采样 + syscall/kevent 占比8 |
| cancel 必 defer、应用数据不进 context | 语言社区铁律 | backpack/剪贴板对照12 |
| 优雅关停 = context 取消 + WaitGroup 等待 | 架构模式(作者实现) | 代理的 Shutdown 设计13 |
| 先写纯函数 API 客户端再套 CLI | 作者的方法论主张 | 「directly transferred into a Cobra-based CLI」17 |
12. 边界与局限¶
- 路由只说标准库。路径参数(/{id} 的提取)、路由组、通配在 ServeMux 里很简陋, 书里一句 Note 把 Chi 指出去就没了——真实项目多半第三周就换路由器。
- 并发安全没点破:便签服务的 notes map 被多 goroutine(每请求一条)共享, 没有 mutex——教学程序在并发写时会 data race,书里未提(第 3 章的 -race 能抓到)。
- context value 的 key 冲突风险(不同库用同名字符串 key)没讲, 官方推荐私有类型 key,示例用的是裸字符串——照抄会埋雷。
- 代理的 TLS 拦截(中间人)与 CONNECT 之外的语义没展开;HTTP/2 在代理下的 退化也没提。
- Pushgateway 的正确用法警示(推了不清理会留僵尸指标)没讲——只演示了推。
13. 可带走的¶
- 服务器三件套:ServeMux(方法前缀路由)+ handler + ListenAndServe; 每请求自动 goroutine。
- middleware = 包装 handler 的函数;日志、认证、限流、恢复全是一个形状。
defer resp.Body.Close();客户端要自定义头/超时用 NewRequest + Client.Do。- pprof:匿名 import + 独立端口;
go tool pprof -seconds=N URL+ ab 灌流; 采样 >100% 是多核并发的证据,HTTP 服务器的瓶颈常在「等」。 - httptest:不发真服务器的 handler 测试,表格驱动照旧适用。
- context 三用具 + 两个合同:
defer cancel()、数据分「请求级进 context、 应用级进结构体」。 - 优雅关停公式:signal → context cancel → 停接新客 → WaitGroup 等完在途。
- 写 CLI 客户端:先纯函数 API 层,再套 Cobra;必填 flag 用 MarkFlagRequired。
- Prometheus API 两个端点(query/query_range)+ label 过滤; CLI 替用户拼 PromQL,一次翻译一层。
14. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| net/http 与并发模型 | The net/http package | text/94-ch09.txt:21(搜「powerful and high-level」) |
| middleware 模式 | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:70(搜「loggingMiddleware」) · :171(搜「GET /」) |
| 五端点与 curl 实测 | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:201(搜「Welcome to the Go Web Service」) · :233(搜「Completed in 32.542µs」) |
| HTTP 客户端三档 | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:274(搜「getRequest」) · :299(搜「getWithHeaders」) |
| pprof 接入与七 profile | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:408(搜「pprof.Profiles()」) · :447(搜「localhost:6060」) |
| ab 灌流与 141.45% | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:472(搜「-seconds=30」) · :480(搜「141.45%」) · :502(搜「syscall.syscall」) |
| httptest 测试 | Creating a simple HTTP server with middleware | text/95-fm-creating-a-simple-http-server-with-middleware.txt:549(搜「TestEndpoints」) · :599(搜「192.0.2.1:1234」) |
| context 三用具与教练比喻 | The context package | text/96-fm-the-context-package.txt:20(搜「carry deadlines」) · :36(搜「doWork」) · :50(搜「WithTimeout」) |
| WithValue 的克制令 | The context package | text/96-fm-the-context-package.txt:110(搜「Why not use a structure」) · :117(搜「backpack」) |
| HTTP 408 演示 | The context package | text/96-fm-the-context-package.txt:124(搜「slowOperation」) · :135(搜「r.Context()」) |
| 代理架构与优雅关停 | Creating a proxy server | text/97-fm-creating-a-proxy-server.txt:22(搜「Proxy structure」) · :29(搜「Shutdown」) · text/97-fm-creating-a-proxy-server.txt:40(搜「shared context.WithCancel」) |
| 代理实测(CONNECT 隧道) | Creating a proxy server | text/98-fm-developing-a-web-service.txt:5(搜「curl -x」) · :13(搜「CONNECT //www.mtsoukalos.eu:443」) |
| 便签服务五端点 | Developing a web service | text/98-fm-developing-a-web-service.txt:60(搜「POST /notes」) · :74(搜「GET /notes」) · :108(搜「curl -X POST」) |
| REST 设计主张 | Developing a web service | text/98-fm-developing-a-web-service.txt:42(搜「Carefully designing」) |
| 纯函数客户端→Cobra | Developing a web service | text/98-fm-developing-a-web-service.txt:233(搜「directly transferred」) · text/99-fm-developing-a-client-for-the-web-service.txt:119(搜「var addCmd」) |
| Prometheus API 与 label 拼接 | Reading data from Prometheus | text/100-fm-reading-data-from-prometheus.txt:128(搜「listMetrics」) · :87(搜「InstantResponse」) · :222(搜「cpu=\"0\"」) |
| 造数与 Pushgateway 回填 | Reading data from Prometheus | text/100-fm-reading-data-from-prometheus.txt:44(搜「backfill」) · :65(搜「backfill-days 7」) |
-
出处:「Creating a simple HTTP server with middleware」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:171,搜「GET /」;:188,搜「method-aware routing」)。GET/POST /echo 分立两条路由见第 190–191 行。 ↩ -
出处:「The net/http package」(
text/94-ch09.txt:24,搜「integration with Go's concurrency model」)。 ↩ -
出处:「Creating a simple HTTP server with middleware」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:70,搜「loggingMiddleware」;:40,搜「function returning another http.HandlerFunc」;:233,搜「Completed in 32.542µs」)。 ↩↩ -
出处:「Creating a simple HTTP server with middleware」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:90,搜「handleRoot」;:127,搜「Invalid JSON body」;:200,搜「Welcome to the Go Web Service」)。writeJSON 的 Content-Type + Encode 见第 56–63 行。 ↩ -
出处:「Creating an HTTP client」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:274,搜「getRequest」;:280,搜「defer resp.Body.Close」;:299,搜「getWithHeaders」;:375,搜「Go-http-client/1.1」)。 ↩ -
出处:「Profiling HTTP applications」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:401,搜「anonymously」;:436,搜「port 6060」)。 ↩↩ -
出处:「Profiling HTTP applications」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:416,搜「allocs」;:432,搜「only exists when you explicitly trigger」)。七 profile 逐一解释见第 423–431 行。 ↩ -
出处:「Profiling HTTP applications」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:480,搜「141.45%」;:502,搜「syscall.syscall」;:518,搜「45.28%」;:526,搜「spends much of its time waiting for requests」)。 ↩↩ -
出处:「Testing HTTP applications」(
text/95-fm-creating-a-simple-http-server-with-middleware.txt:549,搜「TestEndpoints」;:587,搜「ResponseRecorder」;:599,搜「192.0.2.1:1234」)。 ↩ -
出处:「The context package」(
text/96-fm-the-context-package.txt:20,搜「carry deadlines」;:25,搜「WithCancel」;:36,搜「doWork」;:50,搜「WithTimeout」;:51,搜「Always call cancel」)。教练吹哨比喻见第 9–19 行。 ↩ -
出处:「The context package」(
text/96-fm-the-context-package.txt:124,搜「slowOperation」;:135,搜「r.Context()」;:160,搜「408」)。 ↩ -
出处:「The context package」(
text/96-fm-the-context-package.txt:110,搜「Why not use a structure」;:117,搜「backpack」)。「configuration data, database connections, or large objects should live in well-defined structs」在同段。 ↩↩ -
出处:「Creating a proxy server」(
text/97-fm-creating-a-proxy-server.txt:22,搜「Proxy structure」;:26,搜「cancellable context」;:29,搜「Shutdown」;:31,搜「context cancellation and WaitGroup」)。 ↩↩ -
出处:「Creating a proxy server」(
text/98-fm-developing-a-web-service.txt:5,搜「curl -x」;:13,搜「CONNECT //www.mtsoukalos.eu:443」;:18,搜「Shutting down proxy server」)。 ↩ -
出处:「Developing a web service」(
text/98-fm-developing-a-web-service.txt:60,搜「POST /notes」;:98,搜「loadNotes() and saveNotes()」)。curl 四连实测见第 108–117 行。 ↩ -
出处:「Developing a web service」(
text/98-fm-developing-a-web-service.txt:42,搜「Carefully designing」;:51,搜「separate concerns」)。 ↩ -
出处:「Developing a web service」(
text/98-fm-developing-a-web-service.txt:233,搜「directly transferred」)与「Developing a client for the web service」(text/99-fm-developing-a-client-for-the-web-service.txt:13,搜「cobra init」;:119,搜「var addCmd」;:144,搜「MarkFlagRequired」)。 ↩↩ -
出处:「Developing a client for the web service」(
text/99-fm-developing-a-client-for-the-web-service.txt:163,搜「Book Idea」;:6,搜「404 Not Found」)。 ↩ -
出处:「Reading data from Prometheus」(
text/100-fm-reading-data-from-prometheus.txt:21,搜「communicates primarily over HTTP」;:24,搜「PromQL」)。 ↩ -
出处:「Reading data from Prometheus」(
text/100-fm-reading-data-from-prometheus.txt:87,搜「InstantResponse」;:114,搜「two elements」;:222,搜「cpu=\"0\"」;:236,搜「without requiring the web UI」)。 ↩ -
出处:「Reading data from Prometheus」(
text/100-fm-reading-data-from-prometheus.txt:44,搜「backfill」;:52,搜「sinusoidal」;:65,搜「backfill-days 7」;:169,搜「--list」;:195,搜「--metric」)。 ↩