目录
一、前言
上文网络编程篇一中的DNS请求是基于UDP通信协议实现的,而本文要实现的HTTP请求器是基于TCP协议实现的。UDP (User Datagram Protocol) 是一种无连接的通信协议,它不保证数据的可靠传输,但是传输速度较快。UDP适用于实时性要求较高的应用,如视频、音频传输等。而TCP (Transmission Control Protocol) 是一种面向连接的通信协议,它保证数据的可靠传输,但是传输速度相对较慢。TCP适用于要求可靠性的应用,如HTTP、FTP等。在本文中,由于进行HTTP请求需要确保数据的可靠传输,因此选择使用TCP协议来实现HTTP请求器。这样可以保证在与服务器进行通信时的数据传输的可靠性和稳定性。
二、基础知识
1.HTTP
HTTP(HyperText Transfer Protocol)是一种用于传输超文本数据的应用层协议,它是在Web浏览器和Web服务器之间进行通信的基础。HTTP使用TCP/IP协议作为传输协议,通过可靠的连接来传输数据。
通俗来讲,HTTP是一个在计算机世界里专门在两点之间传递文字、图片、音视频等超文本数据的约定和规范。
HTTP优缺点:
优点:
- 简单易用:HTTP协议设计简单,容易理解和使用,使得开发者能够快速构建Web应用程序。
- 跨平台:HTTP是一种无状态的协议,不受操作系统和硬件平台的限制,可以在不同的系统上进行通信。
- 灵活可扩展:HTTP协议可以通过添加新的头部字段来扩展其功能,允许开发者进行自定义和灵活的协议扩展。
缺点:
- 无状态:HTTP协议是无状态的,即服务器无法跟踪客户端的状态信息。对于有状态的应用程序来说,需要使用额外的机制来维护状态信息。
- 安全性较低:HTTP协议的数据传输是明文的,不提供加密和身份验证机制,容易被恶意攻击者截取和篡改数据。为了提高安全性,通常需要使用HTTPS协议进行加密通信。
2.HTTPs
HTTPs(HTTP Secure)是在HTTP的基础上添加了安全性的协议,它使用了SSL/TLS协议对HTTP的数据进行加密。通过使用证书和公钥加密技术,HTTPs可以提供对数据的加密和身份验证,从而保护用户隐私和数据的安全性。
相对于HTTP,HTTPs在传输过程中增加了数据的保密性和完整性。它可以防止数据被窃听、篡改和伪造,并且可以验证服务器的身份是否可信。常见的使用HTTPs的场景包括网上银行、电子商务、用户登录等需要保护用户隐私和数据安全的应用。
3.UDP与TCP的区别
UDP和TCP是两种不同的传输层协议,用于在计算机网络中进行数据通信。他们的区别如下:
- 连接性:TCP是一种面向连接的协议,它要求在数据传输之前建立一个连接,然后在连接上可靠地传输数据;UDP是一种无连接的协议,它不需要连接,只是简单地将数据包发送到目标。
- 可靠性:TCP提供可靠性传输,确保数据包的完整性和顺序性。如数据包损坏或丢失,会重新传输;UDP不提供可靠性保证。
- 开销:TCP具有较高的开销,UDP开销较小。
- 适用场景:TCP适用于那些需要可靠性和数据完整性的应用,如网页浏览、电子邮件和文件下载;UDP适用于那些对延迟更为敏感的应用,如音频和视频流媒体、在线游戏以及一些实时通信应用。
4.TCP的三次握手,四次挥手?
三次握手是指TCP建立连接的过程,四次握手是指TCP终止连接的过程。握手指客户端和服务端的交互。
TCP的三次握手是为了确保双方建立可靠的通信连接。简单描述如下:
- 第一次握手:客户端向服务器发送SYN包,表示请求建立连接。
- 第二次握手:服务器收到SYN包后,会发送一个SYN+ACK包作为确认,并同时向客户端发送一个SYN包。
- 第三次握手:客户端收到服务器的SYN+ACK包后,会发送一个ACK包作为确认。此时,连接已建立。
TCP的四次挥手是为了确保双方能够正常关闭连接。简单描述如下:
- 第一次挥手:客户端发送一个FIN包,表示自己已经不再发送数据。
- 第二次挥手:服务器收到FIN包后,会发送一个ACK包作为确认,并进入CLOSE_WAIT状态。
- 第三次挥手:服务器发送一个FIN包,表示服务器也不再发送数据。
- 第四次挥手:客户端收到服务器的FIN包后,会发送一个ACK包作为确认,并进入TIME_WAIT状态。服务器收到ACK包后,双方的连接正式关闭。
5.OSI模型
OSI模型是控制计算机和网络设备之间信息交换的标准框架,全称为开放系统互联通信参考模型(Open Systems Interconnection Reference Model)。它于1984年由国际标准化组织(ISO)提出,并被广泛接受和应用。
OSI模型将网络通信过程划分为七个不同的层次,每个层次都有特定的功能和任务。这些层次依次是:
- 物理层
- 功能:物理层是OSI模型的最底层,它定义了接口和媒体的物理特性,包括数据传输速率、信号传输模式(单工、半双工、全双工)以及网络物理拓扑(网状、星型、总线型等)。物理层为设备之间的数据通信提供传输媒体及互连设备,如架空明线、平衡电缆、光纤、无线信道等。
- 作用:为数据通信提供物理连接,确保数据能在物理介质上正确传输。
- 数据链路层
- 功能:数据链路层负责在物理层提供的服务基础上,将数据封装成帧,并通过物理层进行传输。它还包括帧定界、帧同步、差错检测与恢复、流量控制等功能。
- 作用:为网络层提供可靠的数据传输服务,确保数据帧能够准确无误地从源节点传输到目的节点。
- 网络层
- 功能:网络层负责将网络地址翻译成对应的物理地址,并决定如何将数据从发送方路由到接收方。它还包括路由选择、中继、差错检测与恢复、排序、流量控制等功能。
- 作用:实现网络之间的互连,确保数据能够跨越多个网络进行传输。
- 传输层
- 功能:传输层是端到端的层次,负责建立、维护和终止端到端的连接,确保数据可靠、顺序、无错地从源端传输到目的端。它还包括流量控制、差错控制等功能。
- 作用:为上层应用提供可靠的数据传输服务,确保数据在传输过程中不会丢失或出错。
- 会话层
- 功能:会话层负责在网络中的两个节点之间建立和维持通信会话,控制会话的建立、同步和管理等。
- 作用:为表示层提供会话服务,确保两个节点之间的通信能够顺利进行。
- 表示层
- 功能:表示层负责数据的编码、解码、加密、解密、压缩和解压缩等,以确保数据能够在不同的系统之间正确传输和解析。
- 作用:为应用层提供数据表示服务,确保数据在传输过程中能够保持其原有的格式和含义。
- 应用层
- 功能:应用层是OSI模型的最高层,它为用户和应用程序提供网络服务接口,如文件传输、电子邮件、远程登录等。
- 作用:为用户和应用程序提供直接的网络服务支持,确保用户能够方便地使用网络资源。
三、HTTP请求器(TCP客户端)
3.1.HTTP工作原理
HTTP 协议工作于客户端-服务端架构上。浏览器作为 HTTP 客户端通过 URL 向 HTTP 服务端即 WEB 服务器发送所有请求。常见的Web 服务器有:Apache 服务器,IIS 服务器(Internet Information Services)等。Web 服务器根据接收到的请求后,向客户端发送响应信息。
HTTP 默认端口号为 80,但是你也可以改为 8080 或者其他端口。
HTTP 三点注意事项:
- HTTP 是无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。
- HTTP 是媒体独立的:这意味着,只要客户端和服务器知道如何处理的数据内容,任何类型的数据都可以通过 HTTP 发送。客户端以及服务器指定使用适合的 MIME-type 内容类型。
- HTTP是无状态:HTTP协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。
3.2.客户端请求消息
http请求报文格式:客户端发送一个 HTTP 请求到服务器的请求消息包括以下格式:请求行(request line)、请求头部(header)、空行和请求数据四个部分组成,下图给出了请求报文的一般格式。
下面实例是一点典型的使用 GET 来传递数据的实例:
客户端请求:
GET /hello.txt HTTP/1.1
User-Agent: curl/7.16.3 libcurl/7.16.3 OpenSSL/0.9.7l zlib/1.2.3
Host: www.example.com
Accept-Language: en, mi
根据 HTTP 标准,HTTP 请求可以使用多种请求方法。
- GET:用于获取资源,可以理解为读取或下载数据。适用于向服务器请求资源,在URL上携带数据的长度有限制。
- HEAD:类似于GET请求,但服务器仅返回响应头部信息,不返回实际的资源内容。常用于检查资源是否存在,或获取资源的元数据。
- POST:向服务器提交数据,相当于写入或上传数据。通常用于发送表单数据、上传文件等场景。
- PUT:用于向服务器上传或更新资源。通常用于创建新资源或覆盖已存在的资源,在请求中携带完整的资源内容。
- DELETE:请求服务器删除指定的资源。
- OPTIONS:用于获取服务器支持的请求方法列表。
http响应报文格式:HTTP 响应也由四个部分组成,分别是:状态行、消息报头、空行和响应正文。
HTTP状态码
当浏览者访问一个网页时,浏览者的浏览器会向网页所在服务器发出请求。当浏览器接收并
显示网页前,此网页所在的服务器会返回一个包含 HTTP 状态码的信息头(server header)用以响应浏览器的请求。
下面是常见的 HTTP 状态码:
- 200 - 请求成功
- 301 - 资源(网页等)被永久转移到其它 URL
- 404 - 请求的资源(网页等)不存在
- 500 - 内部服务器错误
3.3.实现HTTP请求器实例
这段代码实现了一个简单的HTTP客户端,可以向指定的主机发送HTTP请求并接收响应。
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #include <netdb.h> #include <fcntl.h> #define HTTP_VERSION "HTTP/1.1" #define CONNETION_TYPE "Connection: close\r\n" #define BUFFER_SIZE 4096 // DNS --> // baidu --> struct hosten char *host_to_ip(const char *hostname) { struct hostent *host_entry = gethostbyname(hostname); //dns // 14.215.177.39 --> //inet_ntoa ( unsigned int --> char * // 0x12121212 --> "18.18.18.18" if (host_entry) { return inet_ntoa(*(struct in_addr*)*host_entry->h_addr_list); } return NULL; } int http_create_socket(char *ip) { int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in sin = {0}; sin.sin_family = AF_INET; sin.sin_port = htons(80); // sin.sin_addr.s_addr = inet_addr(ip); if (0 != connect(sockfd, (struct sockaddr*)&sin, sizeof(struct sockaddr_in))) { return -1; } fcntl(sockfd, F_SETFL, O_NONBLOCK); return sockfd; } // hostname : github.com --> char * http_send_request(const char *hostname, const char *resource) { char *ip = host_to_ip(hostname); // int sockfd = http_create_socket(ip); char buffer[BUFFER_SIZE] = {0}; sprintf(buffer, "GET %s %s\r\n\ Host: %s\r\n\ %s\r\n\ \r\n", resource, HTTP_VERSION, hostname, CONNETION_TYPE ); send(sockfd, buffer, strlen(buffer), 0); //select fd_set fdread; FD_ZERO(&fdread); FD_SET(sockfd, &fdread); struct timeval tv; tv.tv_sec = 5; tv.tv_usec = 0; char *result = malloc(sizeof(int)); memset(result, 0, sizeof(int)); while (1) { int selection = select(sockfd+1, &fdread, NULL, NULL, &tv); if (!selection || !FD_ISSET(sockfd, &fdread)) { break; } else { memset(buffer, 0, BUFFER_SIZE); int len = recv(sockfd, buffer, BUFFER_SIZE, 0); if (len == 0) { // disconnect break; } result = realloc(result, (strlen(result) + len + 1) * sizeof(char)); strncat(result, buffer, len); } } return result; } int main(int argc, char *argv[]) { if (argc < 3) return -1; char *response = http_send_request(argv[1], argv[2]); printf("response : %s\n", response); free(response); }
下面是对代码的简要解释:
host_to_ip
函数:该函数使用gethostbyname
函数将主机名转换为对应的IP地址。http_create_socket
函数:该函数创建一个套接字,并使用connect
函数连接到指定的IP地址和端口号(80)。http_send_request
函数:该函数接收主机名和资源路径作为参数,首先调用host_to_ip
函数获取对应的IP地址,然后调用http_create_socket
函数创建套接字并连接到主机。接下来,根据HTTP协议的格式构建HTTP请求报文,使用send
函数将请求发送到服务器。之后,通过使用select
函数来轮询套接字是否有数据可读,如果有可读数据,则使用recv
函数读取数据并将其存储在缓冲区中,并将缓冲区的内容追加到结果字符串中。最后,返回结果字符串。main
函数:该函数通过命令行参数接收主机名和资源路径,并调用http_send_request
函数发送HTTP请求并将响应打印出来。
疑问?上面代码中使用的select是什么?有什么用?
回答:
select() 是一个用于多路复用 I/O 的函数。它可以同时监视多个文件描述符,一旦其中一个或多个文件描述符准备好进行 I/O 操作(可读、可写、出错等),select() 函数就会返回。这样可以避免在没有数据可读或写入时阻塞程序。
如果不使用select,而是直接使用阻塞式的recv函数,那么在每次接收数据时都需要等待服务器返回数据,如果服务器的响应时间较长,那么程序会一直阻塞在recv函数的调用处,无法进行其他的操作。
使用select函数可以设置一个超时时间,可以在超时时间内检测socket是否有数据可读,如果没有数据则可以进行其他的操作,避免了阻塞。
综上所述,使用select可以提高程序的并发性能和响应速度,提高了代码的可扩展性。
四、TCP服务器
4.1.TCP客户端/服务端开发流程
TCP客户端程序开发流程:
- 创建客户端套接字对象。socket();
- 与服务端套接字建立连接。connect();
- 发送数据。send();
- 接收数据。recv();
- 关闭客户端套接字.close().
TCP服务端开发流程:
- 创建服务端套接字对象。socket();
- 绑定IP地址和端口号。bind();
- 设置监听。listen();
- 等待接收客户端的连接请求。accept();
- 接收数据。recv();
- 发送数据。send();
- 关闭服务端套接字。close()。
4.2.I/O多路复用机制
在前面的tcp客户端程序中,在使用recv()函数接收数据时使用了I/O多路复用机制——select。这样避免了使用recv()接收不到数据而出现阻塞,select可以设置超时时间,一段时间未收到数据就会结束该段程序。下面将详细介绍几种常用的I/O复用机制,select、poll和epoll。
4.2.1.select、poll和epoll的工作原理
select:
- 原理:select是最早出现的I/O多路复用机制,可以同时监视多个I/O事件。它通过将需要检测的文件描述符集合传递给select函数,并通过轮询的方式来检测是否有事件发生。当有事件发生时,select函数会返回,程序可以根据返回值进行相应的处理。
- 优点:单线程处理多个连接、避免阻塞、较低资源消耗;
- 缺点:参数较多;效率低,每次需要把待检测文件描述符集合copy进入内核,然后不断轮询;可以监听的文件描述符数量有限,通常为1024。
- 实现函数:int nready=select(maxfd,rset,wset,eset,timeout)。
poll:
- 原理:poll是select的改进版,使用方式类似。与select相比,poll的一个优势是没有文件描述符的数量限制,可以处理更多的连接。poll通过将需要监视的文件描述符数组传递给poll函数,并通过轮询的方式来检测是否有事件发生。
- 优点:无文件描述符数量的限制;相比于select参数更少。
- 缺点:效率低,每次需要把待检测文件描述符集合copy进入内核,然后不断轮询;
- 实现函数:int nready = poll(fds,maxfd+1,-1)。
epoll:
- 原理:epoll是Linux系统下的I/O多路复用机制,相比于select和poll,具有更高的性能。epoll通过将需要监视的文件描述符加入到监听队列中,当有事件发生时,只通知发生事件的文件描述符。这种方式避免了轮询的开销,提高了效率。
- 优点:
1.事件驱动,效率高,具有较低的系统调用开销。
2.高并发,支持较大的并发连接数,可以监听上万个文件描述符。
3.具有较好的可移植性,适用于大部分操作系统。
- 缺点:
1.只能在Linux系统下使用:Epoll是Linux内核中的一个特性,因此只能在Linux系统下使用。
2.编程接口相对复杂,使用起来相对困难一些。
- 实现函数:epoll_create()、epoll_ctl()、epoll_wait()。
疑问:epoll相比于select、poll的优势是什么?
回答:
- 高效:epoll使用内核事件通知机制,能够在大量的文件描述符中高效地找出可读、可写或可异常事件,并且避免了遍历所有文件描述符的开销。
- 支持边缘触发:epoll提供了边缘触发模式(edge-triggered),即只有在状态变化时才得到通知。这意味着当一个事件发生后,应用程序必须立即进行处理,否则事件会被丢弃。
- 内核与用户空间共享事件表:使用epoll时,内核会将事件信息填充到用户空间中的事件表中,减少了内核和用户空间之间的数据拷贝操作,提高了效率。
- 支持多线程。
4.2.2.触发方式
两种触发方式是边沿触发(Edge Triggered,ET)和水平触发(Level Triggered,LT)。
1.ET边沿触发:
- ET模式下,只有在触发事件时才会通知应用程序,再次进行非阻塞I/O操作。
- 当数据就绪时,epoll_wait函数会返回,并且应用程序需要一次性将所有就绪的数据读取完毕,否则会有数据丢失的风险。
- ET触发模式较为高效,适用于高并发的情况,但需要应用程序具备高并发处理的能力。
2.LT水平触发:
- LT模式下,当数据就绪时,epoll_wait函数会返回,并且应用程序可以立即进行非阻塞I/O操作。
- 如果应用程序没有完全读取所有就绪的数据,下次epoll_wait返回时还会再次触发事件,直到数据全部读取完毕。
- LT触发模式相对于ET触发模式更容易使用,但在高并发情况下,效率可能略低。
总结:
- ET触发方式适用于高并发情况下,要求应用程序有较高的并发处理能力。
- LT触发方式适用于一般情况下,使用更加简单方便。
- 选择哪种触发方式应根据实际情况和需求来决定。
4.2.3.阻塞和非阻塞的区别
阻塞和非阻塞是指线程或进程在执行某个操作时的行为方式。
阻塞:当一个线程或进程执行某个操作时,如果操作不能立即完成,那么线程或进程将会被挂起,等待操作完成后再继续执行后续任务。在这个等待的过程中,该线程或进程无法执行其他任务。
非阻塞:当一个线程或进程执行某个操作时,如果操作不能立即完成,线程或进程不会被挂起,而是立即返回,继续执行后续任务。在这个过程中,该线程或进程可以同时处理其他任务。
在高并发编程中,阻塞方式可能导致线程或进程的资源浪费,因为线程或进程被挂起时无法做其他事情。而非阻塞方式则可以提高系统的并发能力,充分利用线程或进程的资源。因此,非阻塞方式在高并发场景中更加常用。
4.3.TCP服务器实例
#include <stdio.h> #include <string.h> #include <stdlib.h> #include <netinet/tcp.h> #include <arpa/inet.h> #include <pthread.h> #include <errno.h> #include <fcntl.h> #include <sys/epoll.h> #define BUFFER_LENGTH 1024 #define EPOLL_SIZE 1024 void *client_routine(void *arg) { int clientfd = *(int *)arg; while (1) { char buffer[BUFFER_LENGTH] = {0}; int len = recv(clientfd, buffer, BUFFER_LENGTH, 0); if (len < 0) { close(clientfd); break; } else if (len == 0) { // disconnect close(clientfd); break; } else { printf("Recv: %s, %d byte(s)\n", buffer, len); } } } // ./tcp_server int main(int argc, char *argv[]) { if (argc < 2) { printf("Param Error\n"); return -1; } int port = atoi(argv[1]); int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(struct sockaddr_in)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = INADDR_ANY; if (bind(sockfd, (struct sockaddr*)&addr, sizeof(struct sockaddr_in)) < 0) { perror("bind"); return 2; } if (listen(sockfd, 5) < 0) { perror("listen"); return 3; } // #if 0 while (1) { struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(struct sockaddr_in)); socklen_t client_len = sizeof(client_addr); int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len); pthread_t thread_id; pthread_create(&thread_id, NULL, client_routine, &clientfd); } #else int epfd = epoll_create(1); struct epoll_event events[EPOLL_SIZE] = {0}; struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); while (1) { int nready = epoll_wait(epfd, events, EPOLL_SIZE, 5); // -1, 0, 5 if (nready == -1) continue; int i = 0; for (i = 0;i < nready;i ++) { if (events[i].data.fd == sockfd) { // listen struct sockaddr_in client_addr; memset(&client_addr, 0, sizeof(struct sockaddr_in)); socklen_t client_len = sizeof(client_addr); int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len); ev.events = EPOLLIN | EPOLLET; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev); } else { int clientfd = events[i].data.fd; char buffer[BUFFER_LENGTH] = {0}; int len = recv(clientfd, buffer, BUFFER_LENGTH, 0); if (len < 0) { close(clientfd); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev); } else if (len == 0) { // disconnect close(clientfd); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev); } else { printf("Recv: %s, %d byte(s)\n", buffer, len); } } } } #endif return 0; }
代码讲解:
这段代码是一个简单的TCP服务器程序。它首先创建一个套接字sockfd,并将其绑定到指定的端口上。然后通过调用listen函数将该套接字设置为监听状态,等待客户端的连接。
在传统的实现中,使用了多线程来处理每个客户端连接。当有新的连接到达时,会创建一个新的线程来处理该连接。这部分代码被注释掉了,暂时不考虑。
新的实现中,使用了epoll来处理客户端连接。首先创建了一个epoll实例epfd,并定义了一个epoll_event类型的数组events。然后将sockfd添加到epoll实例中,监听读事件(EPOLLIN)。
进入主循环,调用epoll_wait函数等待事件发生,最多等待5秒。当事件发生时,会返回就绪的事件数量nready。接下来就是遍历就绪事件的过程。
如果就绪事件是sockfd(监听事件),说明有新的客户端连接到达。通过accept函数接收新的客户端连接,并将该连接的文件描述符添加到epoll实例中,监听读事件(EPOLLIN | EPOLLET)。
如果就绪事件是客户端连接的文件描述符,说明有数据可读。通过recv函数读取数据,并进行处理。如果读取失败或者读取到了0字节,说明连接已断开,将该连接的文件描述符从epoll实例中删除。
整个程序的主要思路就是通过epoll来监听多个文件描述符的读事件,实现并发处理多个客户端连接。