23-网络编程:从 HTTP 客户端到可维护服务端

🔖 Go 语言学习教程 · 第 23 篇

📌 本系列基于 Go 1.23 编写,代码兼容 Go 1.21+

全文约 4500 字 | 阅读约 15 分钟


欢迎来到第二十三篇 👋

Go 的 net/http 标准库已经覆盖了构建 HTTP 服务所需的大部分基础能力:客户端、服务端、请求上下文、路由、超时和连接复用都可以从标准库开始。

本篇会搭建一个小型 HTTP 服务,学习请求和响应、路由匹配、客户端超时、中间件雏形,以及 HTTP/2 和优雅关闭的基本概念。


✅ 上一篇练习题答案

以下是上一篇练习题的参考答案,欢迎在评论区分享你的解法~

1. 带标签的用户响应

go 复制代码
type UserResponse struct {
	ID          int    `json:"id"`
	DisplayName string `json:"display_name"`
	Password    string `json:"-"`
}

调用 json.Marshal 后,密码字段不会出现在输出中。

2. 大整数精度实验

使用 json.Decoder 调用 UseNumber(),从 map[string]any 中取出 json.Number,再用 strconv.ParseInt 或 ParseUint 转换。不要先转成 float64。

3. omitempty 的限制

普通 int 的零值是 0,无法区分"没有传"和"明确传入 0"。需要这种区分时,把字段声明为 *int,用 nil 表示缺失。

4. 流式 Decoder

循环调用 Decode,先判断 errors.Is(err, io.EOF),再处理其他错误;成功解码后立即处理事件,不必等整个输入读完。


🧐 一、HTTP 请求和响应

一个 HTTP 服务端处理函数接收 http.ResponseWriter 和 *http.Request:

go 复制代码
func hello(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
		return
	}

	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("Hello, Go"))
}

响应头要在第一次写状态码或响应体之前设置。调用 Write 时,如果没有显式调用 WriteHeader,服务器通常会自动写入 200。

JSON 响应可以统一封装:

go 复制代码
func writeJSON(w http.ResponseWriter, status int, value any) error {
	data, err := json.Marshal(value)
	if err != nil {
		return err
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_, err = w.Write(data)
	return err
}

真实项目还要统一错误响应格式,避免一部分接口返回字符串,另一部分返回对象。


📖 二、使用 ServeMux 路由

可以使用标准库的 ServeMux 注册处理器:

go 复制代码
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)

在 Go 1.22 及以上,模式可以包含 HTTP 方法和路径通配符。处理函数中可以读取路径参数:

go 复制代码
func getUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	_ = writeJSON(w, http.StatusOK, map[string]string{"id": id})
}

如果项目需要兼容更早版本,可以使用第三方路由器或手动解析路径,但要明确版本边界。路由层应该负责匹配和参数提取,不要把数据库查询、业务规则都写进处理函数。

方法和路径优先级

更具体的路径通常优先于更宽泛的路径。注册路由时,应避免互相重叠导致维护者难以判断最终匹配结果。


🌐 三、HTTP 客户端

最简单的 GET:

go 复制代码
resp, err := http.Get("https://example.com")
if err != nil {
	return err
}
defer resp.Body.Close()

body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
	return err
}
fmt.Println(resp.StatusCode, string(body))

生产环境不建议直接使用没有超时策略的默认客户端。可以创建带超时的 http.Client:

go 复制代码
client := &http.Client{
	Timeout: 5 * time.Second,
}

需要携带请求体、请求头或 Context 时,使用 http.NewRequestWithContext:

go 复制代码
payload := strings.NewReader(`{"name":"小明"}`)
req, err := http.NewRequestWithContext(
	ctx,
	http.MethodPost,
	"https://example.com/users",
	payload,
)
if err != nil {
	return err
}
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)

响应体必须关闭,否则连接可能无法复用,长期运行会造成资源耗尽。


💡 四、服务端超时

服务端也需要配置超时:

go 复制代码
server := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       10 * time.Second,
	WriteTimeout:      15 * time.Second,
	IdleTimeout:       60 * time.Second,
}

不同超时保护不同阶段:

配置 作用
ReadHeaderTimeout 等待请求头,减少慢速请求占用
ReadTimeout 读取完整请求的最长时间
WriteTimeout 写响应的最长时间
IdleTimeout Keep-Alive 空闲连接时间

上传大文件、长轮询和流式响应可能需要单独设计,不能盲目套用同一个超时值。


🧩 五、中间件模式

中间件本质是接收一个 Handler,再返回一个增强后的 Handler:

go 复制代码
type Middleware func(http.Handler) http.Handler

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		fmt.Printf("%s %s %s\n", r.Method, r.URL.Path, time.Since(start))
	})
}

组合中间件:

go 复制代码
handler := logging(mux)
server := &http.Server{
	Addr:    ":8080",
	Handler: handler,
}

中间件可以用于日志、鉴权、恢复 panic、请求 ID 和限流。注意中间件顺序会影响行为,例如恢复中间件通常应包在最外层,才能捕获内部处理器的 panic。


🔒 六、请求体、状态码与输入校验

接收 JSON 时,限制请求体大小:

go 复制代码
limited := http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()

decoder := json.NewDecoder(limited)
decoder.DisallowUnknownFields()

var request CreateUserRequest
if err := decoder.Decode(&request); err != nil {
	http.Error(w, "invalid json", http.StatusBadRequest)
	return
}

状态码要表达清晰的语义:

状态码 常见场景
200 查询或更新成功
201 创建资源成功
204 成功但无响应体
400 请求格式或参数错误
401 缺少或无效身份
403 已识别但没有权限
404 资源不存在
500 服务端未预期错误

不要把所有错误都返回 200 再在 JSON 中写一个 code。HTTP 状态码本身就是客户端和监控系统可以理解的协议。


🛑 七、优雅关闭

服务停止时,应该停止接收新请求,并等待已有请求完成:

go 复制代码
server := &http.Server{
	Addr:    ":8080",
	Handler: mux,
}

go func() {
	if err := server.ListenAndServe(); err != nil &&
		!errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()

stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
	log.Println("关闭服务失败:", err)
}

Shutdown 需要和部署平台的停止信号配合。超时后仍未结束的请求会被强制中断,因此业务代码也要正确响应请求 Context。


🚀 八、HTTP/2 基础

Go 的 HTTPS 服务通常可以协商使用 HTTP/2。HTTP/2 支持多路复用、头部压缩和流优先级等能力,多个请求可以共享一个连接。

应用层代码通常仍使用 net/http 的请求和响应模型,不需要为每条 HTTP/2 stream 手动写调度逻辑。需要注意的是:

  • TLS 配置和证书部署会影响协商结果;
  • 代理和负载均衡可能改变客户端到服务端的协议;
  • 不要把 HTTP/2 的多路复用等同于业务层无限并发;
  • 仍需配置请求限制、超时和连接数策略。

📝 九、本篇小结

要点 总结
Handler 接收请求并写出响应
ServeMux 负责路由匹配和路径参数
HTTP Client 配置超时,关闭响应体
服务端超时 防止慢请求长期占用资源
中间件 以 Handler 包装 Handler,复用横切逻辑
输入边界 限制请求体并校验 JSON
优雅关闭 使用 Shutdown 等待请求完成
HTTP/2 多路复用,但仍需业务限流和超时

💬 一句话总结:

一个可靠的 HTTP 服务不仅要能返回结果,还要处理路由、超时、输入大小、资源关闭、中间件和优雅退出。


✏️ 十、练习题

  1. 🖥️ 动手实践 :使用 ServeMux 实现 GET /healthz 和 GET /users/{id}。
  2. 🔧 客户端超时:实现一个带 Context 和 3 秒超时的 HTTP GET 函数。
  3. 🤔 思考题 :为什么 http.Response.Body 必须关闭?不关闭会带来什么问题?
  4. 📚 服务端实践:为一个 HTTP 服务增加日志中间件和优雅关闭逻辑。

💡 练习题答案会在下一篇中公布,欢迎在评论区分享你的答案~


关注我,一起学编程

如果本文对你有帮助,欢迎 点赞、在看、转发、关注。

相关推荐
敲代码的玉米C2 小时前
右边留给终端,左边继续翻代码:给砚加锁定列
go
敲代码的玉米C2 小时前
设置导进来了,界面却没变:配置不能只管写文件
go
人人都是软件工程师2 小时前
24-数据库操作:database/sql、连接池与事务
go
敲代码的玉米C2 小时前
从终端敲一行命令,把文件送进已经打开的砚
go
敲代码的玉米C2 小时前
两个提交到底差在哪:给砚补上比较和补丁导出
go
敲代码的玉米C2 小时前
终端里的工具改了文件,砚不能还停在旧内容上
go
敲代码的玉米C2 小时前
功能藏得有点深了,给砚加一个能跟着做的教程
go
敲代码的玉米C2 小时前
一个目录里放了几个仓库,提交按钮到底该操作谁
go
敲代码的玉米C2 小时前
想在 Markdown 预览里改两个字,最后补了一套块编辑
go