一個沒有記憶的通訊協定
在上一篇裡,你看到 HTTP 是一場「請求—回應」的對話:瀏覽器用一個方法與一條路徑發問,伺服器以一個狀態碼與一個內文回答,然後就結束了。但這裡有個讓多數初學者吃驚的部分。HTTP 是無狀態的:每一個請求都完全獨立自存,伺服器不記得你一秒鐘前問過什麼。下一個請求抵達時,彷彿來自一個全然的陌生人。這是刻意設計,不是意外。
為什麼要刻意健忘?因為「忘記」能擴展。無狀態協定意味著一群伺服器裡的任何一台都能處理任何請求,因為沒有任何一台握著關於你的私人筆記。讓一台伺服器當機,什麼也不會丟失;在負載平衡器後面再加十台,它們立刻就能互相替換。但代價很明顯:購物車、已登入的工作階段、一張填到一半的表單,這些東西在純粹的無狀態世界裡都無法靠自己存活。我們需要一種方法,能在請求之間攜帶一點點記憶,卻又不放棄「忘記」帶來的自由。
Cookie:瀏覽器帶回去的一張小票根
解法簡單得令人愉快,就像一張寄物處的號碼牌。你第一次造訪時,伺服器遞給你的瀏覽器一個小代幣,並說:拿好它,往後每一次請求都把它出示給我看。那個代幣就是 HTTP cookie。伺服器用一個 Set-Cookie 回應標頭設定它;從此以後,瀏覽器在每一個送回那個網站的請求裡,都自動把它附在一個 Cookie 請求標頭中。記憶是每次重新拼湊出來的——靠的不是協定,而是一個瀏覽器忠實回傳的值。
把這次往返具體想像一下。你的第一個請求,比方說 GET /login,沒帶任何 cookie。伺服器的回應加上一行像 Set-Cookie: session=8fa3b1; HttpOnly; Secure; SameSite=Lax,把這個值種進你的瀏覽器。從此以後,每一個後續請求——GET /cart、GET /orders 等等——都自動帶上 Cookie: session=8fa3b1。伺服器讀到 8fa3b1,查了表,心想:啊,這是 Mia。票根送出去一次,往後就永遠帶回來。
有兩種風味值得區分清楚。一種工作階段 cookie 通常只帶一個隨機、不透明的 id,例如 8fa3b1,它本身毫無意義;真正的資料——你的名字、你的購物車——住在伺服器的工作階段儲存區裡,而那個 id 只是用來查表的鑰匙。另一種風格則把實際資料塞進 cookie 本身(通常是一個經過簽章的代符)。前者讓 cookie 很小,但需要伺服器端的儲存;後者自給自足,但瀏覽器每一個請求都得把整包帶上。這還是那個老掉牙的設計拉鋸:你要把狀態放在哪裡,靠近用戶端還是靠近伺服器?
Cookie 老實,但不安全
對 cookie 究竟是什麼要看得清楚:它就是用戶端儲存、再送回的一個普通字串,僅此而已。它本身證明不了什麼、隱藏不了什麼、也阻止不了什麼。如果那個 id 容易被猜,攻擊者就能猜中;如果有人偷走了 cookie,他就變成了你,因為伺服器永遠只看到那張票根,從來看不到握著它的人。Cookie 是個老實的信差,不是保鏢。
這正是為什麼 Set-Cookie 那一行裡的小屬性如此重要。Secure 告訴瀏覽器只在 HTTPS 上送出這個 cookie,這樣它就絕不會暴露在任何人都能在線路上讀到的明文連線中。HttpOnly 把它藏起來不讓頁面的 JavaScript 看到,鈍化惡意指令稿的竊取。SameSite 限制這個 cookie 在「由其他網站發起的請求」中隨行的時機,藉此防禦一類跨站偽造攻擊。這些屬性沒有一個能靠自己讓 cookie 變成祕密;它們是疊在那個真正在網路上提供機密性的東西——TLS(也就是把 HTTP 變成 HTTPS 的同一個 TLS)——之上的護欄。
從網頁到網路 API:REST
到目前為止,HTTP 一直在遞送網頁給人類閱讀。但同一個協定也極擅長讓兩支程式對話——手機 app 抓取你的訊息、天氣小工具拉取一份預報。當 HTTP 被當成給機器(而非瀏覽器)使用的乾淨介面時,我們通常把它塑造成一種叫 REST(表現層狀態轉換)的風格。REST 不是新協定,也不是某個軟體;它是一套「把樸實老舊的 HTTP 用好」的慣例。
核心想法是:把你的服務所提供的一切都模型化為一個資源,並給每個資源一個位址,一個 URL。一個使用者、一筆訂單、一張照片、一整份照片清單:每一個都是有自己 URL 的東西。接著,與其發明上百個自訂指令,你重用 HTTP 既有的動詞——上一篇談過的請求方法——當成「你能對任何資源做什麼」的那一小套固定詞彙。名詞在 URL 裡;動詞就是方法。
Resource: a collection of orders, and one order inside it GET /orders -> read the list of orders (200 OK) POST /orders -> create a new order (201 Created) GET /orders/42 -> read order #42 (200 OK) PUT /orders/42 -> replace order #42 (200 OK) DELETE /orders/42 -> remove order #42 (204 No Content) GET /orders/999 -> there is no such order (404 Not Found) Nouns live in the URL; the verb is the HTTP method; the outcome is an HTTP status code. No new protocol needed.
有兩個設計選擇讓人樂於在這之上開發。第一,REST 倚靠的正是本篇開頭那種無狀態:每一次 API 呼叫都帶上伺服器所需的一切(常常包含一個放在 HTTP 標頭裡的驗證代符),所以伺服器不必記得上一次呼叫。第二,結果的意義就承載在標準的狀態碼裡,所以 200、201、404、500 對每一個用戶端都是同一個意思,沒有人需要去讀一本自訂手冊。重用網頁既有的詞彙、而不是發明一套私房話,正是全部的勝利所在。
把它兜起來:一個已登入的請求
讓我們追蹤單獨一次點擊——你在手機 app 裡打開你的訂單——看 cookie(或代符)、無狀態與 REST 如何同時運作。注意一個健忘的協定是怎麼裝得像它記得你的。
- 稍早,你登入過一次。伺服器檢查了你的密碼,並在一個 cookie 裡種下一個工作階段 id(或交給 app 一個代符)。那唯一一次檢查,是伺服器真正驗證你的唯一時刻。
- 現在 app 發出一個 REST 呼叫:GET /orders,cookie 或一個 Authorization 標頭隨行其中,用來說明是誰在問。名詞在 URL 裡,動詞在方法裡。
- 伺服器對你毫無記憶,於是讀取 cookie 的 id,到它的工作階段儲存區裡查表,瞬間重建出:這是 Mia。無狀態被回復了——只為了這一個請求。
- 它回傳 200 OK,內文裡是你的訂單清單,然後立刻又把你忘了。下一次點擊會從頭再跳一遍整支舞,這正是為什麼伺服器的任何一台機器都本可以回答。
這就是應用層那份安靜的優雅。HTTP 保持簡單而健忘,所以它能擴展;一個小小的 cookie 或代符在剛好的時機重建出剛好夠用的記憶;而 REST 借用 HTTP 自己的動詞與狀態碼,讓機器不必學一種新語言就能對話。下一篇我們會看到,這場對話底下的連線是如何演進的——從 HTTP/1.1,到 HTTP/2 的多工,再到跑在 QUIC 之上的 HTTP/3——好讓這裡的每一次往返都更快。