至今已介紹利用 表單 與其 方法欺騙 的方式送出請求,
以及 JavaScript 發送請求的方式 (I) XMLHttpRequest、(II) jQuery Ajax、(III) Fetch API。
也示範 Java Retrofit 使用 內容編碼 (Content-Encoding),
和 上一篇 中介紹的各種 cmd-line 與圖形化工具 !
那我們如何製作一個 HTTP Client 呢 ?
HTTP 是架構於 TCP/IP 之上的 應用層,
理論上,任何支援 TCP/IP 的 IPC,都有能力實作。
本篇以常見的 Socket 為例,
示範如何發送 HTTP 請求 並 接收回應。
目錄
前情提要
就像許多遊戲要先打「輸出」的道理 😂 ?
首先,得先思考:
輸出什麼 ?
訊息格式 (Message Format)
發送錯誤的訊息格式,通常會被 Server 拒絕,
且可能 少一行也不行,
這也是為何 (3-1) 花費大量篇幅介紹 — 訊息格式 (Message Format) :
所有訊息都是由:
- 起始行 (start-line) 開始
- 0 或多個 表頭欄位 (header-field) + CRLF
[合稱為 表頭 (headers) 或是 表頭部分 (header section)] - 再加上一個 CRLF
- 最後是 可選的 (optional) 訊息主體 (message-body)
HTTP-message = start-line
*( header-field CRLF )
CRLF
[ message-body ]
請求訊息 (request message) 範例 :
(顏色對應上方格式)
POST /?id=1 HTTP/1.1
Host: echo.paw.cloud
Content-Type: application/json; charset=utf-8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:53.0) Gecko/20100101 Firefox/53.0
Connection: close
Content-Length: 136
{
"status": "ok",
"extended": true,
"results": [
{"value": 0, "type": "int64"},
{"value": 1.0e+3, "type": "decimal"}
]
}
總之,無論是 HTTP 還是 TCP、IP、WebSocket…,
先理解 封包 (訊息) 格式,是大有益處的!
[註]:
另外,HTTP/2 則進一步,將表頭欄位壓縮為 二進制 的有序串列,
稱為 表頭區塊片段 (Header Block Fragment)。
2 個 CRLF
這是另一個簡易的 請求訊息:
GET / HTTP/1.1
Host: example.com
以字串表示:
"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
可以看到,最後一個表頭欄位 的 CRLF,
加上做為 表頭 與 訊息主體 分隔 的 CRLF:
是 連續 2 個 CRLF !
是 連續 2 個 CRLF !
是 連續 2 個 CRLF !
別覺得廢話 😂,看過好多人死在這裡 ! 實作上務必注意。
Socket Demo
有了以上先前知識,便能進入程式的部分啦 !
目標同上例請求訊息,對 https://example.com/ 發送基本的 GET 請求:
GET / HTTP/1.1
Host: example.com
目標 URI
首先,準備好 目標 URI 字串,
以供連線與 Host 表頭欄位 使用:
char *host = "example.com"; // Server URI
char *PORT_NUM = "80"; // HTTP port
請求/回應訊息
接著,配置 請求/回應訊息 字串 的 記憶體空間,
其中 0xfff (16 進制) 即為十進制的 4095,並無特殊意義。
而 headerFmt 是等著被注入值 的 格式化字串 (e.g., %s),
CRLF 是表頭部分結束時,務必 加入的空行,
char request[0xfff], response[0xfff]; // 請求 與 回應訊息
char *requestLine = "GET / HTTP/1.1\r\n"; // 請求行
char *headerFmt = "Host: %s\r\n"; // Host 表頭欄位
char *CRLF = "\r\n"; // 表頭後的 CRLF
連接字串 (Concatenate Strings)
如 (3-1) 訊息格式 (Message Format) 所述,排除 分隔符號 以外,
表頭欄位 (header-field) 是基於拉丁字母的 ASCII 碼。
我們能以 連接字串 (Concatenate Strings) 的方式,
輕易製作 請求訊息 (request message),並透過 Socket 以 TCP/IP 發送!
strlen vs. sizeof
在以 strcat 連接字串 前,
得先計算 表頭 (header) 會用到的字串長度 (strlen),以便 malloc 動態配置記憶體,
(小心 ! 別使用 sizeof,其用於字串,回傳的是 指標 or 陣列大小)
/// 動態配置記憶體,已決定 表頭緩衝區 (Buffer) 長度
size_t bufferLen = strlen(header_fmt) + strlen(host) + 1;
char *buffer = (char *) malloc(bufferLen);
//組裝請求訊息
strcpy(request, requestLine);
sprintf(buffer, headerFmt, host);
strcat(request, buffer);
strcat(request, CRLF);
請求訊息完成 😉。
[註]:
長度計算最後的 + 1,是為準備字串終止字元『 \0 』,
是大部分 malloc 字串的 必要動作 (儘管此範例已多算格式化字串的 %s):
[註]:
這裡示範擴充欄位的思路,因此未直接使用現成字串:
"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
malloc vs. free
然而,別忘記了:
有 malloc 就得有 free !
務必養成:利用 free()『 釋放不再需要的「動態記憶體」 』的習慣,
以歸還 堆積 (heap) 記憶體,供應用程式後續使用:
// 釋放緩衝區記憶體
free(buffer);
buffer = NULL;
至於,將釋放後的 指標 指定給 NULL,
是為防止 懸置指標 (Dangling pointer) 的問題,為個人小習慣 😀。
取得 IP
(2-1) 統一資源識別符 (URI):
若使用的是經註冊的網域名稱 (Domain Name),
例: 「example.org」、「jason.party」…,
會先透過 名稱解析服務 (ex: DNS),
找到 Server (或 代理、閘道、負載平衡器) 的 IP 位址,並藉此訪問:
不像 瀏覽器 或其他 user agent 幫我們做好了,輸入網址便能連線,
Internet Domain Socket 需由 IP 位址 與 port 組成 😨。
別擔心!
欲由 Host 取得 IP 位址,可以使用強大的 getaddrinfo() !
使用 getaddrinfo 將取得 addrinfo 結構 之 鏈結串列 (Linked List),
並將之至於第 4 個參數 — 結構指標 &result :
// hints 參數,設定 getaddrinfo() 的回傳方式
struct addrinfo hints;
// getaddrinfo() 執行結果的 addrinfo 結構指標
struct addrinfo *result;
// 以 memset 清空 hints 結構
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC; // 使用 IPv4 or IPv6
hints.ai_socktype = SOCK_STREAM; // 串流 Socket
hints.ai_flags = AI_NUMERICSERV; // 將 getaddrinfo() 第 2 參數 (PORT_NUM) 視為數字
getaddrinfo(host, PORT_NUM, &hints, &result);
欲取得通用的 socket 位址結構 — sockaddr,即可藉由:
result->ai_addr
IP 位址便在其中 😆 (Network ByteOrder),可透過 inet_ntop() 轉為熟悉的字串。
Socket
訊息 與 IP 準備好之後,便能進入 Socket 的部分了 😆 !
檔案描述符
利用 socket() 系統呼叫,分別配置 domain, type, protocol,
(從 getaddrinfo 的 result 取得)
建立 socket 檔案描述符 (Socket File Descriptor) — cfd :
int cfd = socket(result->ai_family, result->ai_socktype, 0);
建立連線
接著,便能以 connect() 與彼端建立連線:
if (connect(cfd, result->ai_addr, result->ai_addrlen) < 0) {
errExit("Connect");
}
連線成功後,當位址資訊不再需要時,
別忘記以 freeaddrinfo() 釋放 getaddrinfo 配置的動態 記憶體:
// 釋放 getaddrinfo (Linked List) 記憶體空間
freeaddrinfo(result);
result = NULL;
發送/接收
連線完成後,僅需 發送/接收 訊息,便大功告成 😆:
// 格式化輸出請求訊息
printf("----------\nRequest:\n----------\n%s\n", request);
// 發送請求
if (send(cfd, request, strlen(request), 0) < 0)
errExit("Send");
// 接收回應
if (recv(cfd, response, 0xfff, 0) < 0)
errExit("Receive");
// 格式化輸出回應訊息
printf("----------\nResponse:\n----------\n%s\n", response);
Console:
----------
Request:
----------
GET / HTTP/1.1
Host: example.com
----------
Response:
----------
HTTP/1.1 200 OK
Cache-Control: max-age=604800
Content-Type: text/html
Date: Thu, 22 Jun 2017 02:54:54 GMT
Etag: "359670651+gzip+ident"
Expires: Thu, 29 Jun 2017 02:54:54 GMT
Last-Modified: Fri, 09 Aug 2013 23:54:35 GMT
Server: ECS (rhv/818F)
Vary: Accept-Encoding
X-Cache: HIT
Content-Length: 1270
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
... 略
關閉連線
如同記憶體『 有 配置 (malloc) 就得 釋放 (free) 』,
Socket 連線完成需記得 關閉連線 !
可透過簡易的 close() 關閉通訊的兩端,
或使用更具彈性的 shutdown() 選擇關閉模式:
// 半雙工關閉 TCP Socket 連線
// (i.e., 關閉寫入)
shutdown(cfd, SHUT_WR);
總結
此篇重點並非刻一完整 HTTP Client,
而是 訊息 (封包) 格式 的用途 及 訊息交換 原理,
從而帶出 語意差異 (Semantic Difference) 的重要性 !
便能了解 POST 與 GET 請求訊息 的 真實差異,僅在:
一個是 POS 一個是 GE 😑…。
// 詳見 (4-2) GET vs. POST.
理解事物本質,才能『 以不變應萬變 』。
實作 HTTPS 或其他應用層協議,基本上大同小異,
相信理解此篇後,任何 語言、工具、IPC…到你手上,
皆能用來製作 Client-side 程式了 !
完整範例 在此 👾👾👾。