GET 请求
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
// A local test server stands in for a real API (the editor has no internet access).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "you asked for %s", r.URL.Path)
}))
defer srv.Close()
resp, err := http.Get(srv.URL + "/status")
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("reading body:", err)
return
}
fmt.Println(resp.StatusCode, resp.Header.Get("Content-Type"))
fmt.Println(string(body))
}Master Go the hands-on way
Interactive lessons, real projects, and instant feedback.
START NOW
本页的每个示例都会用 httptest.NewServer 启动自己的服务器,并通过 localhost 上真实的 TCP 连接调用它。把 srv.URL 换成 https://api.example.com 这样的真实地址,客户端代码完全不用改。
这里体现了三条规则:
先检查 err。 它报告的是根本没拿到响应的失败:DNS 错误、连接被拒绝、超时、TLS 问题。
检查完错误后立即 defer resp.Body.Close()。 响应体会让连接保持打开。不关闭响应体就会泄漏连接。
读取或解码响应体。 文本用 io.ReadAll,JSON 用 json.NewDecoder(resp.Body).Decode(&v)。
状态码不是错误
只要服务器返回了响应,包括 404 和 500,http.Get 都会返回 err == nil。状态码需要你自己检查:
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func getUser(baseURL string, id int) (User, error) {
resp, err := http.Get(fmt.Sprintf("%s/users/%d", baseURL, id))
if err != nil {
return User{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read a little of the body: APIs usually explain the error there.
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return User{}, fmt.Errorf("get user %d: %s: %s", id, resp.Status, bytes.TrimSpace(msg))
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return User{}, fmt.Errorf("decode user %d: %w", id, err)
}
return u, nil
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "1" {
http.Error(w, "no such user", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id": 1, "name": "Grace"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
fmt.Println(getUser(srv.URL, 1))
fmt.Println(getUser(srv.URL, 2))
}
io.LimitReader 限制你从错误响应体中读取的数据量,这样行为异常的服务器就没法让你把几兆字节的内容塞进一条错误信息里。很多 API 用 201、202 或 204 表示成功;如果任何 2xx 都算成功,就检查 resp.StatusCode >= 200 && resp.StatusCode < 300。
超时:真实流量绝不要用默认客户端
http.Get、http.Post 和 http.DefaultClient 没有整体超时 (默认的 transport 只限制建立连接和 TLS 握手的时间)。如果服务器接受了连接却一直不回应,你的 goroutine 就会永远阻塞。请创建你自己的客户端:
package main
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"time"
)
func main() {
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(2 * time.Second):
case <-r.Context().Done(): // stop when the client gives up
}
}))
defer slow.Close()
client := &http.Client{Timeout: 100 * time.Millisecond}
start := time.Now()
_, err := client.Get(slow.URL)
fmt.Println("gave up quickly:", time.Since(start) < time.Second)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
fmt.Println("timeout error:", netErr.Timeout())
}
}
Client.Timeout 覆盖整个交互过程:建立连接、重定向以及读取响应体。它返回的错误是一个 *url.Error,其 Timeout() == true,errors.As 可以通过 net.Error 接口找到它。只创建一个客户端并在各处复用;http.Client 可以安全地被并发使用。连接池位于它的 Transport 中,而不在 Client 里:每个请求新建一个 http.Client{} 仍然共享 http.DefaultTransport 及其连接池,但每个请求新建一个 http.Transport,每次都会从一个空的连接池开始。
带 context 和请求头的请求
http.NewRequestWithContext 构建一个请求,你可以先对它做定制,再用 client.Do 发送。context 在客户端超时之外,又为单个请求提供了截止时间或取消能力;在服务器中,它还能把发出的调用与传入请求的生命周期绑定在一起。
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"time"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "auth=%q agent=%q q=%q page=%q",
r.Header.Get("Authorization"), r.Header.Get("User-Agent"),
r.URL.Query().Get("q"), r.URL.Query().Get("page"))
}))
defer srv.Close()
client := &http.Client{Timeout: 10 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Build the query string safely: url.Values escapes spaces and symbols.
params := url.Values{}
params.Set("q", "go & rust")
params.Set("page", "2")
target := srv.URL + "/search?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
fmt.Println(err)
return
}
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("User-Agent", "docs-example/1.0")
resp, err := client.Do(req)
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
fmt.Println("encoded query:", params.Encode())
}
把用户输入直接拼接进 URL("?q=" + q)遇到 &、# 和空格就会出错。url.Values 及其 Encode 方法会负责转义,并对键排序。路径片段则使用 url.PathEscape。
POST JSON
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
type Order struct {
Item string `json:"item"`
Quantity int `json:"quantity"`
}
type Created struct {
ID int `json:"id"`
Status string `json:"status"`
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var o Order
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "want JSON", http.StatusUnsupportedMediaType)
return
}
if err := json.NewDecoder(r.Body).Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(Created{ID: 101, Status: fmt.Sprintf("%d x %s", o.Quantity, o.Item)})
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
payload, err := json.Marshal(Order{Item: "coffee", Quantity: 2})
if err != nil {
fmt.Println(err)
return
}
resp, err := client.Post(srv.URL+"/orders", "application/json", bytes.NewReader(payload))
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
msg, _ := io.ReadAll(resp.Body)
fmt.Println("unexpected status:", resp.Status, string(msg))
return
}
var c Created
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
fmt.Println(err)
return
}
fmt.Printf("%d %+v\n", resp.StatusCode, c)
}
client.Post(url, contentType, body) 是发送带该 Content-Type 请求头的请求的快捷方式。其他情况(认证请求头、PUT、PATCH、DELETE)就用 http.NewRequestWithContext 指定方法,再调用 client.Do。提交表单时,client.PostForm(url, url.Values{...}) 会编码这些值并设置 application/x-www-form-urlencoded。
连接复用
只有当你把上一个响应体读到末尾并关闭它时,客户端才会复用这个 TCP 连接。如果你提前停止读取(比如遇到错误状态码之后),而又要向同一个主机发送很多请求,就在关闭前把它读空:
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
如果响应体可能非常大,就改为通过 io.LimitReader 读空,或者接受丢掉这个连接。
重定向
客户端会自动跟随最多 10 次重定向,resp.Request.URL 会告诉你最终到达了哪里。如果想在第一次重定向时就停下,比如为了读取 Location 响应头,就设置 CheckRedirect:
client := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // return the 3xx response itself
},
}
常见错误
在生产环境中使用 http.Get 或 http.DefaultClient。 没有超时。请创建一个设置了 Timeout 的 http.Client。
忘记 resp.Body.Close()。 连接会不断泄漏,直到请求开始失败。
在检查 err 之前就关闭响应体。 当 err 不为 nil 时,resp 是 nil,resp.Body.Close() 会引发 panic。
把 err == nil 当成成功。 要检查 resp.StatusCode。
每个请求都新建一个 http.Transport。 每个 transport 都有自己的连接池,所以每个请求都会打开一个新连接(还要做一次新的 TLS 握手)。共用一个 transport,通常是通过一个共享的 http.Client。
手动拼接查询字符串。 使用 url.Values。