logo头像

学如逆水行舟

自上而下的理解网络(2)——HTTP篇

自上而下的理解网络(2)——HTTP篇

一.引言

上一篇博客中,我们介绍了访问网站的第一步:DNS解析的相关内容。地址如下:

https://my.oschina.net/u/2340880/blog/5272671

本系列博客的宗旨是【自上而下的理解网络】,因此我们还是要一步一步的从前向后说,如果你已经阅读过了DNS那篇博客,那么现在,你访问网站使用的域名已经转换成了IP地址,有了IP地址,就相当与有了对方的地址(我们姑且先这么理解),从OSI网络模型的设计思路上说,上层无需关注下层的实现,目前我们先不关注拿到IP地址后是如何找到对应的主机的,暂且认为目前我们已经可以找到服务器并与之通信了。

那么当下,最紧要的问题是我们应该怎样与对方交流,我发送的数据如何能让对方理解?对方传输过来的数据我们又如何理解?这就好比,两个人要进行交流,他们必须使用相同的语言,否则就会驴唇不对马嘴,双方谁都无法理解谁。这里所比喻的“语言”就是互联网中的协议,我们访问一个网站,一般使用的都是HTTP协议。

二.重新认识下HTTP吧

HTTP(Hyper Text Transfer Protocol)是一种应用层的协议,其设计的结构非常面向应用。对于大多数互联网应用来说,都是由一个服务器提供服务,许多的客户机来请求服务,任何互联网服务本质上都是数据交互,即客户端告诉服务端我要什么数据,服务端将对应的数据返回给客户端,或者客户端将一些数据传送给服务端处理,或者客户端告诉服务端我要修改什么数据,删除什么数据等等。在HTTP协议中,请求的发起方必定是客户端,服务端被动的收到请求后根据逻辑做响应。因此,HTTP协议交互的数据实际上分为两种,一种是Request数据,一种是Response数据,无论是哪种数据,我们都将其称为HTTP报文。

现在,你可以尝试打开浏览器的调试模式,随便访问一个网页,在网络监控部分可以看到HTTP请求的相关内容,如下图所示:

大致预览下,可以发现HTTP请求还有有些复杂的,请求头,响应头,请求体,响应体中都包含很多数据。通常,越面向应用层的上层协议实现简单而逻辑复杂,越底层的协议反而逻辑简单而实现复杂。HTTP协议只是看上去数据字段很多,其原理其实很简单。

HTTP报文有两大部分组成,分别为报文首部和报文主体,报文首部和报文主体之间使用空行来分割。即在报文数据中,第一个空行即是标记首部和主体的分割线,如下图所示:

其中,空行是有回车符(0x0d)加换行符(0x0a)组成,即十六进制的0x0d0a。后面我们在自己编写HTTP请求数据的组装或解析时,就可以使用连续的两个0x0d0a来作为首部和主体的分割位。报文首部又分为请求/状态行和请求/响应首部字段。

1. 请求行与状态行

对于请求报文,首部的第一行数据为请求行,其格式如下:

请求方法 URI HTTP版本信息

请求方法,URI和HTTP版本信息之间使用空格进行分割(0x20)。

对于响应报文,首部的第一行数据为状态行,其格式如下:

HTTP版本信息 状态码及状态文案

请求方法用来与告知服务端客户端的意图,例如是要获取数据还是上传数据,是删除文件还是修改文件等。而状态码则是服务器返回给客户端当前响应的状态,是成功了还是失败了等。

2. 请求方法

请求方法表明了客户端要以什么样的方式来与服务端进行数据交互,常用的请求方法有8种,列举如下:

方法 意义
GET 获取资源
POST 发送数据
PUT 传输文件
HEAD 获得报文首部
DELETE 删除文件
OPTIONS 询问支持的方法
TRACE 追踪路径
CONNECT 要求用隧道协议连接代理

方法的本质其实只是客户端与服务端约定好的数据交互意图,其本身并不会影响传输数据本身,这就好比,生活中我们各种各样的车辆,公交汽车,出租车,小货车,不同类型的车辆有不同的功能,但是本质上,他们并没有区别,公交汽车也可以拉货,小货车也可以作为出租车来载人。那么你可能回想,我们可不可以客户端与服务端约定好一种自定义的请求方法进行交互呢,当然可以,后面我们会动手实践。

3. 状态码

状态码负责将服务端请求的结果告知客户端,并不一定客户端所有的请求服务端都能正确处理,通常2XX类型的状态码表示处理成功,200表示请求被正常处理。204表示请求成功处理了,但是没有数据需要返回。206表示客户端进行了范围请求,服务端成功处理了。3XX类型的状态码通常表示与重定向有关,资源移动了位置或发生的变化,服务端会返回此类型的状态码。4XX类的通常是客户端的原因造成的异常,例如使用了不支持的请求方法,要请求的资源不存在等,我相信,404是我们最常见的一种异常状态,当你请求的URL或者参数有问题时会返回这个状态码。5XX类型的状态码通常是服务器错误。

当然,状态码的意义也是服务端和客户端约定而成的,在实际应用中,我们的应用逻辑可能需要更多的状态码来描述,完全可以自定义。

4. 首部字段

首部字段主要用来存放控制字段,例如使用什么方法进行请求,使用的HTTP协议版本,发起请求的客户端信息等。以访问huishao.cc网站为例,抓取到的HTTP请求的首部信息如下:

可以看到,HTTP报文首部每个字段的配置是以行为单位进行分割的,每行配置一个字段。根据不同的上下文首部字段可以分为4类:通用类,请求头类,响应头类,实体类。通用类是指同时适用于请求首部和响应首部的字段,请求头类只适用与请求首部,响应头类只使用于响应首部,实体类是指包含报文主体信息的字段,如Content-Length设置主体部分长度。

下表列出了常用的HTTP官方的参考文档中定义了报文首部字段:

字段 意义
Accept 设置期望的数据类型,例如text/html
Accept-Charset 设置期望的数据字符集
Accept-Encoding 设置期望的内容编码,例如gzip
Accept-Language 设置期望的页面语言
Accept-Ranges 响应首部中的字段,表示是否接受范围请求
Age 资源创建至今的时间
Allow 资源可支持的HTTP方法
Authorization 服务端用来认证客户端身份
Cache-Control 控制缓存行为
Connection 管理连接
Content-Encoding 设置报文主体适用的编码方式
Content-Language 设置报文主体适用的语言
Content-Length 设置报文主体字节长度
Content-Location 标记数据的备用URI
Content-MD5 报文主体的MD5摘要
Content-Range 报文主体的位置范围
Content-Type 报文主体的数据类型
Cookie Cookie设置
Date 创建报文日期时间
DNT 设置是否跟踪用户偏好,属于隐私控制字段
ETag 设置报文主体资源的版本号
Expect 客户端用来设置其期望服务端使用特性行为处理
From 用户的电子邮箱地址
Expires 报文主体过期的日期时间
Host 请求资源的服务器地址
If-Match 进行资源版本比较
If-Modified-Since 进行资源更新时间的比较
If-None-Match 与If-Match逻辑相反
If-Range 资源未更新时发送主体的范围请求
If-Unmodified-Since 与If-Modified-Since逻辑相反
Last-Modified 资源的最后修改时间
Location 令客户端重定向到的URI
Max-Forwards 设置最大的输逐跳数
Proxy-Authenticate 定义了代理服务器对客户端的认证信息
Proxy-Authorization 代理服务器要求客户端的认证信息
Range 主体的字节请求范围
Referer 对请求中URI的原始获取方
Retry-After 对再次发起请求的时机要求
Server HTTP服务器的安装信息
TE 传输编码的优先级
Transfer-Encoding 报文主体的传输编码方式
User-Agent HTTP客户端程序的信息
Upgrade 升级为其他协议
Vary 代理服务器缓存的管理信息
Via 代理服务器的相关信息
Warning 错误通知
WWW-Authenticate 服务端对客户端的认证信息

需要注意,上面列出的字段很多,但不一定所有的HTTP服务器都对他们进行了实现,HTTP本身只是一个协议,真正实现此协议的是服务端程序和客户端程序。同样,HTTP协议也是一种很具扩展性的协议,我们也可以根据应用需要,自定义一些首部字段,只要我们自己的客户端和服务端都按照约定好的理解来处理自定义的字段即可。下面,我们对一些重要的首部字段进行解释。

5. 核心首部字段解析

Cache-Control

Cache-Control首部字段用来控制缓存逻辑,在官方的协议定义中,此字段可以设置多个指令,指令间使用逗号分隔即可,例如:

Cache-Control: private, max-age=0, no-cache

有些指令还支持传参,例如max-age指令。指令及参数意义如下:

指令 参数 意义
no-cache 强制向源服务器验证缓存有效性
no-store 不缓存请求或相应的任何内容
max-age 响应的最大Age值
max-stale 最大收的已过期响应时间
min-fresh 指定在最少多少时间内刷新过的响应才有效
only-if-cached 从缓存获取资源
public 可向任意方提供响应的缓存
private 仅向特定用户返回响应
must-revalidate 允许缓存,但必须向服务端验证有效性

Accept

客户端通过Accept字段可以告知服务端它所需要的数据格式。例如,当客户端向服务端请求一张图片时,服务端可以提供各种类型的图片数据,但是客户端只能够解析png类型的图片,这是就可以设置Accept字段,如下:

Accept:image/png

客户端如果可以收多种类型的数据,Accept也支持设置多个类型,使用逗号进行分割即可,优先级会从前往后依次降低。同样,对字符集和编码方式如果有要求,通过Accept-Charset和Accept-Encoding字段设置。

Authorization

Authorization是HTTP协议中用来进行用户认证的字段,有时候客户端的资源并非所有用户都可以访问,如果有用户对这部分资源发起的访问请求,服务端会回执状态码为401的响应,表示需要用户认证才能范文,这时客户端就需要将用户认证信息放入Authorization字段中再次发起请求,服务端会根据Authorization的值来判断当前用户是否有权限访问此资源。

Host

在介绍这个字段之前,请你先回忆下,我们在进行HTTP数据交互时,要先通过IP来找到对方的位置,其实一台服务器上是可以部署很多个服务的,不同的服务有可能在不同的虚拟主机上,那个我们的这次请求究竟需要那个虚拟主机来处理呢?这就需要有字段来标识用户到底访问的是什么资源,Host字段的用途就在如此。

三. 实践出真知

现在,你是否对HTTP协议有了新的认识和理解,我们可以从实际的应用中验证前面所说的理论知识。

使用Wireshark工具来抓取网络报文包,我们可以将筛选条件设置为http协议,以便过滤掉无关的网络信息。在浏览器中访问huishao.cc网站,为了避免浏览器缓存产生的影响,可以先将缓存清空。

之后,我们可以在Wireshark工具中看到这样两条记录:

其中,第一条记录为HTTP的请求记录,第二条记录为HTTP的响应记录。

我们先看第一个请求记录,将其HTTP协议层的数据复制出来,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a
48 6f 73 74 3a 20 68 75 69 73 68 61 6f 2e 63 63
0d 0a 55 70 67 72 61 64 65 2d 49 6e 73 65 63 75
72 65 2d 52 65 71 75 65 73 74 73 3a 20 31 0d 0a
41 63 63 65 70 74 3a 20 74 65 78 74 2f 68 74 6d
6c 2c 61 70 70 6c 69 63 61 74 69 6f 6e 2f 78 68
74 6d 6c 2b 78 6d 6c 2c 61 70 70 6c 69 63 61 74
69 6f 6e 2f 78 6d 6c 3b 71 3d 30 2e 39 2c 2a 2f
2a 3b 71 3d 30 2e 38 0d 0a 55 73 65 72 2d 41 67
65 6e 74 3a 20 4d 6f 7a 69 6c 6c 61 2f 35 2e 30
20 28 4d 61 63 69 6e 74 6f 73 68 3b 20 49 6e 74
65 6c 20 4d 61 63 20 4f 53 20 58 20 31 30 5f 31
35 5f 37 29 20 41 70 70 6c 65 57 65 62 4b 69 74
2f 36 30 35 2e 31 2e 31 35 20 28 4b 48 54 4d 4c
2c 20 6c 69 6b 65 20 47 65 63 6b 6f 29 20 56 65
72 73 69 6f 6e 2f 31 34 2e 31 2e 31 20 53 61 66
61 72 69 2f 36 30 35 2e 31 2e 31 35 0d 0a 41 63
63 65 70 74 2d 4c 61 6e 67 75 61 67 65 3a 20 7a
68 2d 63 6e 0d 0a 41 63 63 65 70 74 2d 45 6e 63
6f 64 69 6e 67 3a 20 67 7a 69 70 2c 20 64 65 66
6c 61 74 65 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e
3a 20 6b 65 65 70 2d 61 6c 69 76 65 0d 0a 0d 0a

按照我们前面的分析,HTTP协议请求报文中的第一行数据为请求行,我们找到第一个换行符0d 0a为止,截出数据如下:

1
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31

0x20位空格字符,用其作为分割截出三部分数据为:

1
2
3
47 45 54
2f
48 54 54 50 2f 31 2e 31

在Ascii码中,0x47为“G”,0x45为“E”,0x54为“T”,0x2f为“/”,0x48为“H”,0x50为“P”,0x31为“1”,0x2e为“.”。翻译完成即为:

GET / HTTP/1.1

按照同样的方法,以0d 0a为分隔符可以将请求头中的每一个首部字段解析出来,因为我们本次请求没有请求体数据,因此找到两个连续的0d 0a后即表示首部解析完成。

响应报文的解析方法与请求报文没什么不同,这里我们不再赘述,有一点需要注意,响应报文的首部字段总有Content-Length字段表明了响应主体数据的长度。

四. 尝试下基于TCP来实现一个HTTP服务端和客户端吧

在上一篇博客中,我们了解了DNS协议的原理后,基于TCP手动实现了一个简单的DNS解析器,DNS和HTTP都属于应用层的协议,TCP则属于传输层的协议。现在我们理解了HTTP的协议原理,能否手动实现一个简易的HTTP服务端和客户端呢?当然是可以的。

1. 简易HTTP服务端

可以参考前面手动实现DNS的博客中TCP协议的用法,编写代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <stdio.h>
#include <ctype.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

#define QUEUE_MAX_COUNT 5

#define SERVER_STRING "Server: custom HTTP server\r\n"

int main()
{
// 服务端描述符
int server_fd = -1;
// 客户端描述符
int client_fd = -1;
// 定义端口号
u_short port = 9001;

// 客户端地址
struct sockaddr_in client_addr;
// 服务端地址
struct sockaddr_in server_addr;

socklen_t client_addr_len = sizeof(client_addr);

char buf[1024];
char recv_buf[1024];

int hello_len = 0;
int on = 1;

// 创建一个socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);

// 设置地址复用
int ret = setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

memset(&server_addr, 0, sizeof(server_addr));

// 设置端口,IP,和TCP/IP协议族
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(9001);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);


// 绑定套接字到端口
bind(server_fd, (struct sockaddr *)&server_addr,sizeof(server_addr));

// 启动socket监听请求,开始等待客户端发来的请求
listen(server_fd, QUEUE_MAX_COUNT);

printf("http server running on port %d\n", port);

// 循环等待客户端请求
while (1) {
// 调用了accept函数,阻塞了程序,直到收到客户端的请求
client_fd = accept(server_fd, (struct sockaddr *)&client_addr,
&client_addr_len);
printf("client socket fd: %d\n", client_fd);
// 调用recv函数收客户端发来的请求信息
hello_len = recv(client_fd, recv_buf, 1024, 0);
printf("receive %d\n\n", hello_len);
printf("%s\n", recv_buf);

// 不管客户端发过来的请求是啥,我们都返回同样的测试数据
// 添加响应头
sprintf(buf, "HTTP/1.0 200 OK\r\n");
send(client_fd, buf, strlen(buf), 0);
// 添加服务端信息
strcpy(buf, SERVER_STRING);
send(client_fd, buf, strlen(buf), 0);
// 添加主题类型
sprintf(buf, "Content-Type: text/html\r\n");
send(client_fd, buf, strlen(buf), 0);
// 添加自定义头部数据
sprintf(buf, "Custom: custom\r\n");
send(client_fd, buf, strlen(buf), 0);
// 结束首部字段的添加
strcpy(buf, "\r\n");
send(client_fd, buf, strlen(buf), 0);
// 添加主体数据
sprintf(buf, "HelloWorld!\r\n");
send(client_fd, buf, strlen(buf), 0);
/* 关闭客户端套接字 */
close(client_fd);
}
close(server_fd);
return 0;
}

上面代码中有比较详细的注释,一个HTTP请求结束后,当前客户端的Socket连接就会被关闭。上面只是简单实现了基于HTTP协议的数据交互,并没有真正实现应用逻辑,无论客户端发什么样的请求过来,服务端都将返回同样的数据。下面,在浏览器中输入http://localhost:9001/,效果如下图所示:

2. 简易的HTTP客户端

与服务端实现代码类似,简易客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

void socket_init(int *sockfd,char *host);

int main(int argc,char **argv)
{
char message[512];
int socket_desc;
char *host = "127.0.0.1";
socket_init(&socket_desc,host);
// 构建要发送的消息 CUSTOM为自定义的协议
sprintf(message,"CUSTOM / HTTP/1.1\r\nHost: Service.com\r\n\r\n");
// 发送数据到服务端
send(socket_desc,message,strlen(message),0);

struct timeval timeout = {3, 0};
// 设置Sorket
setsockopt(socket_desc, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(struct timeval));
char chunk[512];
fd_set fdset;
// 重设字符集
FD_ZERO(&fdset);
FD_SET(socket_desc,&fdset);
// 收服务端数据
select(socket_desc+1,&fdset,NULL,NULL,NULL);
if(FD_ISSET(socket_desc,&fdset)) {
memset(chunk , 0 , 512);
recv(socket_desc, chunk, 512, 0);
printf("%s" , chunk);
}
return 0;
}

void socket_init(int *sockfd,char *ip)
{
struct sockaddr_in server;
*sockfd = socket(AF_INET,SOCK_STREAM,0);
server.sin_addr.s_addr = inet_addr(ip);
server.sin_family = AF_INET;
server.sin_port = htons(9001);
connect((*sockfd),(struct sockaddr *)&server,sizeof(server));
}

可以看到,上面代码中我们用了自定义的请求方法CUSTOM,启动服务端后在运行客户端,服务端应用会打印出如下信息:

1
2
CUSTOM / HTTP/1.1
Host: Service.com

同样,客户端的程序会打印出如下信息:

1
2
3
4
5
6
HTTP/1.0 200 OK
Server: custom HTTP server
Content-Type: text/html
Custom: custom

HelloWorld!

五. 结尾

上面我们提供了服务端和客户端的简易实现代码,我相信你对HTTP协议的原理有了自己新的理解,你可以尝试下,完善下上面的代码,真正实现下HTTP协议中的GET,POST等方法。现在,回到我们本系列博客的核心:自上而下的理解网络。我们现在已经明白了域名是如何转换成IP地址,获得了IP地址后,客户端和服务端又是如何互相无障碍的通讯的。后面,我们将再进一步,进入传输层的领域,不积跬步,无以至千里,与君共勉。

专注技术,热爱生活,交流技术,也做朋友。

——珲少 QQ:316045346