HTTP 客户端
HTTP 协议
HTTP 是 文本类(text-based) 的 客户端-服务器 协议, 建立在 TCP 之上, 默认端口号 80.
因为安全方面的原因, HTTP 基本上已经过时.
现在的网站应该使用 HTTPS, 运行在 TLS(Transport Layer Security) 之上的 HTTP.
首先, web 客户端向 web 服务器发送一个 HTTP 请求.
然后, web 服务器返回给客户端一个 HTTP 回应.
一般, HTTP 请求中表明客户感兴趣的资源, HTTP 回应中传输请求的资源.
HTTP 请求类型
常用 HTTP 请求类型 | |
---|---|
GET | 客户希望下载资源时使用. |
HEAD | 客户只是希望了解关于资源的某些信息时使用. |
如, 客户想知道某个托管文件的大小, 并不希望下载该文件. | |
POST | 客户需要向服务器发送信息时使用, 如发送在线表单(online form). |
POST 请求会导致服务器状态发生某些改变. | |
作为对 POST 请求的回应, 服务器可能会发送 email, 更新数据库, 修改某个文件. | |
其他 HTTP 请求类型 | |
PUT | 向服务器发送文档. |
不常用, 一般使用 POST. | |
DELETE | 请求服务器删除文档或资源. |
不常用, 一般使用 POST. | |
TRACE | 从代理(web proxy) 请求诊断信息. |
大多数请求并不经过代理, 很多代理也并不完全支持 TRACE. | |
CONNECT | 通过代理服务器初始化一个 HTTP 连接. |
OPTIONS | 对于某资源, 询问其服务器支持的 HTTP 请求类型. |
如果服务器实现了 OPTIONS, 其回应类似'ALLOW: OPTIONS, GET, HEAD, POST'. | |
很多服务器并不支持 OPTIONS. |
如果向服务器发送其不支持的请求, 服务器回应码应该是 '400 Bad Request'.
HTTP 请求格式
通过浏览器访问 http://itlabor.top/learning-area/test.hands-on.html.
浏览器向 itlabor.top 上运行的服务器发送一个 HTTP 请求.
该请求是 GET 类, 向服务器请求文档 /learning-area/test.hands-on.html.
请求内容:
GET /learning-area/test.hands-on.html HTTP/1.1
Host: itlabor.top
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1
GET 请求只包含 HTTP 头部(HTTP Header), 不含 HTTP 主体(HTTP body).
这是因为客户并未向服务器发送数据.
HTTP 请求的第一行被称为 请求行(request line).
请求行的几个组成部分, 请求类型, 文档路径, 协议版本, 之间用空格分隔.
关于行结束符(line ending), 不同操作系统有不同的约定.
HTTP 消息的每一行通过回车(return) 后跟换行(newline) 来结束, C 语言中写成 \r\n.
Host 告诉服务器从哪个主机上获取客户请求的资源.
一台服务器上可能托管着多个网站.
请求行( 第一行) 指明客户请求的文档, 但并没有指明文档来自哪个网站, 这由 Host 说明.
User-Agent 告诉服务器与其联系的软件是什么.
一些服务器对于不同的软件会提供不同的文档.
如, 服务器可能会区别对待 搜索引擎爬取机器人(search engine spider) 和 真实用户.
Connection: keep-alive 的含义是当前请求完成后, 客户会发出更多的请求.
Connection: close 则表明在收到 HTTP 回应后, 客户会关闭 TCP 连接.
HTTP 请求头部后跟一个空行.
空行向服务器表明 HTTP 请求结束, C 语言中, 这个空行写成 \r\n\r\n.
HTTP 回应格式
HTTP 回应同样由 头部 和 主体 构成.
回应也可以不含主体部分, 但大多数 HTTP 回应包括主体.
服务器对于 GET 请求的回应:
HTTP/1.1 200 OK
Server: nginx/1.20.1
Date: Sun, 13 Mar 2022 07:31:19 GMT
Content-Type: text/html
Content-Length: 4584
Last-Modified: Fri, 11 Mar 2022 08:29:59 GMT
Connection: keep-alive
ETag: "622b0887-11e8"
Accept-Ranges: bytes
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
...
HTTP 回应的第一行称为 状态行(status line).
状态行的几个部分, 协议版本, 回应码, 回应码的描述.
回应码及其描述是 200 OK, 表示一切正常.
如果服务器找不到客户请求的资源, 可能会返回 404 Page Not Found.
当客户缓存文档时会用到头部中的 Date, ETag, Expires, Last-Modified.
Content-Type, 表明资源类型, 以便客户采用合适的方式去解释.
Content-Length, HTTP 回应主体 包含的字节数.
HTTP 回应头部 和 HTTP 回应主体之间有一个空行.
主体未必是 文本类的, 如客户请求一张图片时, 主体应该是 二进制的数据.
如果主体是文本类的, 对于主体内的行结束符, 可以跟随系统的约定, 不必使用 \r\n.
HTTP 回应码
200 OK | 请求成功, 服务器已发送请求的资源. |
301 Moved Permanently | 请求的资源移动到了新位置. |
该位置由服务器回应的头部字段 Location 指明. | |
后续请求该资源时都应指向新位置. | |
307 Moved Temporarily | 请求的资源移动到了新位置. |
该位置由服务器回应的头部字段 Location 指明. | |
移动并不是永久的, 所以后续请求该资源时应指向原位置. | |
400 Bad Request | 服务器 不明白/不支持 客户的请求. |
401 Unauthorized | 关于请求的资源, 客户未经过授权. |
403 Forbidden | 禁止客户访问其请求的资源. |
500 Internal Server Error | 处理客户请求过程中, 服务器发生错误. |
确定 HTTP 回应主体的大小
如果服务器在回应头部中包含 Content-Length 行, 即表示服务器直接声明了回应主体的大小.
如果服务器在知道回应主体大小之前就开始发送数据, 就无法使用 Content-Length 行.
这时, 服务器可以在回应头部中发送 Transfer-Encoding: chunked 行.
这一行向客户表明回应主体将被分成多个独立的数据块(chunk) 进行发送.
关于块的编码, 每个块以一个 16 进制数字开始, 表示该块的大小.
然后是换行(newline), 数据.
整个回应主体以一个大小为 0 的块结束.
客户应该在解码出全部的块以后再对回应进行完整的解释.
关于 URL(Uniform Resource Locator, 统一资源定位符)
URL, 网址, 方便的指定特定网络资源.
http://www.xyz.com:80/res/page1.php?user=ali#account
http:// | 协议 | 不同的协议, 如 ftp://, https:// ... |
如果省略, 应用( 浏览器) 会作出事先设定好的假设. | ||
www.xyz.com | 主机名 | 将被解析成 IP 地址, 也可以直接用 IP 地址替代. |
IPv4 地址直接使用(http://192.168.0.1/). | ||
IPv6 地址外加一对方括号(http://[::1]/). | ||
:80 | 端口号 | 如果未指定, 将使用协议默认的端口号. |
http 协议默认端口号 80, https 协议默认端口号 443. | ||
非标准的端口号用于测试和开发. | ||
/res/page1.php?user=ali | 文档路径 | 服务器会区分问号之前和问号之后的部分. |
问号之后的部分被称为 查询字符串(query string). | ||
#account | 哈希(hash) | 指明文档内部的一个位置. |
哈希不会被发送给服务器. | ||
从服务器接收到完整的文档后, 使得浏览器滚动到文档的特定位置. |
实现一个 web 客户端
用到了几个辅助函数.
parse_url(), 用于 url 解析.
输入一个 URL, 返回主机名, 端口号, 文档路径.
为了避免手动内存管理, 以指针的形式返回, 指针指向输入的 URL 的特定位置.
send_request(), 用于发送请求.
connect_to_host(), 用于建立 TCP 连接.
- /*
- get.http.c
- **$ gcc get.http.c -o get.http
- **$ ./get.http http://www.itlabor.top/learning-area/test.hands-on.html
- ...
- **$ ./get.http aliyun.com
- ...
- */
- #include <stdio.h>
- #include <stdlib.h>
- #include <string.h>
- #include <stdbool.h>
- #include <time.h>
- #include "network.h"
- #define TIMEOUT 5.0
- #define RESPONSE_SIZE 8192
- void parse_url(char *url, char **hostname, char **port, char **path);
- void send_request(int sock, char *hostname, char *port, char *path);
- int connect_to_host(char *hostname, char *port);
- int main(int argc, char ** argv) {
- if (argc != 2) {
- fprintf(stderr, "Usage: %s url\n", argv[0]);
- return EXIT_FAILURE;
- }
- char *hostname, *port, *path;
- parse_url(argv[1], &hostname, &port, &path);
- int server = connect_to_host(hostname, port);
- send_request(server, hostname, port, path);
- char response[RESPONSE_SIZE + 1];
- char *p = response, *q;
- char *end = response + RESPONSE_SIZE;
- char *body = NULL;
- enum {length, chunked, connection};
- int encoding = 0;
- int remaining = 0;
- const clock_t start_time = clock();
- while (true) {
- if ((clock() - start_time) / CLOCKS_PER_SEC > TIMEOUT) {
- fprintf(stderr, "timeout after %.2f seconds ...\n", TIMEOUT);
- return EXIT_FAILURE;
- }
- if (p == end) {
- fprintf(stderr, "out of buffer space ...\n");
- return EXIT_FAILURE;
- }
- fd_set reads;
- FD_ZERO(&reads);
- FD_SET(server, &reads);
- struct timeval timeout;
- timeout.tv_sec = 0;
- timeout.tv_usec = 200000;
- if (select(server+1, &reads, NULL, NULL, &timeout) < 0) {
- fprintf(stderr, "select() failed, errno: %d, %s\n", errno, strerror(errno));
- return EXIT_FAILURE;
- }
- if (FD_ISSET(server, &reads)) {
- int bytes_recv = recv(server, p, end - p, 0);
- if (bytes_recv < 1) {
- if (encoding == connection && body) {
- printf("%.*s", (int)(end - body), body);
- }
- printf("\nConnection closed by peer ...\n");
- break;
- }
- p += bytes_recv;
- *p = '\0';
- if (!body && (body = strstr(response, "\r\n\r\n"))) {
- *body = '\0';
- body += 4;
- printf("Received Headers:\n%s\n", response);
- q = strstr(response, "\nContent-Length: ");
- if (q) {
- encoding = length;
- q = strchr(q, ' ');
- q += 1;
- remaining = strtol(q, NULL, 10);
- }
- else {
- q = strstr(response, "\nTransfer-Encoding: chunked");
- if (q) {
- encoding = chunked;
- remaining = 0;
- }
- else
- encoding = connection;
- }
- printf("\nReceived body:\n");
- }
- if (body) {
- if (encoding == length) {
- if (p - body >= remaining) {
- printf("%.*s", remaining, body);
- break;
- }
- }
- else if (encoding == chunked) {
- do {
- if (remaining == 0) {
- if (q = strstr(body, "\r\n\r\n")) {
- remaining = strtol(body, NULL, 16);
- if (!remaining) goto finish;
- body = q + 2;
- }
- else
- break;
- }
- if (remaining && p - body > remaining) {
- printf("%.*s", remaining, body);
- body += remaining + 2;
- remaining = 0;
- }
- } while (!remaining);
- }
- }
- }
- }
- finish:
- printf("Closing socket ...\n");
- close(server);
- printf("Finished ...\n");
- return EXIT_SUCCESS;
- }
- void parse_url(
- char * url,
- char ** hostname, char ** port, char ** path
- ) {
- printf("URL: %s\n", url);
- char * p = strstr(url, "://");
- char * protocol = NULL;
- if (p) {
- protocol = url;
- *p = '\0';
- p += 3;
- }
- else {
- p = url;
- }
- if (protocol) {
- if (strcmp(protocol, "http")) {
- fprintf(stderr, "Unsupported protocol '%s' ...\n", protocol);
- exit(EXIT_FAILURE);
- }
- }
- *hostname = p;
- while (*p && *p != ':' && *p != '/' && *p != '#')
- ++p;
- *port = "80";
- if (*p == ':') {
- *p++ = '\0';
- *port = p;
- }
- while (*p && *p != '/' && *p != '#')
- ++p;
- *path = p;
- if (*p == '/') {
- *path = p + 1;
- }
- *p = '\0';
- while (*p && *p != '#')
- ++p;
- if (*p == '#')
- *p = '\0';
- printf("hostname: %s\n", *hostname);
- printf("port: %s\n", *port);
- printf("path: %s\n", *path);
- }
- void send_request(
- int sock,
- char * hostname, char * port, char * path
- ) {
- char buff[2048];
- sprintf(buff, "GET /%s HTTP/1.1\r\n", path);
- sprintf(buff+strlen(buff), "Host: %s:%s\r\n", hostname, port);
- sprintf(buff+strlen(buff), "Connection: close\r\n");
- sprintf(buff+strlen(buff), "User-Agent: get.http (Debian GNU/Linux)\r\n");
- sprintf(buff+strlen(buff), "\r\n");
- send(sock, buff, strlen(buff), 0);
- printf("Sent Headers:\n%s", buff);
- }
- int connect_to_host(char * hostname, char * port) {
- printf("Configuring remote address ...\n");
- struct addrinfo hints;
- memset(&hints, 0, sizeof(hints));
- hints.ai_socktype = SOCK_STREAM;
- struct addrinfo * peer_addr;
- if (getaddrinfo(hostname, port, &hints, &peer_addr)) {
- fprintf(stderr, "getaddrinfo() failed, errno: %d, %s\n", errno, strerror(errno));
- exit(EXIT_FAILURE);
- }
- char addr_buff[100];
- char serv_buff[100];
- getnameinfo(
- peer_addr->ai_addr, peer_addr->ai_addrlen,
- addr_buff, sizeof(addr_buff),
- serv_buff, sizeof(serv_buff),
- NI_NUMERICHOST
- );
- printf("Remote address: %s %s\n", addr_buff, serv_buff);
- printf("Creating socket ...\n");
- int server = socket(
- peer_addr->ai_family, peer_addr->ai_socktype, peer_addr->ai_protocol
- );
- if (server < 0) {
- fprintf(stderr, "socket() failed, errno: %d, %s\n", errno, strerror(errno));
- exit(EXIT_FAILURE);
- }
- printf("Connecting ...\n");
- if (connect(server, peer_addr->ai_addr, peer_addr->ai_addrlen)) {
- fprintf(stderr, "connect() failed, errno: %d, %s\n", errno, strerror(errno));
- exit(EXIT_FAILURE);
- }
- freeaddrinfo(peer_addr);
- printf("Connected ...\n\n");
- return server;
- }
关于 HTTP POST 请求
HTTP POST 请求会向服务器发送数据.
即是说, POST 请求包括主体部分, 主体内含有数据, 尽管主体长度也有可能是 0.
POST 主体部分格式不只一种, 应该在头部 Content-Type 中表明.
很多现代 web APIs 预期 POST 请求的主体部分按 JSON 格式编码.
例如:
POST /orders HTTP/1.1
HOST: xxxx-shop.com
User-Agent: Mozilla ...
Content-Type: application/json
Content-Length: 56
Connection: close
{"symbol":"VOO", "qty":"10", "side":"buy", "type":"market"}
表单数据的编码
发送表单时的 POST 请求:
POST / HTTP/1.1
Host: itlabor.top:8282
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
Origin: http://itlabor.top
DNT: 1
Connection: keep-alive
Referer: http://itlabor.top/
Upgrade-Insecure-Requests: 1
from=client&to=server&msg=Hello
网站的表单, 如登录框, 传送数据时使用的很可能就是 POST 请求.
标准的 HTML 表单数据编码格式被称作 URL 编码(URL encoding), 或 百分号编码(persent encoding).
使用这种格式对表单的数据进行编码并通过 POST 请求发送时, 头部 Content-Type 应该是:
Content-Type: application/x-www-form-urlencoded
这种格式, 每个成对的 表单域 和 值由 '=' 连接, 多个表单域由符号 '&' 连接.
特殊字符要进行编码处理(encoded), 如 'Well Done!' 被编码成 'Well+Done%21'.
空格被编码成 '+', 特殊字符被编码成 '%' 后跟 2 位 16 进制数值.
通过表单上传文件
发送表单时的 POST 请求:
POST /learning-area/form.ep1.html# HTTP/1.1
Host: itlabor.top
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefox/91.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: multipart/form-data; boundary=---------------------------292112599141026179212145671003
Content-Length: 535
Origin: http://itlabor.top
DNT: 1
Connection: keep-alive
Referer: http://itlabor.top/learning-area/form.ep1.html
Upgrade-Insecure-Requests: 1
-----------------------------292112599141026179212145671003
Content-Disposition: form-data; name="user"
guest
-----------------------------292112599141026179212145671003
Content-Disposition: form-data; name="file"; filename="test.hands-on.html"
Content-Type: text/html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>TEST PAGE | 测试页</title>
</head>
<body>
<p>This is a test page.</p>
<p>这就是个测试页.</p>
</body>
</html>
-----------------------------292112599141026179212145671003--
通过表单上传文件时, POST 请求头部 Content-Type 应该是:
Content-Type: multipart/form-data; boundary=----------------xxxxxxxxxxxxxxxx
此时, 用到了边界指示符(boundary specifier).
指的是一种特殊的分隔符, 由发送者设定, 用于将表单数据分成若干部分.
要注意的是, 该指示符不能出现在 表单域数据内 和 上传的文件内.
否则, 接收者处理收到的数据时会混淆表单域的各个部分.