Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

Go 与 HTTP QUERY 方法深度解析

TL;DR

  • 事件:IETF 于 2026 年 6 月发布 RFC 10008(Proposed Standard),正式定义 HTTP 新方法 QUERY。草案名 draft-ietf-httpbis-safe-method-w-body,作者是 Julian Reschke、James M. Snell、Mike Bishop。
  • QUERY 是什么安全(safe)+ 幂等(idempotent)+ 可携带请求体。语义上是"把请求内容当作查询交给服务器处理,返回结果但不改变资源状态"。
  • 解决什么:复杂查询(多字段筛选、长 ID 列表、GraphQL 风格结构)塞进 URL 会撞长度上限、编码丑陋、日志泄露;改用 POST 又丢掉缓存、自动重试与"只读"语义。QUERY 就是那个缺失的第三选项。
  • Go 侧现状net/http 已在 API 文档的 api/next 候选中新增 MethodQuery = "QUERY" 常量(尚未定稿进正式版本),落地前可先自定义常量。
  • 别误用:QUERY 不是"GET 加 body"。它要求 Content-Type、跨域需要预检、缓存键必须覆盖请求体——当成 POST 的替代品无脑铺开会踩坑。

一、三十年的两难:GET 塞不下,POST 说不清

做 API 的人几乎都遇到过同一类别扭接口:页面只是查数据,不创建、不修改、不删除,但查询条件复杂到离谱

一个企业级数据表格可能同时带十几个筛选字段、多个排序条件、几十个勾选值、日期区间、字段投影、全文检索、聚合维度、游标分页,甚至嵌套的 AND/OR。把它塞进 URL 会立刻撞上三堵墙:

GET /contacts?select=surname,givenname,email&limit=10
    &match=email%3D*%40example.*&sort=...&filter=...(还有 20 个参数)
  1. 长度上限:服务器、代理、CDN 普遍限制 URL 长度(常见 2000–8000 字符)。复杂过滤表达式、GraphQL 风格查询、长 ID 列表根本放不下。
  2. 编码成本:所有内容都要百分号编码,可读性差、出错率高。
  3. 日志泄露:URL 会被写进访问日志、代理日志、浏览器历史。查询里含身份证号、邮箱、医疗关键词,就等于落盘到日志文件。

于是工程上的标准做法是改用 POST:

http
POST /contacts/search HTTP/1.1
Host: example.org
Content-Type: application/json

{"select":["surname","givenname","email"],"limit":10,"match":"email=*@example.*"}

能跑,但代价是丢掉了 GET 的全部优点

  • POST 不是安全方法。中间任何一环(浏览器、Service Worker、反向代理、API 网关、CDN、缓存系统、重试机制)都无法仅凭方法判断这是"创建订单"还是"查个列表"。
  • POST 响应实际上不可缓存。CDN 不会替你缓存一个 POST 结果。
  • POST 不能自动重试。网络中途断开,客户端不敢重发——万一它是下单请求呢?

结果就是:Elasticsearch 的 _search、绝大多数 GraphQL 服务端,全都用 POST 承载只读查询,然后长期忍受这套语义错配。

RFC 10008 的答案就是补上第三种组合:服务器处理一份结构化的查询描述,而调用方明确声明"这次只是查询,不改变目标资源状态,且相同请求可安全重复"。

二、QUERY 的协议定位:safe + idempotent + body

先把两个被说烂但常被误解的词讲清楚:

  • Safe(安全):从客户端视角看是只读的。重复发同样的请求,服务器状态不变。GET、HEAD、OPTIONS、TRACE、QUERY 都属于安全方法。
  • Idempotent(幂等):重复执行多次的最终效果与执行一次相同。GET、HEAD、PUT、DELETE、OPTIONS、TRACE、QUERY 都幂等。

主流方法的语义对照:

方法用途SafeIdempotent可缓存
GET获取资源表示
HEAD只取响应头
POST提交内容供处理❌(实际)
PUT整体替换资源
PATCH局部修改资源
DELETE删除资源
QUERY带请求体的安全查询

一次 QUERY 请求长这样:

http
QUERY /contacts HTTP/1.1
Host: example.org
Content-Type: application/json
Accept: application/json

{"select":["surname","givenname","email"],"limit":10,"match":"email=*@example.*"}

2.1 几条必须记住的协议细节

这些细节决定了你能不能正确实现和缓存 QUERY,别跳过:

  1. Content-Type 是强制的。请求内容的具体格式由 Content-Type 决定(可以是 JSON、application/x-www-form-urlencoded、甚至 SQL、JSONPath、XSLT)。服务器收到不带 Content-Type 的 QUERY,必须返回 400
  2. Accept-Query 响应头:服务器用它告知客户端自己接受的查询格式。客户端可以据此选择用哪种查询语言,而不是靠猜。
  3. Content-LocationLocation 的区别(很容易搞混):
    • Content-Location 指向本次查询结果本身
    • Location 指向一个等效资源——客户端下次可以直接用 GET 访问它,跳过重新查询。
  4. 响应可缓存,但缓存键必须包含完整请求内容。因为方法名相同、URI 相同,结果却可能因请求体不同而完全不同。缓存中间件可以对请求内容做语义归一化(例如键顺序无关的 JSON 归一),除非客户端显式发了 no-transform
  5. 跨域需要预检。QUERY 不是 CORS 安全列表方法,跨域调用会触发 OPTIONS 预检——这点和 GET 不同,前端同学要留意。
  6. 名称的由来:草案早期考虑过复用 WebDAV 的 SEARCH,最终选了 QUERY,理由是它"更好地捕捉了与 URI 查询组件的关联"。

三、Go 侧落地:从 MethodQuery 常量到服务端路由

3.1 net/http 新增 MethodQuery

Go 标准库 net/http 已在 API 文档的 api/next 候选中新增:

go
const MethodQuery = "QUERY" // untyped string constant

在此之前,标准库只提供 MethodGetMethodPostMethodHead 等常量,要处理 QUERY 只能自己写字面量 "QUERY"——容易拼错,也无法和既有常量统一参与方法集合判断与 switch 分支。

重要提醒:该常量目前是 api/next 的候选条目,属于尚未定稿的下一版本 API 变化,实际落地版本以官方发布说明为准。在它进入你使用的 Go 版本之前,先在自己的包里定义常量是稳妥做法:

go
package httpx

// 在 net/http.MethodQuery 正式可用前,本地先定义,避免全项目散落字符串字面量。
const MethodQuery = "QUERY"

// 判定一个方法是否可安全重试(safe + idempotent)。
func IsRetryable(method string) bool {
	switch method {
	case "GET", "HEAD", "OPTIONS", "TRACE", MethodQuery:
		return true
	default:
		return false
	}
}

3.2 服务端:路由与方法校验

Go 1.22+ 的 net/http 路由支持方法前缀,可以直接把 QUERY 挂上去:

go
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
)

type contactQuery struct {
	Select []string `json:"select"`
	Limit  int      `json:"limit"`
	Match  string   `json:"match"`
}

func main() {
	mux := http.NewServeMux()

	// Go 1.22+ 模式路由:方法 + 路径。
	// 注意:若使用尚未支持 MethodQuery 的 Go 版本,这里直接写 "QUERY /contacts" 字符串。
	mux.HandleFunc("QUERY /contacts", func(w http.ResponseWriter, r *http.Request) {
		// 1) Content-Type 缺失必须 400 —— 这是 RFC 10008 的硬要求。
		ct := r.Header.Get("Content-Type")
		if ct == "" {
			http.Error(w, "QUERY requires Content-Type", http.StatusBadRequest)
			return
		}
		if ct != "application/json" {
			// 用 Accept-Query 告知客户端我们支持哪些格式,而不是干巴巴地 415。
			w.Header().Set("Accept-Query", "application/json")
			http.Error(w, "unsupported query format", http.StatusUnsupportedMediaType)
			return
		}

		// 2) 限制请求体大小,防止把"只读查询"变成内存放大器。
		body := http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
		defer body.Close()

		var q contactQuery
		if err := json.NewDecoder(body).Decode(&q); err != nil && err != io.EOF {
			http.Error(w, "invalid query body", http.StatusBadRequest)
			return
		}
		if q.Limit <= 0 || q.Limit > 100 {
			q.Limit = 10
		}

		// 3) 只读执行查询……
		result := []map[string]string{
			{"surname": "Zhang", "givenname": "San", "email": "zs@example.com"},
		}

		// 4) 用 Content-Location 指向本次结果;用 Location 指向可 GET 的等效资源。
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Content-Location", "/contacts/query-results/9f2c1a")
		w.Header().Set("Location", "/contacts?email_domain=example.com&limit=10")
		if err := json.NewEncoder(w).Encode(result); err != nil {
			log.Printf("encode failed: %v", err)
		}
	})

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

3.3 客户端:构造 QUERY 请求

http.NewRequest 对未知方法完全开放,直接用字符串即可:

go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

func main() {
	payload := map[string]any{
		"select": []string{"surname", "givenname", "email"},
		"limit":  10,
		"match":  "email=*@example.*",
	}
	buf, _ := json.Marshal(payload)

	req, err := http.NewRequest(http.MethodQuery, // 或本地常量 httpx.MethodQuery
		"https://example.org/contacts", bytes.NewReader(buf))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusBadRequest {
		// 服务器拒绝了:多半是 Content-Type 没带或格式不被接受。
		fmt.Println("server rejected query:", resp.Header.Get("Accept-Query"))
		return
	}

	data, _ := io.ReadAll(resp.Body)
	fmt.Printf("status=%d content-location=%s\n%s\n",
		resp.StatusCode, resp.Header.Get("Content-Location"), data)
}

3.4 中间件与框架作者要注意什么

  • 重试逻辑:把 QUERY 加入"可安全重试"白名单。这是 QUERY 相对 POST 的最大收益之一。
  • 访问日志:QUERY 的查询内容在 body 里,不会自动进 access log。这对含敏感条件的查询是隐私收益,但也意味着你要主动记录审计信息(否则排查问题时会缺上下文)。
  • 网关/反代:确认你的网关不会把未知方法当成 405 Method Not Allowed 拦掉。Nginx、Envoy 这类需要显式放行 QUERY
  • 缓存层:如果你的缓存实现把"非 GET/HEAD"一律视为不可缓存,需要为 QUERY 增加"按完整请求体计算缓存键"的分支。
  • CORS:跨域场景记得在预检响应里把 QUERY 加进 Access-Control-Allow-Methods

四、采用决策:什么时候用,什么时候别用

该用 QUERY 的场景:

  • 只读但条件复杂到不适合放 URL 的查询(搜索、报表筛选、批量 ID 查询)。
  • 需要 CDN / 网关缓存的只读查询——这是 POST 永远给不了的。
  • 客户端希望网络抖动时能自动重试的查询。
  • 查询条件含敏感信息,不希望落进 URL 日志。

不该用 QUERY 的场景:

  • 简单查询(几个参数)——老老实实用 GET,兼容性最好。
  • 有副作用的操作(下单、发送、写库)——继续用 POST/PUT/PATCH/DELETE,别用"安全方法"包装写操作,那是在污染语义。
  • 需要极致的浏览器兼容——QUERY 是新方法,老客户端、部分代理、某些 CDN 边缘节点可能不认识,需要在网关侧做好降级(例如同时暴露一个 POST 端点)。
  • 依赖 HTML 表单提交——表单只支持 GET/POST,QUERY 用不上。

一句话总结判断标准:如果这个请求你"敢"让爬虫、预取器、CDN 自动重放,那它才配用 QUERY。

五、陷阱清单

陷阱后果正确做法
忘记 Content-Type服务器必须返回 400客户端强制设置;服务端显式校验
把 QUERY 当"GET 加 body"误以为跨域免预检、URL 也能带参数记住它需要 CORS 预检,语义是"处理请求内容"
缓存键只用 URI不同查询体命中同一缓存,返回错误结果缓存键必须包含完整请求内容
网关默认 405请求根本到不了应用显式在网关放行 QUERY
无请求体大小限制只读接口变成内存放大器http.MaxBytesReader 兜底
用 QUERY 承载写操作中间层会放心重放,产生重复副作用有副作用一律用 POST/PUT/PATCH/DELETE

六、小结

RFC 10008 干的事很朴素:把"只读但带结构体"这个语义空档填上。它不炫技,但解决了 HTTP 三十年里一个真实且高频的痛点。

对 Go 开发者来说,短期动作是两件:

  1. 在网关、日志、重试、缓存这几层把 QUERY 加进"安全方法"白名单;
  2. net/http.MethodQueryapi/next 转正,在此之前用本地常量收敛字符串字面量。

中期则是一个设计习惯的转变:再写 /xxx/search 这种 POST 只读接口时,先问一句"这个能不能用 QUERY"。语义正确了,缓存、重试、审计这些基础设施才会自动帮你干活。

参考资料


相关阅读

上次更新于: