Menu

Golang HTTP 服务器:net/http 路由、JSON 与状态码

如何用 Go 标准库 net/http 包搭建 Web 服务器:处理器、支持方法和路径通配符的 ServeMux 路由(Go 1.22)、JSON 响应、状态码、中间件、超时和优雅关闭。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

几行代码搭一个服务器

处理器(handler)是一个函数,它接收请求并写出响应。ServeMux 负责把请求路由到对应的处理器。

编辑器无法接受来自你浏览器的连接,所以本页的示例都用 httptest.NewServer 启动服务器,并在同一个程序里调用它。在真实程序中,最后那部分会换成一行代码,它会阻塞并一直提供服务:

log.Fatal(http.ListenAndServe(":8080", mux))

然后 curl localhost:8080/hello/gopher 会打印 Hello, gopher!。ListenAndServe 只在出错时才返回(比如端口已被占用),所以它被包在 log.Fatal 里。

每个请求都在自己的 goroutine 中运行。处理器之间共享的任何东西,比如一个 map 或一个计数器,都需要一个互斥锁。

处理器

任何带有 ServeHTTP(http.ResponseWriter, *http.Request) 方法的类型都是 http.Handler。http.HandlerFunc 把普通函数适配成这个接口,mux.HandleFunc 会替你完成这个转换。当处理器需要依赖时,用结构体作为处理器会很方便:

type API struct {
	db *sql.DB
}

func (a *API) listItems(w http.ResponseWriter, r *http.Request) { /* uses a.db */ }

mux.HandleFunc("GET /items", api.listItems)

*http.Request 为你提供:

字段或方法内容
r.MethodGET、POST、...
r.URL.Path路径,如 /items/42
r.PathValue("id")路由模式中的通配符(Go 1.22)
r.URL.Query().Get("q")查询字符串参数
r.Header.Get("Authorization")请求头
r.Body请求体,一个 io.ReadCloser(由服务器负责关闭)
r.FormValue("name")表单字段或查询参数
r.Context()客户端断开连接时会被取消的 context

路由模式(Go 1.22)

从 Go 1.22 起,ServeMux 的模式形如 [METHOD ][HOST]/[PATH],路径中可以包含通配符。

模式匹配
"/items/"/items/ 及其下的所有路径(末尾斜杠 = 前缀)
"/items"只匹配 /items
"GET /items/{id}"/items/42 上的 GET(以及 HEAD);r.PathValue("id") == "42"
"POST /items"只匹配 /items 上的 POST
"/files/{path...}"/files/a/b/c;path 为 "a/b/c"
"/{$}"只匹配 /,而不是所有路径
"/"其他模式都不匹配的所有路径

当两个模式都匹配时,更具体的那个胜出,所以 /items/new 优先于 /items/{id}。如果两者都不比对方更具体,比如 /items/{id} 和 /{kind}/new(两者都匹配 /items/new),注册第二个时就会 panic,报错信息会写出这两个模式。如果路径匹配但方法不匹配,mux 会自动返回 405 Method Not Allowed,并附上 Allow 响应头,你不用写任何代码。

"/" 模式会匹配所有路径。有人用 "/" 注册首页,结果发现它对每个未知 URL 都返回 200,这常常让人意外。首页请用 "GET /{$}"。

这些模式要求 go.mod 中写的是 go 1.22 或更高版本。如果版本行更旧,mux 会退回到旧的行为:它把 "GET /items" 当成一个主机名加一个路径,于是这条路由永远不会匹配,所有发往 /items 的请求都会得到 404。

一个小型 JSON API

最后一个请求用的是 DELETE,没有任何路由接受它,于是 mux 自动返回 405,并带上 Allow: GET, HEAD:这些是为该路径注册过的方法(GET 路由也接受 HEAD)。

状态码与写入顺序

一个响应有三个部分,必须按顺序写入:响应头、状态码、响应体。

  1. w.Header().Set(...) 修改响应头。它只在状态码发送之前有效。
  2. w.WriteHeader(code) 发送状态行和响应头。
  3. w.Write(...)(或者 fmt.Fprint(w, ...),或者一个编码器)发送响应体。如果还没调用过 WriteHeader,第一次 Write 会先发送 200 OK。

由此可知:

  • 响应体开始写入之后再设置响应头,不会有任何效果。
  • 调用两次 WriteHeader 会记录 http: superfluous response.WriteHeader call 日志,并保留第一次的状态码。
  • 返回错误响应之后要 return。http.Error 不会终止你的处理器,它之后的代码会继续往同一个响应里写内容。

请使用具名常量(http.StatusOK、http.StatusCreated、http.StatusBadRequest、http.StatusUnauthorized、http.StatusNotFound、http.StatusInternalServerError),而不是直接写数字。http.StatusText(404) 返回 "Not Found"。

中间件

中间件是一个接收处理器并返回处理器的函数,它在调用下一个处理器之前或之后做一些事情:

对同一个请求,log: 这一行总是出现在 client got: 之前。处理器所在的 goroutine 在返回前打印它,而服务器要等处理器返回后才会完成响应。

statusRecorder 嵌入了真正的 ResponseWriter,只重写了 WriteHeader,这是在中间件中获取状态码的标准做法。

生产环境配置

http.ListenAndServe 使用的服务器没有任何超时,所以一个很慢的客户端可以无限期地占用连接。凡是暴露在互联网上的服务,都要显式配置一个 http.Server,并让它优雅地关闭:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       10 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       120 * time.Second,
}

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

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // wait for Ctrl+C or a SIGTERM from the orchestrator

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // finish in-flight requests
	log.Println("shutdown:", err)
}

Shutdown 会停止接受新连接,并等待正在处理的请求完成,最长等到 context 的截止时间。Shutdown 一开始,ListenAndServe 就会返回 http.ErrServerClosed,所以这个错误不被当作失败。对于运行时间较长的处理器,要把 r.Context() 传下去,这样客户端离开时它们就会停止。

提供静态文件只需要一行:mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))。

常见错误

  • 调用 http.Error 之后没有返回。 处理器会继续运行,往响应里写入更多内容。
  • 写入响应体之后才设置响应头。 这些响应头会被悄无声息地丢弃。
  • 把首页注册在 "/" 上。 它会变成所有未知路径的兜底路由。请用 "/{$}"。
  • 处理器之间共享状态却不加锁。 请求是并发运行的。
  • 在生产环境中使用默认服务器。 它没有超时;请在 http.Server 上设置超时。
  • 路由模式不生效。 如果 "GET /x/{id}" 从来不匹配,检查 go.mod 中是否写的是 go 1.22 或更高版本。

常见问题

Go 语言怎么创建一个简单的 Web 服务器?

注册一个处理器,然后开始监听:http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") }),接着 log.Fatal(http.ListenAndServe(":8080", nil))。标准库的服务器可以直接用于生产环境;它支持 HTTP/1.1、基于 TLS 的 HTTP/2、keep-alive,并为每个连接分配一个 goroutine。

Go net/http 怎么获取路径参数?

从 Go 1.22 起,ServeMux 的模式可以包含通配符:注册 mux.HandleFunc("GET /items/{id}", h),然后在处理器里用 r.PathValue("id") 读取值。末尾的 {path...} 匹配路径的剩余部分。在 1.22 之前,你得自己拆分 r.URL.Path,或者使用 chi 之类的路由库。

用 Go 写 REST API 需要 Gin 这样的框架吗?

不需要。从 Go 1.22 起,net/http 可以按方法和路径参数路由,而这正是过去人们使用路由库的主要原因。用 encoding/json 处理请求体,再写几个小的中间件函数负责日志和认证,标准库就足以应付大多数 API。框架提供的是请求绑定、校验之类的便利功能。

Go HTTP 处理器中怎么设置状态码?

在写入响应体之前调用 w.WriteHeader(http.StatusCreated)。如果先写了响应体,Go 会自动发送 200 OK,之后的 WriteHeader 会被忽略,并记录一条 superfluous response.WriteHeader call 日志。响应头也要在 WriteHeader 之前用 w.Header().Set 设置;返回错误时,http.Error(w, msg, code) 会把这些全部做好。

Coddy 编程语言插图

和 Coddy 一起学编程

开始