Skip to content

Go 网络编程:从 TCP 到生产级 HTTP 服务

面向有 Java 经验的开发者,基于 Go 1.26。

Go 的网络库分层很清楚:net.Conn 表示双向字节流,net.Listener 接受连接,net/http 在它们之上实现 HTTP 协议,crypto/tls 再提供安全传输。goroutine 让“一连接一 goroutine”或“一请求一 goroutine”写起来很自然,但并发代码容易写,并不意味着网络程序天然可靠。到了生产环境,真正难处理的是超时、取消、半关闭、连接复用、响应体释放、限流、优雅停机和不可信输入。

下面从地址和 socket 模型讲起,一步步写到具备超时、结构化错误、中间件和优雅停机的 HTTP 服务。示例尽量只使用标准库,以便看清协议边界和资源所有权。

目录

1. 先建立网络分层

可以把常用 API 看成:

text
应用协议            HTTP、RPC、自定义协议
协议实现            net/http
安全传输            crypto/tls
双向连接            net.Conn
监听与接入          net.Listener
地址与名称解析      net/netip、net、net/url
操作系统            socket、TCP/IP、UDP

net.Conn 的核心接口很小:

go
type Conn interface {
	Read(b []byte) (n int, err error)
	Write(b []byte) (n int, err error)
	Close() error
	LocalAddr() Addr
	RemoteAddr() Addr
	SetDeadline(t time.Time) error
	SetReadDeadline(t time.Time) error
	SetWriteDeadline(t time.Time) error
}

它同时实现 io.Readerio.Writer,所以 bufioio.Copyencoding/json 等工具可以直接工作在网络连接上。

网络 I/O 的基本事实:

  • TCP 是字节流,没有消息边界;
  • 一次 Write 不保证对端一次 Read 收到;
  • Read 返回数据和错误可以同时发生,先处理 n > 0 的数据;
  • goroutine 被阻塞在 I/O 上不会自动响应普通 channel;
  • 连接、response body、listener 都需要明确关闭;
  • 超时是每个外部调用的必要契约。

2. 地址、IP 与 DNS

2.1 netip.Addr

新代码处理 IP 值优先考虑 net/netip

go
addr, err := netip.ParseAddr("2001:db8::1")
if err != nil {
	return err
}

fmt.Println(addr.Is6())
fmt.Println(addr.IsPrivate())
fmt.Println(addr.Unmap())

netip.Addr 是紧凑、不可变且可比较的值,可以直接作为 map key:

go
counts := map[netip.Addr]int{}

带端口:

go
ap, err := netip.ParseAddrPort("[2001:db8::1]:443")
fmt.Println(ap.Addr(), ap.Port())

拼接主机和端口不要手写冒号:

go
endpoint := net.JoinHostPort(host, port)

IPv6 地址本身含冒号,必须用方括号包裹。JoinHostPort 会正确处理。

2.2 前缀

go
prefix := netip.MustParsePrefix("10.0.0.0/8")
fmt.Println(prefix.Contains(netip.MustParseAddr("10.1.2.3")))

Go 1.26 增加 netip.Prefix.Compare,便于对前缀建立稳定顺序。

2.3 DNS

go
resolver := net.DefaultResolver
addrs, err := resolver.LookupNetIP(ctx, "ip", "example.com")

DNS 结果可能有多个 IPv4/IPv6 地址,顺序和生命周期都不是永久契约。不要把启动时解析出的一个 IP 永久缓存。

net.Dialer 会负责名称解析和地址尝试,普通客户端通常不需要先手工 Lookup

go
dialer := net.Dialer{Timeout: 3 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", "example.com:443")

3. net.Conn 与超时

3.1 Deadline 是绝对时间

go
conn.SetReadDeadline(time.Now().Add(5 * time.Second))

deadline 到期后,未来操作仍然会超时,直到设置新的时间或用零值清除:

go
conn.SetReadDeadline(time.Time{})

它不是“每次读取自动获得 5 秒”。长连接协议通常在每次成功读写或每个消息周期刷新 deadline。

3.2 识别超时

go
if err != nil {
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		// 超时
	}
}

更上层的 HTTP/context API 常返回 context.DeadlineExceeded,应使用 errors.Is

3.3 context 不能直接中断任意 Read

DialContext 能在拨号阶段响应 context。连接建立后,普通 conn.Read 不会因为某个 context 被取消就自动返回。常见做法:

  • 取消时关闭连接;
  • 根据 context deadline 设置连接 deadline;
  • 使用本身支持 context 的更高层 API。

不要额外启动一个 goroutine 调用 Read,主流程超时后直接丢下它;这会泄漏 goroutine 和连接。

4. TCP 服务端

一个最小但有资源边界的 echo server:

go
func serveTCP(ctx context.Context, addr string) error {
	var lc net.ListenConfig
	ln, err := lc.Listen(ctx, "tcp", addr)
	if err != nil {
		return fmt.Errorf("listen %s: %w", addr, err)
	}
	defer ln.Close()

	go func() {
		<-ctx.Done()
		_ = ln.Close() // 让 Accept 返回
	}()

	var wg sync.WaitGroup
	defer wg.Wait()

	for {
		conn, err := ln.Accept()
		if err != nil {
			if ctx.Err() != nil {
				return nil
			}
			var ne net.Error
			if errors.As(err, &ne) && ne.Temporary() {
				continue
			}
			return fmt.Errorf("accept: %w", err)
		}

		wg.Go(func() {
			defer conn.Close()
			handleConn(ctx, conn)
		})
	}
}

处理连接:

go
func handleConn(ctx context.Context, conn net.Conn) {
	reader := bufio.NewReader(conn)
	writer := bufio.NewWriter(conn)

	for {
		if deadline, ok := ctx.Deadline(); ok {
			_ = conn.SetDeadline(deadline)
		} else {
			_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
		}

		line, err := reader.ReadString('\n')
		if len(line) > 0 {
			if _, werr := writer.WriteString("echo: " + line); werr != nil {
				return
			}
			if werr := writer.Flush(); werr != nil {
				return
			}
		}
		if err != nil {
			return
		}
	}
}

生产服务还需要:

  • 最大连接数;
  • 每连接最大并发或请求数;
  • 消息长度上限;
  • idle timeout;
  • panic 边界;
  • 连接级日志和指标;
  • 停机时等待现有连接的上限;
  • 防止慢客户端长期占用资源。

“每连接一个 goroutine”通常没有问题,问题是连接数无上限以及每个连接分配了无上限缓冲。

5. TCP 客户端与消息边界

连接:

go
dialer := net.Dialer{
	Timeout:   3 * time.Second,
	KeepAlive: 30 * time.Second,
}
conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:9000")

TCP 不保留 Write 边界:

go
conn.Write([]byte("hello"))
conn.Write([]byte("world"))

对端可能一次读到 helloworld,也可能拆成多次。因此应用协议必须自行定义 framing:

  1. 固定长度;
  2. 分隔符,如每行一条;
  3. 长度前缀;
  4. 自描述格式;
  5. 关闭写端表示消息结束。

长度前缀示例:

go
func writeFrame(w io.Writer, payload []byte) error {
	if len(payload) > 1<<20 {
		return errors.New("frame too large")
	}
	if err := binary.Write(w, binary.BigEndian, uint32(len(payload))); err != nil {
		return err
	}
	_, err := w.Write(payload)
	return err
}

func readFrame(r io.Reader) ([]byte, error) {
	var size uint32
	if err := binary.Read(r, binary.BigEndian, &size); err != nil {
		return nil, err
	}
	if size > 1<<20 {
		return nil, errors.New("frame too large")
	}
	buf := make([]byte, size)
	if _, err := io.ReadFull(r, buf); err != nil {
		return nil, err
	}
	return buf, nil
}

必须先验证长度再分配,否则攻击者可以发送巨大长度导致内存耗尽。

6. 半关闭、保活与异常断开

TCP 允许只关闭一个方向:

go
tcpConn, ok := conn.(*net.TCPConn)
if ok {
	_ = tcpConn.CloseWrite()
}

这可以表示“请求已发送完,但我还要读取响应”。CloseRead 则关闭读取方向。是否使用取决于应用协议。

KeepAlive 用于探测长时间无流量的失效连接,不是应用层健康检查,也不能替代请求超时。NAT、防火墙和负载均衡器还有自己的 idle timeout,长连接协议通常需要应用心跳。

对端异常退出时,本端可能直到下一次读写才发现。一次成功 Write 也不表示应用已经处理数据,只说明数据被本机网络栈接受。需要业务确认时必须在协议层增加 ACK 或请求响应。

7. UDP

UDP 是数据报协议,保留消息边界,但不保证:

  • 到达;
  • 顺序;
  • 不重复;
  • 不被截断;
  • 对端确实存在。

服务端:

go
pc, err := net.ListenPacket("udp", ":9001")
if err != nil {
	return err
}
defer pc.Close()

buf := make([]byte, 64<<10)
for {
	n, addr, err := pc.ReadFrom(buf)
	if err != nil {
		return err
	}
	payload := append([]byte(nil), buf[:n]...) // 异步处理前复制
	go handleDatagram(pc, addr, payload)
}

不要把复用的 buf[:n] 直接交给异步 goroutine,否则下一次读取会覆盖它。

UDP 适合 DNS、遥测、实时媒体或应用自己实现可靠性的协议。普通业务 RPC 不要因为“UDP 快”就跳过丢包、拥塞控制、MTU、放大攻击和顺序处理。

8. HTTP handler 模型

核心接口:

go
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

函数适配:

go
func health(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	io.WriteString(w, `{"status":"ok"}`)
}

handler := http.HandlerFunc(health)

每个请求通常在独立 goroutine 中处理,因此 handler 共享的对象必须满足并发安全。请求对象只属于当前请求;不要在 handler 返回后继续读它,也不要把 ResponseWriter 交给后台 goroutine。

依赖通过 struct 注入:

go
type API struct {
	users  UserService
	logger *slog.Logger
}

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

避免 package 级数据库、logger 和可变配置。显式依赖更容易测试和优雅关闭。

9. 路由、参数与 Go 1.22+ ServeMux

现代 http.ServeMux 支持方法和通配符:

go
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
mux.HandleFunc("GET /users/{id}", api.getUser)
mux.HandleFunc("POST /users", api.createUser)

读取路径参数:

go
idText := r.PathValue("id")
id, err := strconv.ParseInt(idText, 10, 64)

通配符:

go
mux.HandleFunc("GET /files/{path...}", serveFile)

主机也可进入 pattern:

go
mux.HandleFunc("GET api.example.com/health", health)

路由冲突在注册时可能 panic,应在启动阶段尽早暴露。对外 API 仍要明确:

  • 允许的方法;
  • 末尾斜杠和重定向行为;
  • URL 解码后的参数语义;
  • 404 与 405 格式;
  • API 版本策略。

10. 读取请求与写响应

10.1 限制请求体

go
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()

dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()

var input CreateUserRequest
if err := dec.Decode(&input); err != nil {
	writeProblem(w, http.StatusBadRequest, "invalid JSON")
	return
}

if err := dec.Decode(&struct{}{}); err != io.EOF {
	writeProblem(w, http.StatusBadRequest, "request must contain one JSON value")
	return
}

限制必须在读取前设置。只在解码后检查 len,巨大 body 已经消耗了资源。

10.2 响应头和状态码

WriteHeader 之后再修改 header 已经太晚:

go
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)

第一次 Write 若此前未调用 WriteHeader,会隐式发送 200。

业务代码不要在已经写了一半响应后才返回结构化错误。能失败的校验和查询尽量在写 header 前完成。流式响应必须接受“已经无法更换状态码”的协议事实。

10.3 JSON 错误

统一错误结构:

go
type Problem struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

客户端错误和内部错误分开:日志可以记录底层错误、request ID 和堆栈,但响应不要泄漏 SQL、文件路径、token 或内部主机名。

11. 中间件

中间件是 Handler -> Handler

go
type Middleware func(http.Handler) http.Handler

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = newRequestID()
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey{}, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

组合顺序很重要:

go
handler := recoverPanic(
	requestID(
		accessLog(
			mux,
		),
	),
)

外层先进入、最后退出。通常 request ID 要早于日志和 panic 恢复建立。

panic 恢复:

go
func recoverPanic(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if p := recover(); p != nil {
				slog.ErrorContext(r.Context(), "panic",
					"value", p,
					"stack", string(debug.Stack()),
				)
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

如果响应已部分写出,恢复中间件无法撤回内容。可以包装 ResponseWriter 缓冲小响应,但会影响 streaming、flush 和额外接口,不能无条件使用。

自定义 wrapper 要考虑 http.Flusherhttp.Hijackerio.ReaderFrom 等可选能力。现代代码也可使用 http.NewResponseController 访问 flush、deadline 等功能,避免手写大量类型断言。

12. HTTP Server 的生产配置

不要直接把 http.ListenAndServe(":8080", mux) 当作完整生产配置:

go
server := &http.Server{
	Addr:              ":8080",
	Handler:           handler,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       15 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       60 * time.Second,
	MaxHeaderBytes:    1 << 20,
}

各超时的含义并不完全相同:

  • ReadHeaderTimeout:读取请求头的上限,防 slowloris;
  • ReadTimeout:读取完整请求的上限,上传接口要谨慎;
  • WriteTimeout:写响应的上限,流式接口不能照搬普通值;
  • IdleTimeout:keep-alive 等待下一个请求的时间。

网络基础设施也应设置匹配的 upstream/downstream timeout。应用 30 秒、反向代理 10 秒会让应用继续做最终无人接收的工作。

Server 还支持:

  • BaseContext:为连接派生根 context;
  • ConnContext:把连接信息放进 context;
  • ConnState:观察连接状态,适合指标,不要做重业务;
  • ErrorLog:协议层错误日志。

13. 优雅停机

停机目标:

  1. 收到信号后停止接受新请求;
  2. 让就绪探针先失败,给负载均衡器摘流量时间;
  3. 等待在途请求完成;
  4. 超时后强制结束;
  5. 最后关闭数据库、消息客户端等共享依赖。
go
func run() error {
	mux := http.NewServeMux()
	// register routes

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	ctx, stop := signal.NotifyContext(
		context.Background(),
		os.Interrupt,
		syscall.SIGTERM,
	)
	defer stop()

	errCh := make(chan error, 1)
	go func() {
		err := server.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
			return
		}
		errCh <- nil
	}()

	select {
	case err := <-errCh:
		return err
	case <-ctx.Done():
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := server.Shutdown(shutdownCtx); err != nil {
		_ = server.Close()
		return fmt.Errorf("shutdown: %w", err)
	}
	return <-errCh
}

Go 1.26 中 signal.NotifyContext 使用带 cause 的取消,并让错误指明触发的信号。需要时可以通过 context.Cause(ctx) 记录。

Shutdown 不会替你等待自行启动的后台 goroutine,也不会自动处理被 hijack 的连接。后台 worker、WebSocket 等长连接需要应用自己的生命周期管理。

14. HTTP Client 与连接复用

14.1 复用 Client

http.Clienthttp.Transport 可并发使用,应长期复用:

go
transport := &http.Transport{
	Proxy:                 http.ProxyFromEnvironment,
	MaxIdleConns:          100,
	MaxIdleConnsPerHost:   20,
	MaxConnsPerHost:       50,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   5 * time.Second,
	ResponseHeaderTimeout: 10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

client := &http.Client{
	Transport: transport,
	Timeout:   15 * time.Second,
}

每次请求新建 client/transport 会丢失连接池,带来额外 DNS、TCP、TLS 成本,严重时耗尽临时端口。

14.2 请求 context

go
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
res, err := client.Do(req)

14.3 关闭响应体

go
res, err := client.Do(req)
if err != nil {
	return err
}
defer res.Body.Close()

为了让 HTTP/1.1 连接更可能复用,应把响应体读到 EOF;但不能无上限丢弃未知巨大 body:

go
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))

如果只需放弃一个已知很小的错误体,可以在限制内读取。超出限制时接受该连接不能复用。

14.4 状态码不是 transport error

client.Do 对 404、500 通常返回 err == nil。必须自行检查:

go
if res.StatusCode < 200 || res.StatusCode >= 300 {
	return decodeRemoteError(res)
}

15. context、超时与重试

超时可以分层:

  • 拨号超时;
  • TLS 握手超时;
  • 响应头超时;
  • 整个请求超时;
  • 上层业务 deadline。

最外层 context deadline 应沿调用链传递,不要在每层无条件重新给更长超时。

15.1 重试条件

只有满足这些条件时才考虑自动重试:

  • 操作幂等,或带可靠幂等键;
  • 错误是暂时性的;
  • 请求体可以重放;
  • 仍有总时间预算;
  • 有最大次数;
  • 使用指数退避和 jitter;
  • 尊重 Retry-After
  • 不把过载服务打得更重。

GET 通常幂等,但“方法名是 GET”不保证服务实现无副作用。POST 也可以通过业务幂等键安全重试。

请求体重放可用 Request.GetBodyhttp.NewRequest 对部分内存 reader 会自动设置。流式 body 通常不能重试。

不要同时让 SDK、业务层、服务网格和负载均衡器各重试三次;组合后请求量会指数放大。

15.2 取消后清理

收到 ctx.Done() 后应尽快:

  • 停止新工作;
  • 让阻塞 API 使用 context;
  • 回收 semaphore;
  • 停止 timer;
  • 关闭当前操作独占的连接;
  • 返回 ctx.Err() 或带上下文的包装错误。

不要把 context 存在长期 struct 字段中;通常作为函数第一个参数传递。

16. TLS 与证书

服务端可以:

go
server.ListenAndServeTLS("cert.pem", "key.pem")

更复杂场景使用 tls.Config

go
tlsConfig := &tls.Config{
	MinVersion: tls.VersionTLS12,
}
server.TLSConfig = tlsConfig

Go 1.26 默认启用新的混合后量子密钥交换组合。不要为了“兼容”随意手工固定过时 cipher suite 或曲线;优先采用安全默认值,只在明确互操作问题下调整。

客户端:

go
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
	MinVersion: tls.VersionTLS12,
	ServerName: "api.example.com",
}

不要在生产代码设置:

go
InsecureSkipVerify: true

这会关闭主机名和证书链验证,允许中间人攻击。私有 CA 应加入独立 CertPool。mTLS 则同时配置客户端证书和服务端对客户端证书的校验。

证书热更新可以通过 GetCertificate 或外部终止 TLS。更新过程要保证并发安全,并保留最后一份有效证书,避免短暂读取失败把服务打挂。

17. 流式传输、SSE 与双向协议

17.1 流式 HTTP

go
func stream(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/x-ndjson")
	rc := http.NewResponseController(w)

	for item := range items(r.Context()) {
		if err := json.NewEncoder(w).Encode(item); err != nil {
			return
		}
		if err := rc.Flush(); err != nil {
			return
		}
	}
}

流式响应需要:

  • 定期 flush;
  • 监听请求 context;
  • 限制每客户端缓冲;
  • 处理慢消费者;
  • 与反向代理缓冲配置协调;
  • 使用适合的 WriteTimeout 策略。

17.2 SSE

SSE 响应类型为 text/event-stream,每条事件用空行结束:

text
event: update
data: {"id":1}

数据中每行都要按 data: 规则编码。定期发送注释心跳可避免中间设备因 idle 关闭连接。

17.3 WebSocket 与 HTTP/2

标准库提供 HTTP/2 支持,但没有面向应用的完整 WebSocket API。需要 WebSocket 时选维护良好的库,并重点处理:

  • 单写者或写锁;
  • ping/pong 和 read deadline;
  • 消息大小;
  • 压缩炸弹;
  • 关闭握手;
  • 停机时连接登记和排空。

不要把 WebSocket 连接从生命周期管理中“放飞”。

18. 代理、反向代理与真实客户端地址

httputil.ReverseProxy 提供基础反向代理能力。现代 API 可通过 rewrite 明确设置目标:

go
proxy := &httputil.ReverseProxy{
	Rewrite: func(pr *httputil.ProxyRequest) {
		pr.SetURL(target)
		pr.SetXForwarded()
	},
}

生产代理还要设置:

  • transport timeout 和连接池;
  • 错误处理;
  • flush 策略;
  • header 白名单;
  • WebSocket/upgrade;
  • body 上限;
  • 后端选择和健康检查;
  • 指标与追踪。

客户端地址

r.RemoteAddr 通常是直接 TCP 对端。经过可信反向代理时,真实地址可能在 ForwardedX-Forwarded-For

绝不能无条件相信客户端自己发来的转发头。正确策略:

  1. 判断直接对端是否在可信代理网段;
  2. 只有可信时解析转发链;
  3. 从约定方向移除可信代理;
  4. 对格式和数量设置限制;
  5. IP 只作为风控信号,不当作强身份。

19. 性能、背压与限流

19.1 先看指标和 profile

网络慢可能来自:

  • DNS;
  • 建连/TLS;
  • 连接池等待;
  • 上游响应;
  • 本地排队;
  • 序列化;
  • 锁竞争;
  • 慢客户端;
  • GC 和分配。

先拆分延迟,再优化。盲目把 goroutine 数量加大经常只会把瓶颈推到数据库。

19.2 限制并发

简单 semaphore:

go
slots := make(chan struct{}, 100)

func limited(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case slots <- struct{}{}:
			defer func() { <-slots }()
			next.ServeHTTP(w, r)
		case <-r.Context().Done():
			http.Error(w, "request canceled", 499)
		default:
			http.Error(w, "busy", http.StatusServiceUnavailable)
		}
	})
}

HTTP 没有标准 499,公开 API 通常不应随意使用非标准码;上例只是说明取消分支,实际可直接返回不写响应,或采用团队约定。

限流可以按全局、租户、用户、IP 或成本权重进行。队列必须有长度和等待上限。无界队列只是把过载从失败变成内存增长和长尾延迟。

19.3 缓冲池

sync.Pool 可减少高频临时 buffer 分配,但要谨慎:

  • 归还前重置;
  • 不在归还后继续引用;
  • 限制大 buffer 回池;
  • 不把敏感内容泄漏给下一个使用者;
  • 先用 profile 证明确有价值。

19.4 零拷贝和 io.Copy

文件到连接可使用:

go
_, err := io.Copy(w, file)

标准库会在可能时使用更高效路径。手写 4 KB 循环未必更快。仍需限制长度和处理 context/timeout。

20. 可观测性与诊断

每个请求建议记录:

  • request ID / trace ID;
  • 方法、路由模板、状态码;
  • 延迟和响应字节;
  • 经过脱敏的租户或用户标识;
  • 错误类别;
  • 上游目标类别和耗时;
  • 不记录 token、完整 cookie、密码和大 body。

指标常见维度:

  • 请求数、错误数、延迟分布;
  • 在途请求;
  • 连接状态;
  • 上游重试与超时;
  • DNS/建连/TLS/TTFB 分段;
  • 请求/响应大小;
  • 限流和队列等待。

诊断工具:

go
import _ "net/http/pprof"

不要把 pprof 无认证暴露到公网。可以单独绑定管理地址,并通过网络策略或认证保护。

客户端追踪可使用 net/http/httptrace 观察 DNS、连接获取、TLS 和首字节时间。它适合诊断分段延迟,不应在每个请求上无采样地记录海量事件。

21. 安全清单

输入与资源

  • 限制 header、body、表单、multipart、消息和解压后大小;
  • 设置读取、写入、idle 和总 deadline;
  • 限制连接数、并发和队列;
  • JSON 是否拒绝未知字段由协议明确决定;
  • 上传文件不信任文件名和 Content-Type。

URL 与外连

  • 防 SSRF:限制 scheme、host、端口和解析后的 IP;
  • 重定向后也要重新验证;
  • 防 DNS rebinding:连接目标与校验策略一致;
  • Go 1.26 的 net/url.Parse 默认拒绝 host 子组件中未加括号的异常冒号,不要依赖旧的宽松解析。

HTTP

  • 防响应拆分和 header 注入;
  • cookie 设置 SecureHttpOnly、适当的 SameSite
  • CORS 精确列出 origin,不把凭据和 * 混用;
  • 状态改变操作处理 CSRF;
  • 不信任任意代理头;
  • 错误响应不泄漏内部信息。

TLS

  • 使用受支持 Go 补丁版本;
  • 不关闭证书验证;
  • 私钥权限最小化并支持轮换;
  • mTLS 身份还要映射到应用授权;
  • 不自行实现密码协议。

22. 与 Java 网络栈的差异

Java 常见概念Go 对应
Socketnet.Conn / net.TCPConn
ServerSocketnet.Listener
NIO Selector多数业务直接用 goroutine + runtime netpoll;动态 channel select 不是 socket selector
Servlethttp.Handler
Filterfunc(http.Handler) http.Handler 中间件
HttpClienthttp.Client
Executor/线程池goroutine 很轻,但仍需并发上限
try-with-resourcesdefer Close(),同时关注循环内 defer 范围

Go runtime 在底层使用网络轮询器,阻塞式 API 不等于每个连接永久占一个操作系统线程。不要照搬 Java NIO 的复杂状态机,除非经过 profile 确认普通模型不满足需求。

23. 常见故障排查

connection refused

目标主机可达但端口没有 listener,或防火墙主动拒绝。检查监听地址、容器端口映射和 IPv4/IPv6。

i/o timeout / context deadline exceeded

区分 DNS、dial、TLS、response header、body 读取和上层 deadline。使用 httptrace 和分段指标。

connection reset by peer

对端或中间设备异常关闭。检查 idle timeout、请求大小、协议不匹配和服务端日志。

大量 TIME_WAIT

通常意味着没有复用 HTTP transport、连接被频繁短开,或服务主动关闭很多连接。不要通过危险内核参数掩盖客户端生命周期错误。

goroutine 持续增长

检查:

  • 没有 deadline 的 Read
  • 未关闭 response body;
  • channel 发送者无人接收;
  • 重试 goroutine 没有 context;
  • hijack/长连接没有登记;
  • 无界后台任务。

Go 1.26 提供实验性的 goroutine leak profile,可通过 GOEXPERIMENT=goroutineleakprofile 尝试;它不是所有泄漏的完美检测器。

too many open files

检查文件描述符上限之外,更要找:

  • response body/conn 未关闭;
  • listener 泄漏;
  • 每次请求新建 transport;
  • 重试未回收旧连接;
  • 长连接数无上限。

24. 速查表

目标API
TCP 监听net.Listen("tcp", addr)
带 context 监听net.ListenConfig.Listen
带 context 拨号net.Dialer.DialContext
设置读超时conn.SetReadDeadline
精确读取 N 字节io.ReadFull
IP 值netip.Addr
主机端口拼接net.JoinHostPort
HTTP 路由http.ServeMux
路径参数r.PathValue
限制 bodyhttp.MaxBytesReader / io.LimitReader
测试服务httptest.NewServer
优雅关闭server.Shutdown(ctx)
信号 contextsignal.NotifyContext
HTTP 客户端长期复用 http.Client
请求取消http.NewRequestWithContext
TLScrypto/tls
反向代理httputil.ReverseProxy
分段追踪net/http/httptrace
网络 profilenet/http/pprof

可运行示例

网络示例很容易被固定端口、外部域名或缺失的截止时间拖成不稳定测试。这里统一使用回环地址和随机端口,并为所有可能阻塞的操作设置时间边界。

示例一:本地 JSON 服务与有超时的客户端

第一个程序只用标准库实现一条返回 JSON 的路由,再通过真实 HTTP 客户端调用它。整个过程发生在本机,不依赖公网服务。

go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

type Item struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

func newHandler() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("id") != "42" {
			http.Error(w, "item not found", http.StatusNotFound)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		// Encoder 直接写入响应流,避免先构造一份额外的 []byte。
		if err := json.NewEncoder(w).Encode(Item{ID: 42, Name: "Go 手册"}); err != nil {
			// 响应可能已部分写出,此时不能可靠改写状态码;真实服务应记录该错误。
			return
		}
	})
	return mux
}

func main() {
	// httptest.Server 使用本机随机端口,示例不依赖公网,也不会占用固定端口。
	server := httptest.NewServer(newHandler())
	defer server.Close()

	// 生产客户端必须有超时。默认 http.Client 没有整体截止时间,
	// 当对端不返回时,调用可能无限等待并耗尽 goroutine。
	client := &http.Client{Timeout: time.Second}
	resp, err := client.Get(server.URL + "/items/42")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	var item Item
	if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
		panic(err)
	}
	fmt.Printf("状态=%d\n", resp.StatusCode)
	fmt.Printf("商品=%+v\n", item)
}

运行:

bash
go run ./examples/ch19/json-service

预期输出:

text
状态=200
商品={ID:42 Name:Go 手册}

拆解:

  • httptest.NewServer 启动真实本地 HTTP 服务并分配随机端口,适合示例和集成测试。
  • Go 1.22 之后的 ServeMux 模式可以同时约束方法和路径;PathValue 读取路径参数。
  • 服务端用 json.Encoder 流式写响应,客户端用 json.Decoder 按结构解码,双方通过 JSON 字段而非 Go 字段名耦合。
  • 客户端设置整体超时;真正的服务还可通过自定义 Transport 分别约束拨号、TLS 握手、响应头和连接池。

修改实验:请求不存在的 ID 并检查 404;再添加 DisallowUnknownFields 的 POST 解码路径,观察未知 JSON 字段如何被拒绝。

示例二:用 Context 截止时间中止慢请求

第二个程序故意制造超时:上游在 100 毫秒后才响应,而当前调用只有 20 毫秒预算。客户端必须及时取消,不能一直占用 goroutine。

go
package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	slowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 模拟处理速度超过客户端预算的上游服务。
		time.Sleep(100 * time.Millisecond)
		_, _ = w.Write([]byte("too late"))
	}))
	defer slowServer.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
	if err != nil {
		panic(err)
	}
	_, err = slowServer.Client().Do(req)

	// 不比较错误字符串:其中常含动态 URL,而且不同 Go 版本的包装文本可能变化。
	// errors.Is 会沿错误链识别稳定的语义原因。
	fmt.Println("请求已超时:", errors.Is(err, context.DeadlineExceeded))
}

运行:

bash
go run ./examples/ch19/client-timeout

预期输出:

text
请求已超时: true

拆解:

  • NewRequestWithContext 把取消信号传入 HTTP 栈;截止时间到达后,等待连接或响应的操作都会被中止。
  • errors.Is(err, context.DeadlineExceeded) 判断语义,不比较包含动态 URL 的错误字符串。
  • defer cancel() 即使请求提前完成也会释放计时器资源。
  • 超时只限制等待时间,不会自动让上游处理函数停止。真实服务端也必须监听 r.Context().Done(),避免客户端离开后继续做昂贵工作。

修改实验:把服务端处理改为 select 监听 r.Context().Done() 并记录取消;再比较 Client.Timeout 与单请求 context 哪个错误更适合携带调用链预算。

示例三:带分帧和截止时间的 TCP Echo

最后回到 TCP。它只提供字节流,消息边界需要应用自己定义;同时还要保证 AcceptReadWrite 不会永久卡住。

go
package main

import (
	"bufio"
	"fmt"
	"io"
	"net"
	"time"
)

func main() {
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	defer listener.Close()

	serverDone := make(chan error, 1)
	go func() {
		conn, err := listener.Accept()
		if err != nil {
			serverDone <- err
			return
		}
		defer conn.Close()
		_ = conn.SetDeadline(time.Now().Add(time.Second))

		// ReadString 说明协议用换行符分帧;TCP 本身只是字节流,
		// 一次 Write 与一次 Read 并没有天然的一一对应关系。
		line, err := bufio.NewReader(conn).ReadString('\n')
		if err != nil {
			serverDone <- err
			return
		}
		_, err = io.WriteString(conn, "echo:"+line)
		serverDone <- err
	}()

	conn, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
	if err != nil {
		panic(err)
	}
	_ = conn.SetDeadline(time.Now().Add(time.Second))

	if _, err := io.WriteString(conn, "hello\n"); err != nil {
		panic(err)
	}
	reply, err := bufio.NewReader(conn).ReadString('\n')
	if err != nil {
		panic(err)
	}
	_ = conn.Close()

	if err := <-serverDone; err != nil {
		panic(err)
	}
	fmt.Printf("响应=%q\n", reply)
}

运行:

bash
go run ./examples/ch19/tcp-echo

预期输出:

text
响应="echo:hello\n"

拆解:

  • 监听 127.0.0.1:0 表示仅绑定回环地址并让系统选择空闲端口,避免端口冲突和外部访问。
  • 协议以换行符分帧。一次 Write 的内容可能被多次 Read 取回,不能把 TCP 当消息队列。
  • 客户端用 DialTimeout 控制连接建立,连接双方再用 SetDeadline 约束后续读写。
  • 容量为 1 的 serverDone 让服务 goroutine 即使主 goroutine 尚未接收,也能报告一个结果并退出,避免泄漏。

修改实验:连续发送两行并在服务端循环读取;随后设置最大行长,防止攻击者一直发送不含换行的内容耗尽内存。

25. 练习

  1. 实现长度前缀 TCP 协议,限制 1 MiB,支持 context 取消、idle timeout 和优雅停机。
  2. 写一个 HTTP JSON API,使用标准库路由、统一 Problem 错误、1 MiB body 上限和 request ID 中间件。
  3. 为 HTTP client 增加总超时、状态码错误、限量响应体和只针对幂等请求的指数退避。
  4. 使用 httptest.Server 验证 client 在 500、429、慢响应、断开连接和非法 JSON 时的行为。
  5. 写 SSE 服务并实现每客户端有界队列;慢客户端不能阻塞生产者。
  6. 实现可信代理地址解析,并为伪造 X-Forwarded-For、IPv6 和多级代理链写测试。
  7. 使用 httptrace 输出一次请求的 DNS、connect、TLS 和 TTFB 时间。

26. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。