文章翻译至: HTTP Server: Everything you need to know to Build a simple HTTP server from scratch
译者注:虽然我是翻译别人的文章,但是我是参照着文章实践,并且成功了才贴出来的哦,下面的截图也是自己做的,当然我是在CentOS 7桌面系统上进行的实践,可能不同的环境有些差异吧吧吧。
HTTP无处不在! 我们访问的每个网站都在HTTP服务器上运行。 您可能会说还有HTTPS服务器。 从技术上讲,HTTPS与HTTP相同,只是HTTPS具有更高的安全性。
许多程序员可能会对HTTP服务器的工作原理以及如何从头开始构建一个没有任何外部库HTTP服务感到好奇。
我是众多程序员中的一员。
最近,我开始将node.js用于我的一个项目。 在使用它时,我对“如何构建HTTP服务器?”和“HTTP服务器如何工作?”感到好奇。我问的下一个问题是:“我如何从头开始构建HTTP服务器?”。 “初学者甚至可以建立一个吗?”
答案是: Yes
我们要问的第一个问题是:
我们从哪里开始?
首先,我们需要了解什么是OSI。
OSI: 开放系统互连模型(OSI模型)是概念模型,其表征和标准化电信或计算系统的通信功能,而不考虑其底层内部结构和技术。 其目标是使用标准协议实现各种通信系统的互操作性。 该模型将通信系统划分为抽象层。 该模型的原始版本定义了七层。
要实现HTTP,我们只关心第4层:传输层。
传输层:
传输层主要负责确保数据可靠且无错误地从一个点传输到另一个点。
例如,传输层确保以正确的顺序发送和接收数据。传输层提供流量控制和错误处理,并参与解决与数据包传输和接收有关的问题。
传输层协议的常见示例是传输控制协议(TCP),用户数据报协议(UDP)和顺序分组交换(SPX)。
在传输层,我们主要使用**传输控制协议(TCP)来实现HTTP服务器。 我们也可以使用用户数据报协议(UDP)**来实现HTTP服务器,但许多人不使用它。 具体原因可能偏离我们构建HTTP服务器的主题。
简而言之,来自RFC 2616:
HTTP通信通常通过TCP / IP连接进行。 默认端口为TCP 80,但可以使用其他端口。
这并不妨碍HTTP在Internet上或其他网络上的任何其他协议之上实现。 HTTP只假定可靠的传输; 可以使用任何提供此类保证的协议;
HTTP / 1.1请求和响应结构到所讨论协议的传输数据单元的映射超出了本规范的范围。
因此,尽管没有明确说明,但不使用UDP,因为它不是“可靠的传输”。
所有著名的HTTP服务器,如Apache Tomcat,NginX等都是在TCP之上实现的。 所以,在这篇文章中,我们将坚持使用基于TCP的HTTP服务器。
现在,您可能会想到“RFC到底是什么!”
RFC:
在互联网治理的背景下,征求意见书(RFC)是互联网工程任务组(IETF)和互联网协会(ISOC)的一种出版物,互联网是互联网的主要技术发展和标准制定机构。
RFC由工程师和计算机科学家以备忘录的形式撰写,该备忘录描述了适用于互联网和互联网连接系统的方法,行为,研究或创新。
它被提交给同行评审或传达新概念,信息或(偶尔)工程幽默。 IETF采用作为RFC标准发布的一些提议作为RFC。
评论文件是由史蒂夫克罗克于1969年发明的,旨在帮助记录关于ARPANET发展的非官方记录。
RFC已成为Internet规范,通信协议,程序和事件的官方文档。
简而言之,RFC是一个文档,其中有人提出改变,修改当前方法或提出新方法。 以及已经标准化的规范。
截至2017年8月,有超过8200个RFC。
一些标准化的RFC是:
HTTP / 1.1→最初它是RFC 2616,但后来被RFC 7230,RFC 7231,RFC 7232,RFC 7233,RFC 7234,RFC 7235取代。因此,我们需要从RFC 7230读取到RFC 7235以实现HTTP的基本工作。
HTTP / 2→RFC 7540和RFC 7541
FTP→RFC959
因此,如果我们想要实现HTTP服务器,我们必须阅读他们的特定RFC,即RFC 7230,RFC 7231,RFC 7232,RFC 7233,RFC 7234,RFC 7235。
在我们深入编码之前,请放松一下????
现在实现我们学到的东西:
实现TCP:
首先,我们需要实现HTTP的传输层,即TCP。
注意:
C语言将用于编码部分。
使用C语言的原因是因为它可以与Python,Java,Swift等任何编程语言一起使用。因为这是“从头开始”,我们正在用C语言构建它,它被认为是许多高级语言的桥梁语言。 您可以将C代码与任何高级语言集成。
我们将实现的代码适用于基于UNIX的系统,如macOS和Linux。 对于Windows,只有TCP的实现代码与UNIX不同。 但HTTP服务器的实现是相同的,因为我们必须遵循HTTP RFC的一些特定的指导方针,这些指导方针与语言无关!
要实现TCP,我们必须学习TCP套接字编程。
什么是套接字?
套接字是大多数流行操作系统提供的机制,使程序可以访问网络。 它允许在不同的联网机器上的应用程序(不相关的进程)之间发送和接收消息。
套接字机制以独立于任何特定类型的网络。 然而,IP是迄今为止最主要的网络和最流行的套接字使用。
使用TCP / IP套接字编程
使用套接字涉及几个步骤:
- 创建套接字
- 识别套接字
- 在服务器上,监听连接
- 发送和接收消息
- 关闭套接字
步骤1.创建套接字
使用套接字系统调用创建套接字server_fd:
int server_fd = socket(domain, type, protocol);
所有参数以及返回值都是整数:
domain, or address family —
应在其中创建套接字的通信域。.可选值有:
AF_INET(IP),AF_INET6(IPv6),AF_UNIX(本地通道,类似于管道),AF_ISO(ISO协议)和AF_NS(Xerox网络系统协议)。
type —
服务类型。
这是根据应用程序所需的属性选择的:SOCK_STREAM(虚电路服务),SOCK_DGRAM(数据报服务),SOCK_RAW(直接IP服务)。
请咨询您的地址系列,了解特定服务是否可用。
protocol —
表示用于支持套接字操作的特定协议。 这在某些系列可能具有多个协议以支持给定类型的服务的情况下非常有用。 返回值是文件描述符(小整数)。
创建套接字就像从电话公司请求电话线。
对于TCP / IP套接字,我们要指定IP地址系列(AF_INET)和虚电路服务(SOCK_STREAM)。 由于只有一种形式的虚拟电路服务,因此协议没有变化,因此最后一个参数协议为零。 我们创建TCP套接字的代码如下所示:
#include <sys/socket.h>
...
...
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
perror(“cannot create socket”);
return 0;
}
步骤2.识别(命名)套接字
当我们谈论命名套接字时,我们讨论的是为套接字分配传输地址(IP网络中的端口号)。 在套接字中,此操作称为绑定地址,并且 “bind” 系统调用用于此目的。
我们已经从在第一步请求电话公司拉了一根电话线,命名套接字就类似于像电话公司申请一个电话号码。
传输地址在套接字地址结构中定义。 由于套接字设计用于各种不同类型的通信接口,因此接口非常通用。 它不是接受端口号作为参数,而是采用sockaddr结构,其实际格式取决于您正在使用的地址族(网络类型)。 例如,如果您使用的是UNIX域套接字,则bind实际上会在文件系统中创建一个文件。
系统调用 bind 是:
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
第一个参数socket是使用套接字系统调用创建的套接字。
第二个参数,结构sockaddr是一个通用容器,它只允许操作系统读取标识地址族的前几个字节。 地址族确定要使用的sockaddr结构的哪个变体包含对该特定通信类型有意义的元素。 对于IP网络,我们使用struct sockaddr_in,它在头文件netinet / in.h中定义。 该结构定义:
struct sockaddr_in
{
__uint8_t sin_len;
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
在调用bind之前,我们需要填写这个结构。 我们需要设定的三个关键部分是:
sin_family
我们设置套接字时使用的地址族。 在我们的例子中,它是AF_INET。
sin_port
端口号(传输地址)。 您可以显式分配传输地址(端口)或允许操作系统分配一个。
如果您是客户端并且不接收传入连接,您通常只需让操作系统通过指定端口0来选择任何可用的端口号。如果您是服务器,则通常会选择一个特定的号码。
客户端需要知道要连接的端口号。
sin_addr
此套接字的地址。 这是您机器的IP地址。 使用IP,您的计算机将为每个网络接口提供一个IP地址。
例如,如果您的计算机同时具有Wi-Fi和以太网连接,则该计算机将具有两个地址,每个接口一个。
大多数情况下,我们不关心指定特定的接口,并且可以让操作系统使用它想要的任何东西。
它的特殊地址是0.0.0.0,由符号常量INADDR_ANY定义。
由于地址结构可能根据使用的传输类型而不同,因此第三个参数指定该结构的长度。这里使用sizeof(struct sockaddr_in) 计算长度
。
绑定套接字的代码如下所示:
#include <sys/socket.h>
…
struct sockaddr_in address;
const int PORT = 8080; //Where the clients can reach at
/* htonl converts a long integer (e.g. address) to a network representation */
/* htons converts a short integer (e.g. port) to a network representation */
memset((char *)&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);
address.sin_port = htons(PORT);
if (bind(server_fd,(struct sockaddr *)&address,sizeof(address)) < 0)
{
perror(“bind failed”);
return 0;
}
步骤3.在服务器上,等待传入连接
在客户端连接到服务器之前,服务器应该有一个准备接受连接的套接字。
listen系统调用告诉套接字能够接受的传入连接:
#include <sys/socket.h>
int listen(int socket, int backlog);
第二个参数backlog定义了在拒绝连接之前可以排队的最大挂起连接数。
accept系统调用在挂起连接队列(在listen中设置)上获取第一个连接请求,并为该连接创建一个新套接字。
accept的语法是:
#include <sys/socket.h>
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
第一个参数socket是为接受与listen的连接而设置的套接字。
第二个参数address是使用正在进行连接的客户端的地址的地址结构。 如果需要,我们可以通过它检查连接套接字的地址和端口号。
第三个参数用地址结构的长度填充。
Listen和Accept的代码如下:
if (listen(server_fd, 3) < 0)
{
perror(“In listen”);
exit(EXIT_FAILURE);
}
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0)
{
perror("In accept");
exit(EXIT_FAILURE);
}
步骤4.发送和接收消息
我们终于在客户端(当您从Web浏览器访问服务器的IP地址)和服务器之间连接套接字!
通信是最容易的部分。使用read和write系统调用同样可以操作套接字。
char buffer[1024] = {0};
int valread = read( new_socket , buffer, 1024);
printf(“%s\n”,buffer );
if(valread < 0)
{
printf("No bytes are there to read");
}
char *hello = "Hello from the server";//IMPORTANT! WE WILL GET TO IT
write(new_socket , hello , strlen(hello));
注意:HTTP服务器的实际工作基于char * hello变量中的内容发生。 我们稍后会再回过头来看看。
步骤5.关闭套接字
当我们完成通信时,最简单的方法是使用close系统调用关闭套接字 - 与用于文件的close相同。
close(new_socket);
我们已经在服务器上成功创建了TCP套接字!
TCP套接字服务器端代码:
// Server side C program to demonstrate Socket programming
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>
#define PORT 8080
int main(int argc, char const *argv[])
{
int server_fd, new_socket; long valread;
struct sockaddr_in address;
int addrlen = sizeof(address);
char *hello = "Hello from server";
// Creating socket file descriptor
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("In socket");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons( PORT );
memset(address.sin_zero, '\0', sizeof address.sin_zero);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0)
{
perror("In bind");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 10) < 0)
{
perror("In listen");
exit(EXIT_FAILURE);
}
while(1)
{
printf("\n+++++++ Waiting for new connection ++++++++\n\n");
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0)
{
perror("In accept");
exit(EXIT_FAILURE);
}
char buffer[30000] = {0};
valread = read( new_socket , buffer, 30000);
printf("%s\n",buffer );
write(new_socket , hello , strlen(hello));
printf("------------------Hello message sent-------------------\n");
close(new_socket);
}
return 0;
}
为了测试TCP服务器代码,我编写了一个TCP客户端代码:(不要担心这段代码。编写这段代码是为了显示简单TCP连接和HTTP连接之间的区别。你还记得我告诉过的关于变量的内容吗? char * hello in Step 4.发送和接收消息?)。
TCP套接字客户端代码:
// Client side C/C++ program to demonstrate Socket programming
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <unistd.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>
#define PORT 8080
int main(int argc, char const *argv[])
{
int sock = 0; long valread;
struct sockaddr_in serv_addr;
char *hello = "Hello from client";
char buffer[1024] = {0};
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("\n Socket creation error \n");
return -1;
}
memset(&serv_addr, '0', sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// Convert IPv4 and IPv6 addresses from text to binary form
if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)<=0)
{
printf("\nInvalid address/ Address not supported \n");
return -1;
}
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0)
{
printf("\nConnection Failed \n");
return -1;
}
send(sock , hello , strlen(hello) , 0 );
printf("Hello message sent\n");
valread = read( sock , buffer, 1024);
printf("%s\n",buffer );
return 0;
}
现在,在一个终端中运行TCP套接字服务器端代码,在另一个终端中运行TCP套接字客户端代码。
注意:顺序在这里很重要。 先执行服务器端代码,然后是客户端代码。
使用gcc编译:
gcc -o test-server test-server.c
gcc -o test-client test-client.c
结果:
服务端:
客户端:
HTTP
首先,我们将了解服务器和Web浏览器之间的交互。
这是互动的基本轮廓,如果我们放大HTTP部分:
- 最初,HTTP客户端(即Web浏览器)向HTTP服务器发送HTTP请求。
- 服务器处理请求接收并将HTTP响应发送到HTTP客户端。
现在,让我们来看看客户端 - 服务器以及它们发送的内容和收到的内容。
HTTP客户端(Web浏览器):
客户端每次都需要连接到服务器。 服务器无法连接到客户端。
因此,客户有责任启动连接。
当我们想要连接到服务器时,我们通常会做什么?
我们在浏览器中输入网站的一些URL /地址
要显示页面,浏览器从Web服务器获取文件index.html。
与www.example.com相同(默认值:端口80,文件index.html,http协议)。
因此,如果您在Web浏览器中键入www.example.com,则Web浏览器会将URL /地址重新构造为:
http://www.example.com:80
这是我们的Web浏览器每次浏览网页时发送到服务器的内容。
如果服务器配置为某些默认页面。 比如,服务器有一个默认的网页,当我们访问服务器上的文件夹时会显示它。
该网页由文件名决定。 有些服务器有public.html,有些服务器有index.html。
在此示例中,我们将index.html视为默认页面。
不敢相信?
我们会做一件事。
- 在终端中运行TCP服务器端代码(从上面)。
- 打开Web浏览器并在地址栏中输入localhost:8080 / index.html。
- 现在看看终端的输出是什么。
浏览器端:
服务器端:
哈,为什么就成功了,根据文档应该会失败的阿????不管了,根据文档继续往下跑:
问题是什么? 为什么我们看不到从服务器发送的数据?
你还记得我在步骤4中对变量char * hello所说的内容吗?发送和接收消息? 如果你忘了那件事。 回去看看我在那里说了什么。
我们将在一分钟内回到变量char * hello。 别担心。
HTTP Methods (Verbs):
GET是HTTP使用的默认方法。
他们之中有一些是:
GET - 获取URL
HEAD - 获取有关URL的信息
PUT - 存储到URL
POST - 将表单数据发送到URL并获取响应
DELETE - 删除URL GET和POST(表单)是常用的
REST API使用GET,PUT,POST和DELETE。
HTTP Server:
现在,是时候回应客户并发送他们想要的东西了!
客户向我们发送了一些标题,并希望我们得到同样的回报。
但不是这样,我们只发送一条问候语:
char* hello = "Hello from server";
浏览器期望发送请求的格式响应相同。
HTTP只是遵循RFC文档中指定的一些规则。 这就是为什么我说在实现TCP开始时HTTP实现与语言无关。
这是Web浏览器期望我们的HTTP响应格式:
如果我们想从服务器发送Hello,首先我们需要构造Header。 然后插入一个空行,然后我们就可以发送我们的消息/数据。
上面显示的标题只是一个例子。 事实上,HTTP中存在许多标头。 您可以查看HTTP RFC→RFC 7230,RFC 7231,RFC 7232,RFC 7233,RFC 7234,RFC 7235。
现在,我们将构建一个最小的HTTP头来使我们的服务器工作。
char *hello = "HTTP/1.1 200 OK\nContent-Type: text/plain\nContent-Length: 12\n\nHello world!";
这3个Header是最低要求:
- HTTP / 1.1 200 OK→这提到了我们使用的HTTP版本,状态代码和状态消息。
- Content-Type:text / plain→这表示我(服务器)发送纯文本。 有许多内容类型。 例如,对于图像我们使用它。
- Content-Length:12→它提到服务器发送给客户端的字节数。 网络浏览器只读取我们在这里提到的内容。
下一部分是Body。 我们在这里携带需要发送的数据。
首先,我们需要计算Body中发送的字节数。 然后我们在Content-Length中提到它。 此外,我们根据发送的数据适当地设置Content-Type。
状态代码和状态消息:
状态代码由服务器发出,以响应客户端对服务器的请求。 它包括来自IETF Request for
Comments(RFC)的代码,其他规范以及在超文本传输协议(HTTP)的一些常见应用中使用的一些附加代码。
状态代码的第一个数字指定五个标准响应类别之一。 所示的消息短语是典型的,但是可以提供任何人类可读的替代方案。 除非另有说明,否则状态代码是HTTP / 1.1标准(RFC 7231)的一部分。
因此,如果找不到客户端要求的文件,则发送相应的状态代码。
如果客户端无权查看该文件,则会发送相应的状态代码。
现在,在终端中运行以下代码,然后在浏览器中转到localhost:8080。
// Server side C program to demonstrate HTTP Server programming
#include <stdio.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>
#define PORT 8080
int main(int argc, char const *argv[])
{
int server_fd, new_socket; long valread;
struct sockaddr_in address;
int addrlen = sizeof(address);
// Only this line has been changed. Everything is same.
char *hello = "HTTP/1.1 200 OK\nContent-Type: text/plain\nContent-Length: 12\n\nHello world!";
// Creating socket file descriptor
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("In socket");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons( PORT );
memset(address.sin_zero, '\0', sizeof address.sin_zero);
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0)
{
perror("In bind");
exit(EXIT_FAILURE);
}
if (listen(server_fd, 10) < 0)
{
perror("In listen");
exit(EXIT_FAILURE);
}
while(1)
{
printf("\n+++++++ Waiting for new connection ++++++++\n\n");
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0)
{
perror("In accept");
exit(EXIT_FAILURE);
}
char buffer[30000] = {0};
valread = read( new_socket , buffer, 30000);
printf("%s\n",buffer );
write(new_socket , hello , strlen(hello));
printf("------------------Hello message sent-------------------");
close(new_socket);
}
return 0;
}
现在,你可以看到Hello world! 在您的浏览器中。
我唯一改变的是char * hello变量。
最后,我们的HTTP服务器正在运行!
客户端:
服务器端:
我们如何向客户发送请求的网页?
直到现在,我们学会了如何发送字符串。
现在,我们将看看如何发送文件,图像等。
假设您在地址栏中输入了localhost:8080 / info.html。
在服务器终端中,我们获得以下请求头:
GET /info.html HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Cookie: username-localhost-8888="2|1:0|10:1550473468|23:username-localhost-8888|44:ODY5NDZlMjA0ZmY3NDM2ODkzMTNhZGY0YTg4OTY2MTg=|0f975ad006b829323e52326ebccaf8454c8319061a45a9e1d9971b0812a876fa"; _xsrf=2|fe5c071b|1fb34ff9aa0b4e33216e25fa8dc76c84|1550197682; username-localhost-8889="2|1:0|10:1550544945|23:username-localhost-8889|44:NjlmOWIxYTM3Mzg4NDhiNmE5OTJjZWMzN2FhNmQ3ZTc=|42e2ed3be050af199bc1bab483162640b21264ab61c5fc40ff210f300b865c8b"
Connection: keep-alive
Upgrade-Insecure-Requests: 1
为简单起见,我们只考虑请求头中的第一行。
GET /info.html HTTP/1.1
所以,我们只需在当前目录中搜索info.html文件(作为/指定它在服务器的根目录中查找。如果它像/messages/info.html那么我们必须查看消息文件夹内部 for info.html文件)。
这里有很多案例需要考虑:
他们之中有一些是:
文件(网页)存在
文件(网页)不存在
客户端无权访问该文件(网页)。
还有很多……
首先从这里选择适当的状态代码。
如果文件存在且客户端有权访问它,则从此处选择适当的Content-Type。
然后构造响应头。
现在在Response Header的末尾添加一个换行符,并将数据附加到我们从文件中读取的数据(当且仅当文件存在且客户端有权访问它时)。
向客户发送响应标题!
而已!
我们已经从Scratch成功创建了一个HTTP服务器!