GET 和 POST 到底有什麼區別?

slowlydance2me發表於2023-04-06

HTTP最早被用來做瀏覽器與伺服器之間互動HTML和表單的通訊協議;後來又被被廣泛的擴充到介面格式的定義上。所以在討論GET和POST區別的時候,需要現確定下到底是瀏覽器使用的GET/POST還是用HTTP作為介面傳輸協議的場景。

 

瀏覽器的GET和POST

  這裡特指瀏覽器中非Ajax的HTTP請求,即從HTML和瀏覽器誕生就一直使用的HTTP協議中的GET/POST。瀏覽器用GET請求來獲取一個html頁面/圖片/css/js等資源;用POST來提交一個<form>表單,並得到一個結果的網頁。

 

瀏覽器將GET和POST定義為:


GET

“讀取“一個資源。比如Get到一個html檔案。反覆讀取不應該對訪問的資料有副作用。比如”GET一下,使用者就下單了,返回訂單已受理“,這是不可接受的。沒有副作用被稱為“冪等“(Idempotent)。

因為GET因為是讀取,就可以對GET請求的資料做快取。這個快取可以做到瀏覽器本身上(徹底避免瀏覽器發請求),也可以做到代理上(如nginx),或者做到server端(用Etag,至少可以減少頻寬消耗)

POST

在頁面裡<form> 標籤會定義一個表單。點選其中的submit元素會發出一個POST請求讓伺服器做一件事。這件事往往是有副作用的,不冪等的。

不冪等也就意味著不能隨意多次執行。因此也就不能快取。比如透過POST下一個單,伺服器建立了新的訂單,然後返回訂單成功的介面。這個頁面不能被快取。試想一下,如果POST請求被瀏覽器快取了,那麼下單請求就可以不向伺服器發請求,而直接返回本地快取的“下單成功介面”,卻又沒有真的在伺服器下單。那是一件多麼滑稽的事情。

因為POST可能有副作用,所以瀏覽器實現為不能把POST請求儲存為書籤。想想,如果點一下書籤就下一個單,是不是很恐怖?。

此外如果嘗試重新執行POST請求,瀏覽器也會彈一個框提示下這個重新整理可能會有副作用,詢問要不要繼續。


當然,伺服器的開發者完全可以把GET實現為有副作用;把POST實現為沒有副作用。只不過這和瀏覽器的預期不符。把GET實現為有副作用是個很可怕的事情。 我依稀記得很久之前百度貼吧有一個因為使用GET請求可以修改管理員的許可權而造成的安全漏洞。反過來,把沒有副作用的請求用POST實現,瀏覽器該彈框還是會彈框,對使用者體驗好處改善不大。

但是後邊可以看到,將HTTP POST作為介面的形式使用時,就沒有這種彈框了。於是把一個POST請求實現為冪等就有實際的意義。POST冪等能讓很多業務的前後端互動更順暢,以及避免一些因為前端bug,觸控失誤等帶來的重複提交。將一個有副作用的操作實現為冪等必須得從業務上能定義出怎麼就算是“重複”。如提交資料中增加一個dedupKey在一個交易會話中有效,或者利用提交的資料裡可以天然當dedupKey的欄位。這樣萬一使用者強行重複提交,伺服器端可以做一次防護。

GET和POST攜帶資料的格式也有區別。當瀏覽器發出一個GET請求時,就意味著要麼是使用者自己在瀏覽器的位址列輸入,要不就是點選了html裡a標籤的href中的url。所以其實並不是GET只能用url,而是瀏覽器直接發出的GET只能由一個url觸發。所以沒辦法,GET上要在url之外帶一些引數就只能依靠url上附帶querystring。但是HTTP協議本身並沒有這個限制。

瀏覽器的POST請求都來自表單提交。每次提交,表單的資料被瀏覽器用編碼到HTTP請求的body裡。瀏覽器發出的POST請求的body主要有有兩種格式,一種是application/x-www-form-urlencoded用來傳輸簡單的資料,大概就是"key1=value1&key2=value2"這樣的格式。另外一種是傳檔案,會採用multipart/form-data格式。採用後者是因為application/x-www-form-urlencoded的編碼方式對於檔案這種二進位制的資料非常低效。

瀏覽器在POST一個表單時,url上也可以帶引數,只要<form action="url" >裡的url帶querystring就行。只不過表單裡面的那些用<input> 等標籤經過使用者操作產生的資料都在會在body裡。

因此我們一般會泛泛的說“GET請求沒有body,只有url,請求資料放在url的querystring中;POST請求的資料在body中“。但這種情況僅限於瀏覽器發請求的場景。

 

介面中的GET和POST

這裡是指透過瀏覽器的Ajax api,或者iOS/Android的App的http client,java的commons-httpclient/okhttp或者是curl,postman之類的工具發出來的GET和POST請求。此時GET/POST不光能用在前端和後端的互動中,還能用在後端各個子服務的呼叫中(即當一種RPC協議使用)。儘管RPC有很多協議,比如thrift,grpc,但是http本身已經有大量的現成的支援工具可以使用,並且對人類很友好,容易debug。HTTP協議在微服務中的使用是相當普遍的。

當用HTTP實現介面傳送請求時,就沒有瀏覽器中那麼多限制了,只要是符合HTTP格式的就可以發。HTTP請求的格式,大概是這樣的一個字串(為了美觀,我在\r\n後都換行一下):

<METHOD> <URL> HTTP/1.1\r\n
<Header1>: <HeaderValue1>\r\n
<Header2>: <HeaderValue2>\r\n
...
<HeaderN>: <HeaderValueN>\r\n
\r\n
<Body Data....>

其中的“<METHOD>"可以是GET也可以是POST,或者其他的HTTP Method,如PUT、DELETE、OPTION……。從協議本身看,並沒有什麼限制說GET一定不能沒有body,POST就一定不能把參放到<URL>的querystring上。因此其實可以更加自由的去利用格式。比如Elastic Search的_search api就用了帶body的GET;也可以自己開發介面讓POST一半的引數放在url的querystring裡,另外一半放body裡;你甚至還可以讓所有的引數都放Header裡——可以做各種各樣的定製,只要請求的客戶端和伺服器端能夠約定好。

當然,太自由也帶來了另一種麻煩,開發人員不得不每次討論確定引數是放url的path裡,querystring裡,body裡,header裡這種問題,太低效了。於是就有了一些列介面規範/風格。其中名氣最大的當屬REST。REST充分運用GET、POST、PUT和DELETE,約定了這4個介面分別獲取、建立、替換和刪除“資源”,REST最佳實踐還推薦在請求體使用json格式。這樣僅僅透過看HTTP的method就可以明白介面是什麼意思,並且解析格式也得到了統一。

json相對於x-www-form-urlencoded的優勢在於1)可以有巢狀結構;以及 2)可以支援更豐富的資料型別。透過一些框架,json可以直接被伺服器程式碼對映為業務實體。用起來十分方便。但是如果是寫一個介面支援上傳檔案,那麼還是multipart/form-data格式更合適。

REST中GET和POST不是隨便用的。在REST中, 【GET】 + 【資源定位符】被專用於獲取資源或者資源列表,比如:

GET http://foo.com/books          獲取書籍列表
GET http://foo.com/books/:bookId  根據bookId獲取一本具體的書

與瀏覽器的場景類似,REST GET也不應該有副作用,於是可以被反覆無腦呼叫。瀏覽器(包括瀏覽器的Ajax請求)對於這種GET也可以實現快取(如果伺服器端提示了明確需要Caching);但是如果用非瀏覽器,有沒有快取完全看客戶端的實現了。當然,也可以從整個App角度,也可以完全繞開瀏覽器的快取機制,實現一套業務定製的快取框架

關於安全性

我們常聽到GET不如POST安全,因為POST用body傳輸資料,而GET用url傳輸,更加容易看到。但是從攻擊的角度,無論是GET還是POST都不夠安全,因為HTTP本身是明文協議每個HTTP請求和返回的每個byte都會在網路上明文傳播,不管是url,header還是body。這完全不是一個“是否容易在瀏覽器位址列上看到“的問題。

為了避免傳輸中資料被竊取,必須做從客戶端到伺服器的端端加密。業界的通行做法就是https——即用SSL協議協商出的金鑰加密明文的http資料。這個加密的協議和HTTP協議本身相互獨立。如果是利用HTTP開發公網的站點/App,要保證安全,https是最最基本的要求。

當然,端端加密並不一定非得用https。比如國內金融領域都會用私有網路,也有GB的加密協議SM系列。但除了軍隊,金融等特殊機構之外,似乎並沒有必要自己發明一套類似於ssl的協議。

回到HTTP本身,的確GET請求的引數更傾向於放在url上,因此有更多機會被洩漏。比如攜帶私密資訊的url會展示在位址列上,還可以分享給第三方,就非常不安全了。此外,從客戶端到伺服器端,有大量的中間節點,包括閘道器,代理等。他們的access log通常會輸出完整的url,比如nginx的預設access log就是如此。如果url上攜帶敏感資料,就會被記錄下來。但請注意,就算私密資料在body裡,也是可以被記錄下來的,因此如果請求要經過不信任的公網,避免洩密的唯一手段就是https。這裡說的“避免access log洩漏“僅僅是指避免可信區域中的http代理的預設行為帶來的安全隱患。比如你是不太希望讓自己公司的運維同學從公司主閘道器的log裡看到使用者的密碼吧。

另外,上面講過,如果是用作介面,GET實際上也可以帶body,POST也可以在url上攜帶資料。所以實際上到底怎麼傳輸私密資料,要看具體場景具體分析。當然,絕大多數場景,用POST + body裡寫私密資料是合理的選擇。一個典型的例子就是“登入”:
POST http://foo.com/user/login
{
  "username": "admin",
  "passowrd": "12345678"
}
安全是一個巨大的主題,有由很多細節組成的一個完備體系,比如返回私密資料的mask,XSS,CSRF,跨域安全,前端加密,釣魚,salt,…… POST和GET在安全這件事上僅僅是個小角色。因此單獨討論POST和GET本身哪個更安全意義並不是太大。只要記得一般情況下,私密資料傳輸用POST + body就好。

關於編碼

常見的說法有,比如GET的引數只能支援ASCII,而POST能支援任意binary,包括中文。但其實從上面可以看到,GET和POST實際上都能用url和body。因此所謂編碼確切地說應該是http中url用什麼編碼,body用什麼編碼。

先說下url。url只能支援ASCII的說法源自於RFC1738

Thus, only alphanumerics, the special characters "$-_.+!*'(),", and
reserved characters used for their reserved purposes may be used
unencoded within a URL.

實際上這裡規定的僅僅是一個ASCII的子集[a-zA-Z0-9$-_.+!*'(),]。它們是可以“不經編碼”在url中使用。比如儘管空格也是ASCII字元,但是不能直接用在url裡。

那這個“編碼”是什麼呢?如果有了特殊符號和中文怎麼辦呢?一種叫做percent encoding的編碼方法就是幹這個用的:

這也就是為啥我們偶爾看到url裡有一坨%和16位數字組成的序列。

使用Percent Encoding,即使是binary data,也是可以透過編碼後放在URL上的。

 

但要特別注意,這個編碼方式只管把字元轉換成URL可用字元,但是卻不管字符集編碼(比如中文到底是用UTF8還是GBK)這塊早期一直都相當亂,也沒有什麼統一規範。比如有時跟網頁編碼一樣,有的是作業系統的編碼一樣。最要命的是瀏覽器的位址列是不受開發者控制的。這樣,對於同樣一個帶中文的url,如果有的瀏覽器一定要用GBK(比如老的IE8),有的一定要用UTF8(比如chrome)。後端就可能認不出來。對此常用的辦法是避免讓使用者輸入這種帶中文的url。如果有這種形式的請求,都改成使用者介面上輸入,然後透過Ajax發出的辦法。Ajax發出的編碼形式開發者是可以100%控制的。

不過目前基本上utf8已經大一統了。現在的開發者除非是被國家規定要求一定要用GB系列編碼的場景,基本上不會再遇到這類問題了。

順便說一句,儘管在瀏覽器位址列可以看到中文。但這種url在傳送請求過程中,瀏覽器會把中文用字元編碼+Percent Encode翻譯為真正的url,再發給伺服器。瀏覽器位址列裡的中文只是想讓使用者體驗好些而已。

再討論下Body。HTTP Body相對好些,因為有個Content-Type來比較明確的定義。比如:

POST xxxxxx HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded ; charset=UTF-8

這裡Content-Type會同時定義請求body的格式(application/x-www-form-urlencoded)和字元編碼(UTF-8)。

所以body和url都可以提交中文資料給後端,但是POST的規範好一些,相對不容易出錯,容易讓開發者安心。對於GET+url的情況,只要不涉及到在老舊瀏覽器的位址列輸入url,也不會有什麼太大的問題。

回到POST,瀏覽器直接發出的POST請求就是表單提交,而表單提交只有application/x-www-form-urlencoded針對簡單的key-value場景;和multipart/form-data,針對只有檔案提交,或者同時有檔案和key-value的混合提交表單的場景。

如果是Ajax或者其他HTTP Client發出去的POST請求,其body格式就非常自由了,常用的有json,xml,文字,csv……甚至是你自己發明的格式。只要前後端能約定好即可。

瀏覽器的POST需要發兩個請求嗎?

上文中的"HTTP 格式“清楚的顯示了HTTP請求可以被大致分為“請求頭”和“請求體”兩個部分。使用HTTP時大家會有一個約定,即所有的“控制類”資訊應該放在請求頭中,具體的資料放在請求體裡“。於是伺服器端在解析時,總是會先完全解析全部的請求頭部。這樣,伺服器端總是希望能夠瞭解請求的控制資訊後,就能決定這個請求怎麼進一步處理,是拒絕,還是根據content-type去呼叫相應的解析器處理資料,或者直接用zero copy轉發。

比如在用Java寫服務時,請求處理程式碼總是能從HttpSerlvetRequest裡getParameter/Header/url。這些資訊都是請求頭裡的,框架直接就解析了。而對於請求體,只提供了一個inputstream,如果開發人員覺得應該進一步處理,就自己去讀取和解析請求體。這就能體現出伺服器端對請求頭和請求體的不同處理方式。

舉個實際的例子,比如寫一個上傳檔案的服務,請求url中包含了檔名稱,請求體中是個尺寸為幾百兆的壓縮二進位制流。伺服器端接收到請求後,就可以先拿到請求頭部,檢視使用者是不是有許可權上傳,檔名是不是符合規範等。如果不符合,就不再處理請求體的資料了,直接丟棄。而不用等到整個請求都處理完了再拒絕。

為了進一步最佳化,客戶端可以利用HTTP的Continued協議來這樣做:客戶端總是先傳送所有請求頭給伺服器,讓伺服器校驗。如果透過了,伺服器回覆“100 - Continue”,客戶端再把剩下的資料發給伺服器。如果請求被拒了,伺服器就回復個400之類的錯誤,這個互動就終止了。這樣,就可以避免浪費頻寬傳請求體。但是代價就是會多一次Round Trip。如果剛好請求體的資料也不多,那麼一次性全部發給伺服器可能反而更好。

基於此,客戶端就能做一些最佳化,比如內部設定一次POST的資料超過1KB就先只發“請求頭”,否則就一次性全發。客戶端甚至還可以做一些Adaptive的策略,統計傳送成功率,如果成功率很高,就總是全部發等等。不同瀏覽器,不同的客戶端(curl,postman)可以有各自的不同的方案。不管怎樣做,最佳化目的總是在提高資料吞吐和降低頻寬浪費上做一個折衷。

因此到底是發一次還是發N次,客戶端可以很靈活的決定。因為不管怎麼發都是符合HTTP協議的,因此我們應該視為這種最佳化是一種實現細節,而不用扯到GET和POST本身的區別上。更不要當個什麼世紀大發現。

到底什麼算請求體

看完了上面的內容後,讀者也許會對“什麼是請求體”感到困惑不已,比如x-www-form-endocded編碼的body算不算“請求體”呢?

從HTTP協議的角度,“請求頭”就是Method + URL(含querystring)+ Headers;再後邊的都是請求體。

但是從業務角度,如果你把一次請求立即為一個呼叫的話。比如上面的

POST http://foo.com/books
{
  "title": "wow",
  "author": "admin",
  ...
}

用Java寫大概等價於

createBook("wow", "admin");

那麼這一行函式名和兩個引數都可以看作是一個請求,不區分頭和體。即便用HTTP協議實現,title和author編碼到了HTTP請求體中。Java的HttpServletRequest支援用getParameter方法獲取x-www-url-form-encoded中的資料,表達的意思就是“請求“的”引數“。

對於HTTP,需要區分【頭】和【體】,Http Request和Http Response都這麼區分。Http這麼幹主要用作

  • 對於HTTP代理
    • 支援轉發規則,比如nginx先要解析請求頭,拿到URL和Header才能決定怎麼做(轉發proxy_pass,重定向redirect,rewrite後重新判斷……)
    • 需要用請求頭的資訊記錄log。儘管請求體裡的資料也可以記錄,但一般只記錄請求頭的部分資料。
    • 如果代理規則不涉及到請求體,那麼請求體的位元組流可以直接轉發,無需解析。而解析需要額外的記憶體和CPU。(具體的轉發形式要看是否是chunked編碼,代理buffering是否開啟等)
    • ……
  • 對於HTTP伺服器
    • 可以透過請求頭進行ACL控制,比如看看Athorization頭裡的資料是否能讓認證透過
    • 可以做一些攔截,比如看到Content-Length裡的數太大,或者Content-Type自己不支援,或者Accept要求的格式自己無法處理,就直接返回失敗了。
    • 如果body的資料很大,利用Stream API,可以方便支援一塊一塊的處理資料,而不是一次性全部讀取出來再操作,以至於佔用大量記憶體。
    • ……

但從高一級的業務角度,我們在意的其實是【請求】和【返回】。當我們在說“請求頭”這三個字時,也許實際的意思是【請求】。而用HTTP實現【請求】時,可能僅僅用到【HTTP的請求頭】(比如大部分GET請求),也可能是【HTTP請求頭】+【HTTP請求體】(比如用POST實現一次下單)。

總之,這裡有兩層,不要混哦。

關於URL的長度

因為上面提到了不論是GET和POST都可以使用URL傳遞資料,所以我們常說的“GET資料有長度限制“其實是指”URL的長度限制“。

HTTP協議本身對URL長度並沒有做任何規定。實際的限制是由客戶端/瀏覽器以及伺服器端決定的。

先說瀏覽器。不同瀏覽器不太一樣。比如我們常說的2048個字元的限制,其實是IE8的限制。並且原始檔案的說的其實是“URL的最大長度是2083個字元,path的部分最長是2048個字元“。見。IE8之後的IE URL限制我沒有查到明確的檔案,但有些資料稱IE 11的位址列只能輸入法2047個字元,但是允許使用者點選html裡的超長URL。我沒實驗,哪位有興趣可以試試。

Safari,Firefox等瀏覽器也有自己的限制,但都比IE大的多,這裡就不挨個列出了。

然而新的IE已經開始使用Chrome的核心了,也就意味著“瀏覽器端URL的長度限制為2048字元”這種說法會慢慢成為歷史。

其他的客戶端,比如Java的,js的http client大多數也並沒有限制URL最大有多長。

除了瀏覽器,伺服器這邊也有限制,比如apache的LimieRequestLine指令。

apache實際上限制的是HTTP請求第一行“Request Line“的長度,即<METHOD><URL> <VERSION>那一行。

再比如nginx用large_client_header_buffers 指令來分配請求頭中的很長資料的buffer。這個buffer可以用來處理url,header value等。

Tomcat的限制是web.xml裡maxHttpHeaderSize來設定的,控制的是整個“請求頭”的總長度。

為啥要限制呢?如果寫過解析一段字串的程式碼就能明白,解析的時候要分配記憶體。對於一個位元組流的解析,必須分配buffer來儲存所有要儲存的資料。而URL這種東西必須當作一個整體看待,無法一塊一塊處理,於是就處理一個請求時必須分配一整塊足夠大的記憶體。如果URL太長,而併發又很高,就容易擠爆伺服器的記憶體;同時,超長URL的好處並不多,我也只有處理老系統的URL時因為不敢碰原來的邏輯,又得追加更多資料,才會使用超長URL。

對於開發者來說,使用超長的URL完全是給自己埋坑,需要同時要考慮前後端,以及中間代理每一個環節的配置。此外,超長URL會影響搜尋引擎的爬蟲,有些爬蟲甚至無法處理超過2000個位元組的URL。這也就意味著這些URL無法被搜到,坑爹啊。

其實並沒有太大必要弄清楚精確的URL最大長度限制。我個人的經驗是,只要某個要開發的資源/api的URL長度有可能達到2000個bytes以上,就必須使用body來傳輸資料,除非有特殊情況。至於到底是GET + body還是POST + body可以看情況決定。

留意,1個漢字字元經過UTF8編碼 + percent encoding後會變成9個位元組,別算錯哦。

總結

上面講了一大堆,是希望讀者不要死記硬背GET和POST的區別,而是能從更廣的層面去看待和思考這個問題。

最後,協議都是人定的。只要客戶端和伺服器能彼此認同,就能工作。在常規的情況下,用符合規範的方式去實現系統可以減少很多工作量——大家都約定好了,就不要折騰了。但是,總會有一些情況用常規規範不合適,不滿足需求。這時思路也不能被規範限制死,更不要死摳RFC。這些規範也許不能處理你遇到的特殊問題。比如:

  • Elastic Search的_search介面使用GET,卻用body來表達查詢,因為查詢很複雜,用querystring很麻煩,必須用json格式才舒服,在請求體用json編碼更加容易,不用折騰percent encoding。
  • 用POST寫一個介面下單時可能也要考慮冪等,因為前端可能實現“下單按鍵”有bug,造成使用者一次點選發出N個請求。你不能說因為POST by design應該是不冪等就不管了。

協議是死的,人是活的。遇到實際的問題時靈活的運用手上的工具滿足需求就好。

參考?
https://www.zhihu.com/question/28586791


相關文章