← 中文资源

简体中文版本

用 ETag 条件请求跳过未变更下载

重复请求时携带 If-None-Match,处理 304 并复用已保存的响应内容。

阅读英文原文

功能说明

这段代码演示 HTTP 条件请求:第一次下载资源时保存响应体和 ETag;后续请求把 ETag 放进 If-None-Match 头。如果资源没有变化,服务器返回 304 且没有响应体,客户端直接复用本地副本,节省带宽和时间。

它适合轮询配置、清单文件、静态数据包等“多数时候不变”的资源。示例使用 httptest 启动本地服务器,完整展示了 200 与 304 两条路径。

关键代码

fetchConditional 创建 GET 请求,非空 etag 时设置 If-None-Match。收到 304 时返回空 body、原 etag 和 notModified=true;收到 200 时用 io.LimitReader 限制最多读取 1 MiB,防止异常响应耗尽内存。

服务器端设置固定的 ETag "v1",发现请求头匹配时直接写 304 并返回;否则写入负载。main 断言第一次拿到完整内容,第二次拿到 304、空 body,且服务器共收到两次请求。

使用注意

收到 304 时不能清空本地缓存,必须继续使用已保存的副本;示例把复用逻辑留给调用方,返回空 body 是刻意的。ETag 是包含引号的不透明字符串,必须原样回传,不能自行去掉引号。

服务器可能返回弱 ETag(W/"v1"),也可能支持多个 ETag 值;比较逻辑应遵循 RFC,而不是简单字符串相等。条件请求还要与 Cache-Control、Vary 和压缩协商配合。读取响应体失败时不要更新本地缓存。

验证方式

运行示例会打印第一次的响应体和 ETag,以及第二次的 notModified=true 和请求计数 2。把服务器端的 ETag 改成 "v2",第二次请求应返回完整负载而不是 304。可以再加入 If-None-Match 发送多个 ETag 的场景,验证服务端的匹配策略。

代码示例

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

var payload = []byte("configuration-v1")

func fetchConditional(
	client *http.Client,
	url string,
	etag string,
) (body []byte, newETag string, notModified bool, err error) {
	request, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, "", false, err
	}
	if etag != "" {
		request.Header.Set("If-None-Match", etag)
	}

	response, err := client.Do(request)
	if err != nil {
		return nil, "", false, err
	}
	defer response.Body.Close()

	if response.StatusCode == http.StatusNotModified {
		return nil, etag, true, nil
	}
	if response.StatusCode != http.StatusOK {
		return nil, "", false, fmt.Errorf("unexpected status %d", response.StatusCode)
	}
	body, err = io.ReadAll(io.LimitReader(response.Body, 1<<20))
	if err != nil {
		return nil, "", false, err
	}
	return body, response.Header.Get("ETag"), false, nil
}

func main() {
	requests := 0
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		requests++
		writer.Header().Set("ETag", "\"v1\"")
		if request.Header.Get("If-None-Match") == "\"v1\"" {
			writer.WriteHeader(http.StatusNotModified)
			return
		}
		_, _ = writer.Write(payload)
	}))
	defer server.Close()

	client := server.Client()
	body, etag, notModified, err := fetchConditional(client, server.URL, "")
	if err != nil || notModified || string(body) != string(payload) || etag == "" {
		panic("first request should download the payload")
	}
	fmt.Printf("first: body=%q etag=%s\n", body, etag)

	body, _, notModified, err = fetchConditional(client, server.URL, etag)
	if err != nil || !notModified || len(body) != 0 || requests != 2 {
		panic("second request should return 304 without a body")
	}
	fmt.Printf("second: notModified=%v requests=%d\n", notModified, requests)
}