← 返回文章列表
技術 11 min read

QUERY 不是 GET with body:HTTP 終於承認我們一直在用 POST 做查詢

RFC 10008 標準化 HTTP QUERY 方法後,API 設計終於有方法表達安全、冪等、可帶 body 的複雜查詢;但瀏覽器、CORS、快取、WAF 與 CDN 還沒完全跟上。


每個後端專案裡,大概都有一支長得像這樣的 API:

POST /products/search
Content-Type: application/json

{
  "filters": {
    "price": { "gte": 1000, "lte": 5000 },
    "brands": ["sony", "panasonic"],
    "features": ["wifi", "4k", "hdr"]
  },
  "sort": ["-rating", "price"],
  "page": 3
}

它很好用,也很合理。前端可以把複雜條件放進 JSON,後端不用解一串失控的 query string,測試工具也看得懂。

唯一尷尬的是:這支 API 其實沒有「post」任何東西。

它沒有建立商品,沒有送出訂單,沒有改變伺服器狀態。它只是在查資料。只是這個查詢太大、太複雜、太結構化,塞不進一條漂亮的 URL,所以我們把它放進 POST /search。久而久之,大家都知道這是查詢,但 HTTP 基礎設施不知道。快取不知道,重試策略不知道,代理伺服器也只能從 method 猜它可能會改狀態。

RFC 10008 標準化的 HTTP QUERY 方法,就是在補這個洞。

不是因為 POST /search 是壞設計。剛好相反:POST /search 太普遍了,普遍到足以證明 HTTP 少了一個剛好的語意位置。

POST /search 不是罪,它是沒有標準時的生存策略

過去我們在 GETPOST 之間選邊站,常常不是因為其中一邊完美,而是因為另一邊更糟。

GET 的語意很漂亮:安全、冪等、可快取、可分享。你把查詢條件寫在 URI 裡,瀏覽器歷史、書籤、CDN、代理伺服器全都能理解。

GET /products?brand=sony&minPrice=1000&maxPrice=5000&sort=-rating

條件一多,畫面就開始難看。巢狀物件要怎麼編碼?陣列用 brand=sony&brand=panasonic,還是 brand[]=sony&brand[]=panasonic?查詢條件裡如果有一整段 JSON 呢?URL 太長時,瀏覽器、反向代理、API gateway 誰先擋下來?敏感篩選條件被寫進 access log,又算誰的責任?

所以大家改用 POST

POST /products/search
Content-Type: application/json

{
  "brand": ["sony", "panasonic"],
  "price": { "gte": 1000, "lte": 5000 },
  "sort": ["-rating"]
}

工程上很舒服。語意上有點心虛。

這張表大概就是過去二十年的 API 設計日常:

複雜查詢想要的能力

             語意正確     可帶 body     可快取     可安全重試
GET             ✅           ❌          ✅          ✅
POST            ❌           ✅          △          ❌
QUERY           ✅           ✅          ✅          ✅

QUERY 的出現,不是拿來審判以前寫 POST /search 的人。它只是把那個大家早就知道的尷尬說出口:我們需要一個「安全、冪等、但可以帶 request body」的查詢方法。

QUERY 補的是語意,不是魔法

最容易把 QUERY 說錯的方式,是把它叫成「GET with body」。

這個說法好記,但不夠準。

比較準確的心智模型應該是這樣:

GET
  請把這個 URI 所代表的資源給我。

POST
  請讓這個 URI 處理我送來的內容;結果由 server 語意決定。

QUERY
  請在這個 URI 的範圍內,用我送來的查詢內容,
  安全且冪等地算出結果。

RFC 10008 對 QUERY 的定義很直接:request target 會以安全且冪等的方式處理 enclosed content,然後回傳處理結果。這讓 QUERYPOST 多了一個重要承諾:相同查詢可以在連線失敗後重送,不必擔心半途改了狀態。

一個 QUERY request 可能長這樣:

QUERY /products HTTP/1.1
Content-Type: application/json
Accept: application/json

{
  "filter": {
    "price": { "gte": 1000, "lte": 5000 },
    "brand": ["sony", "panasonic"]
  },
  "sort": ["-rating", "price"]
}

這裡的 Content-Type 不是裝飾。RFC 10008 要求 server 在缺少 Content-Type,或內容和 media type 不一致時拒絕請求。因為 QUERY 的查詢語意來自 request content,而 request content 要靠 media type 解讀。

Server 也可以用 Accept-Query 告訴 client:這個資源支援哪些查詢格式。

HTTP/1.1 200 OK
Content-Type: application/json
Accept-Query: application/json, application/jsonpath

這個 header 的價值,不只是在文件上多一行。它讓 client 有機會用 HTTP 本身探索查詢能力,而不是只靠人類讀 Swagger 或 README。

更有意思的是 LocationContent-Location

HTTP/1.1 200 OK
Content-Type: application/json
Location: /stored-queries/42
Content-Location: /stored-results/17

Content-Location 可以指向這次查詢結果。Location 可以指向一個等價查詢資源。這是 RFC 10008 裡最容易被略過、但其實很漂亮的設計。

它承認一件事:body 裡的查詢不好分享,不好 bookmark,也不好讓後續請求直接重放。所以 server 可以替這次查詢或結果補上一個 URI。換句話說,QUERY 沒有背叛 Web 的 URI 哲學;它只是承認有些查詢一開始真的很難塞進 URI。

真正麻煩的不是發出 QUERY,是快取它

QUERY response 可以被快取。這句話聽起來像好消息。

但好消息後面接著工程帳單。

GET 的快取 key 很直覺:URL,再加上相關 header。

GET 快取 key

  /products?brand=sony&sort=-rating
  + Accept-Language
  + Authorization / Vary 相關資訊

  cache hit / miss

QUERY 不一樣。查詢條件在 body 裡,所以 cache key 必須納入 request content 和相關 metadata。

QUERY 快取 key

  /products
  + Content-Type: application/json
  + Accept: application/json
  + body:
    {"brand":["sony"],"sort":["-rating"]}

  body hash / normalization

  cache hit / miss

這裡開始有坑。

如果 cache 只看 URL,兩個完全不同的查詢都叫 QUERY /products,就可能撞在同一個 cache entry。這不是效能問題,是安全問題。安全研究者已經開始提醒:body-keyed cache 如果做錯,會打開 cache poisoning 或 cache deception 的門。

比較保守的做法,是 byte-for-byte hash request body。但這會讓語意相同、格式不同的 JSON 失去 cache hit:

{"brand":["sony"],"sort":["-rating"]}

和:

{
  "sort": ["-rating"],
  "brand": ["sony"]
}

對 application 來說可能是一樣的查詢,對 byte hash 來說不是。

如果 cache 想聰明一點,先 normalize JSON,再算 key,又會進入另一個風險區:你確定 cache 的 normalization 規則,和 application 對查詢語意的理解完全一致嗎?陣列順序能不能換?大小寫能不能折疊?空字串和缺欄位是不是同一件事?

QUERY 讓查詢「可以」被快取,但它沒有讓快取「自動變簡單」。如果你的 CDN 仍然只用 URL 當 key,那 QUERY /search 反而可能比 POST /search 更危險,因為它看起來像一個可以快取的東西。

社群對這點的反應很真實。有人在 Hacker News 上直接說:現在的 cache 軟體主要看 headers 和 query string,現在還要擴到 body,這只是更多程式碼。反駁也很直接:重點不是少寫幾行,而是 client、proxy 和 retry policy 能知道這個 request 可以安全重送。

兩邊都沒錯。標準補上語意,實作要付成本。

前端這關還沒過,別急著把 public API 全換掉

如果你今天在瀏覽器裡寫:

await fetch('/api/products', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ brand: ['sony'] }),
});

故事不會只停在「瀏覽器能不能送出這個 method」。

QUERY 不是 forbidden method,但它也不是 CORS safelisted method。Fetch Standard 目前的 CORS safelisted methods 是 GETHEADPOST。所以跨來源 QUERY 會走 preflight。

Browser
  |
  |  想送 QUERY /api/products
  v
OPTIONS /api/products
Access-Control-Request-Method: QUERY
  |
  v
Gateway / WAF / Load Balancer
  |
  |  允許 QUERY 嗎?OPTIONS 規則正確嗎?
  v
App Server
  |
  |  router 認得 QUERY 嗎?middleware 會讀 body 嗎?
  v
CDN / Cache
  |
  |  cache key 有納入 body 嗎?
  v
Response

這條鏈上任何一段不認得 QUERY,你都會得到一個很無聊的錯誤:405、403、CORS error,或某個 WAF 覺得你在做奇怪的事。

更麻煩的是,瀏覽器與 Fetch 生態還在補整合細節。WHATWG Fetch issue 已經有人提出 QUERY 的 method normalization、CORS 行為與 HTTP cache 模型問題。裡面有一個很實務的觀察:目前不該依賴 Chrome 或 Firefox 對重複 QUERY 做瀏覽器層快取。

所以 public browser API 現在不適合豪賭。

對前端來說,QUERY 比較像「可以開始設計」,不是「可以全面替換」。尤其是需要分享網址、SEO、書籤、瀏覽器上一頁下一頁狀態的場景,GET 仍然是正解。你不能把所有篩選條件藏進 request body,然後期待使用者複製網址時一切都還在。

有些查詢就是應該活在 URL 裡。

GET /products?status=active&page=1

如果你的查詢條件只有這樣,請放過 QUERY。標準不是拿來裝飾 API 的。

誰可以先用?誰應該先等等?

我會把 QUERY 的導入分成兩種世界。

第一種是 service-to-service。兩端都由你控制,中間沒有太多未知 gateway、WAF、CDN,查詢 body 又真的大而結構化。這時候 QUERY 很適合拿來試。

適合先試

- service-to-service
- client 和 server 都由同一團隊控制
- 查詢條件大而結構化
- retry / idempotency 很重要
- 中間鏈路可控,能明確放行 QUERY
- cache 策略由 application 或受控 proxy 處理

.NET 10 已經開始走在前面。System.Net.Http.HttpMethod.Query 和 ASP.NET Core 的 HttpMethods.IsQuery 都已出現在官方文件。Kreya 1.20 也已經把 QUERY 加進 API 測試工具的支援範圍。OpenAPI 3.2 則能在 spec 裡描述 query operation。

這些訊號很重要,代表工具鏈開始動了。

但第二種世界是 public API,尤其是 browser client。這裡我會保守很多。

不急著換

- 使用者需要分享網址或 bookmark
- SEO 是需求的一部分
- public browser API
- 中間有未知 CDN / WAF / API gateway
- 團隊還沒驗證 CORS preflight
- 只是簡單 filter / pagination

公開 API 的問題不是你能不能在 controller 裡接到 QUERY。問題是整條路徑上的每個角色都要理解它。瀏覽器、preflight、gateway、WAF、observability、cache、文件、SDK、測試工具,只要有一段還停在「我只認得 GET/POST/PUT/DELETE」的年代,你就得準備 fallback。

比較務實的遷移方式:三軌並行

我不會建議把所有 POST /search 一次改成 QUERY

比較務實的設計,是三軌並行:

GET /products?status=active&page=1
  → 簡單查詢、可分享、可 bookmark

POST /products/search
  → 既有複雜查詢 fallback,支援舊 client

QUERY /products
  → 新 client、service-to-service、可控環境中的複雜查詢

OpenAPI 3.2 裡可以把 QUERY 寫進文件:

paths:
  /products:
    get:
      summary: Simple product filtering
    query:
      summary: Advanced product search
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                filter:
                  type: object
                sort:
                  type: array
                  items:
                    type: string

但文件支援不等於整條鏈路支援。OpenAPI 3.2 可以描述 QUERY,不代表你的 gateway 已經願意放行它,也不代表你的 SDK generator、mock server、contract test、API client 都準備好了。

真正的導入檢查表應該長這樣:

QUERY readiness checklist

Client
  □ HTTP client 可以送 QUERY + body
  □ method 大小寫不會被奇怪 normalization 破壞
  □ retry policy 把 QUERY 視為 idempotent

Browser / CORS
  □ OPTIONS preflight 正確回應
  □ Access-Control-Allow-Methods 包含 QUERY
  □ error monitoring 能分辨 preflight 失敗

Gateway / WAF / Proxy
  □ method allowlist 包含 QUERY
  □ request body 不會被丟棄
  □ security rules 不會把 QUERY 當未知攻擊

Cache
  □ cache key 納入 request body
  □ Content-Type / Accept / Vary 規則明確
  □ normalization 規則和 application 語意一致

Observability
  □ logs 顯示 QUERY method
  □ tracing / metrics 能分組
  □ audit policy 知道 body 可能含查詢條件

Documentation
  □ OpenAPI / SDK / 測試工具支援
  □ fallback 策略寫清楚
  □ client migration guide 說明何時用 GET、POST、QUERY

這份清單看起來很長,因為它本來就不只是改一個 controller attribute。

QUERY 的價值不是取代 POST,而是讓系統說實話

POST /search 會繼續存在很久。它太好用,也太相容。很多 public API 甚至應該繼續保留它,直到瀏覽器、gateway、WAF、CDN、SDK 和文件工具都追上來。

RFC 10008 的意義不在於明天把所有 search endpoint 改名。它真正補上的,是一個讓 HTTP 基礎設施看懂意圖的機會。

這不是建立資源。

不是送出命令。

也不是偷偷改狀態。

這只是一個查詢。

二十年後,HTTP 終於有一個 method 可以誠實地說出這句話。

參考資料