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 个参数)- 长度上限:服务器、代理、CDN 普遍限制 URL 长度(常见 2000–8000 字符)。复杂过滤表达式、GraphQL 风格查询、长 ID 列表根本放不下。
- 编码成本:所有内容都要百分号编码,可读性差、出错率高。
- 日志泄露:URL 会被写进访问日志、代理日志、浏览器历史。查询里含身份证号、邮箱、医疗关键词,就等于落盘到日志文件。
于是工程上的标准做法是改用 POST:
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 都幂等。
主流方法的语义对照:
| 方法 | 用途 | Safe | Idempotent | 可缓存 |
|---|---|---|---|---|
| GET | 获取资源表示 | ✅ | ✅ | ✅ |
| HEAD | 只取响应头 | ✅ | ✅ | ✅ |
| POST | 提交内容供处理 | ❌ | ❌ | ❌(实际) |
| PUT | 整体替换资源 | ❌ | ✅ | ❌ |
| PATCH | 局部修改资源 | ❌ | ❌ | ❌ |
| DELETE | 删除资源 | ❌ | ✅ | ❌ |
| QUERY | 带请求体的安全查询 | ✅ | ✅ | ✅ |
一次 QUERY 请求长这样:
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,别跳过:
Content-Type是强制的。请求内容的具体格式由Content-Type决定(可以是 JSON、application/x-www-form-urlencoded、甚至 SQL、JSONPath、XSLT)。服务器收到不带Content-Type的 QUERY,必须返回 400。Accept-Query响应头:服务器用它告知客户端自己接受的查询格式。客户端可以据此选择用哪种查询语言,而不是靠猜。Content-Location与Location的区别(很容易搞混):Content-Location指向本次查询结果本身;Location指向一个等效资源——客户端下次可以直接用 GET 访问它,跳过重新查询。
- 响应可缓存,但缓存键必须包含完整请求内容。因为方法名相同、URI 相同,结果却可能因请求体不同而完全不同。缓存中间件可以对请求内容做语义归一化(例如键顺序无关的 JSON 归一),除非客户端显式发了
no-transform。 - 跨域需要预检。QUERY 不是 CORS 安全列表方法,跨域调用会触发
OPTIONS预检——这点和 GET 不同,前端同学要留意。 - 名称的由来:草案早期考虑过复用 WebDAV 的
SEARCH,最终选了QUERY,理由是它"更好地捕捉了与 URI 查询组件的关联"。
三、Go 侧落地:从 MethodQuery 常量到服务端路由
3.1 net/http 新增 MethodQuery
Go 标准库 net/http 已在 API 文档的 api/next 候选中新增:
const MethodQuery = "QUERY" // untyped string constant在此之前,标准库只提供 MethodGet、MethodPost、MethodHead 等常量,要处理 QUERY 只能自己写字面量 "QUERY"——容易拼错,也无法和既有常量统一参与方法集合判断与 switch 分支。
重要提醒:该常量目前是 api/next 的候选条目,属于尚未定稿的下一版本 API 变化,实际落地版本以官方发布说明为准。在它进入你使用的 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 挂上去:
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 对未知方法完全开放,直接用字符串即可:
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 开发者来说,短期动作是两件:
- 在网关、日志、重试、缓存这几层把
QUERY加进"安全方法"白名单; - 等
net/http.MethodQuery从api/next转正,在此之前用本地常量收敛字符串字面量。
中期则是一个设计习惯的转变:再写 /xxx/search 这种 POST 只读接口时,先问一句"这个能不能用 QUERY"。语义正确了,缓存、重试、审计这些基础设施才会自动帮你干活。
参考资料
- RFC 10008: The HTTP QUERY Method(IETF,2026 年 6 月)
- draft-ietf-httpbis-safe-method-w-body(草案原始版本)
- net/http 包文档(
MethodQuery常量) - RFC 9110: HTTP Semantics(方法语义、safe 与 idempotent 的权威定义)
- Go 周刊 2026W35(
net/http新增MethodQuery的条目来源)
相关阅读

