HTTP

以 C Socket 實作 HTTP Client

至今已介紹利用 表單 與其 方法欺騙 的方式送出請求,
以及 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 請求 並 接收回應。
C 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 位址,並藉此訪問:
dns-example
 
不像 瀏覽器 或其他 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 程式了 !
 
完整範例 在此 👾👾👾。
 
 

作者: 鄭中勝

喜愛音樂,但不知為何總在打程式 ?
期許能重新審視、整理自身所學,幫助有需要的人。

發表迴響