← 中文资源

简体中文版本

Alt-Svc 头

Alt-Svc 响应头为同一源宣告替代服务端点(如另一主机上的 HTTP/3 端口),客户端可升级传输协议或在连接中途迁移。

阅读英文原文

概念定义

Alt-Svc(替代服务)把“源的身份”与“提供服务的端点”解耦。example.com 的响应可以声明:同一服务还以 h3 协议在 alt.example.com 的 443 端口提供,有效期一天。客户端缓存该映射并在后续请求中尝试替代端点,失败则回退默认端点。

工作机制

头部列出协议标识、authority 与 max-age;ma 参数控制缓存时长。对替代端点的证书校验仍使用原始源名称,因此需要 SAN 或泛域名覆盖。QUIC 场景下还可携带传输参数以支持连接迁移。

使用场景

从 HTTP/2 平滑升级 HTTP/3、把流量迁到任播或区域边缘、维护期间排空端点,都靠 Alt-Svc 完成重指派。替代端点共享状态时,QUIC 连接 ID 让飞行中迁移几乎无感。

常见误区

Alt-Svc 只是提议而非强制,客户端有权忽略并停留在默认路径。服务下线后过期的通告会让客户端反复探测失败,变更窗口期应把 max-age 调短。替代端点证书必须覆盖原源名称,否则每次升级都失败。监控要看协议分布与回退率,而不只是可用性。

代码示例

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)
}
package main

import (
	"compress/gzip"
	"fmt"
	"net/http"
	"strings"
)

type gzipWriter struct {
	http.ResponseWriter
	writer *gzip.Writer
}

func (g *gzipWriter) Write(data []byte) (int, error) {
	return g.writer.Write(data)
}

func GzipMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Add("Vary", "Accept-Encoding")
		if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
			next.ServeHTTP(w, r)
			return
		}
		w.Header().Set("Content-Encoding", "gzip")
		compressor := gzip.NewWriter(w)
		defer compressor.Close()
		next.ServeHTTP(&gzipWriter{ResponseWriter: w, writer: compressor}, r)
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		fmt.Fprintln(w, strings.Repeat("hello gzip ", 64))
	})
	_ = http.ListenAndServe(":8080", GzipMiddleware(mux))
}