07_http客户端请求
一.http协议的介绍
当我们在浏览器里,想要请求目标网站里的资源(图片、数据、应用接口等),这些资源来自于服务器,而这个请求过程需要http客户端来实现。本文将通过C语言实现基于TCP连接的http客户端请求。
步骤如下:
- 建立tcp连接 (a.通过DNS请求获得其ip地址 b. tcp连接这个ip地址)
- 在tcp连接,socket的基础上,发送http协议请求
- 服务器在tcp链接socket,返回http协议response
二.C语言实现
1. 通过域名获取IP地址
这个ip地址是通过DNS解析得到的,可以使用我们自己实现的DNS解析代码实现,不过在这里我们使用gethostbyname函数:gethostbyname函数详解。
gethostbyname() 函数可以完成域名转换成 IP 地址的转换。
它的原型为
struct hostent *gethostbyname(const char *hostname);
hostname 为主机名,也就是域名。使用该函数时,只要传递域名字符串,就会返回域名对应的 IP 地址。返回的地址信息会装入 hostent 结构体,该结构体的定义如下:
struct hostent{
char *h_name; //official name
char **h_aliases; //alias list
int h_addrtype; //host address type
int h_length; //address lenght
char **h_addr_list; //address list
}
- h_name:官方域名
- h_aliases:别名,可以给你这个ip地址起别名,可以通过多个域名访问同一主机
- h_addrtype:获取IP地址的地址族(地址类型),IPv4对应AF_INET, IPv6对应AF_INET6
- h_length:保存的IP地址长度,IPv4为4个字节,IPv6为16个字节
- h_addr_list:这是我们需要的,该成员以整数形式保存域名对应的 IP 地址。对于用户较多的服务器,可能会分配多个 IP 地址给同一域名,利用多个服务器进行均衡负载。
hostent结构体可以用这张图来形象地表示:

由于h_addr_list可能会有多个IP地址,我们在这里选择第一个作为建立TCP连接的IP地址*host_entry->h_addr_list。
把域名强转为(struct in_addr*), 也就是unsigned int类型的IP地址。

而inet_ntoa 函数用于将网络字节序的 IPv4 地址转换为点分十进制的字符串表示,也就是将IP地址转化成人可读的字符串。 [0x12121212 --> "18.18.18.18"]
// 获得域名的ip地址
char *host_to_ip(const char *hostname) {
struct hostent *host_entry = gethostbyname(hostname);
// 点分十进制, 14.215.177.39
// 通过inet_ntoa将 unsigned char --> char *
// 0x12121212 --> "18.18.18.18"
if(host_entry) {
//hostname -> ip
return inet_ntoa(*(struct in_addr*)*host_entry->h_addr_list);
}
return NULL;
}
2. 建立TCP连接
我们已经获取服务器的ip地址,现在我们要通过 socket 实现, 对ip地址:目标端口建立TCP连接。
这里复习一下 socket 的用法,参考链接:https://c.biancheng.net/view/2131.html
socket用法介绍
在 Linux 下使用 <sys/socket.h> 头文件中 socket() 函数来创建套接字,原型为:
int socket(int af, int type, int protocol);
- af: 地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。
AF_INET 表示 IPv4 地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。 - type: 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字), 这里有一个网站讲的很好:https://c.biancheng.net/view/2124.html
- protocol: 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
关于protocol,上面两种情况都只有一种协议满足条件,可以将 protocol 的值设为 0,系统会自动推演出应该使用什么协议,如下所示,不用自己来写:
int tcp_socketfd = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字
int udp_socketfd = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字
创建完套接字后,我们需要绑定套接字、建立连接
服务器端要用 bind() 函数 将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。
类似地,客户端也要用 connect() 函数建立连接。
这里以bind为例,connect用法同理。参考链接:https://c.biancheng.net/view/2344.html
bind用法介绍
bind() 函数的原型为:
int bind(int sock, struct sockaddr *addr, socklen_t addrlen); //Linux
// connect与bind的参数都一样,学会bind就会connect了:
// int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);
- sock 为 socket 文件描述符, 也就是咱们创建的套接字socketfd
- addr 为 sockaddr 结构体变量的指针
- addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
这里我们使用 sockaddr_in 结构体,然后再强制转换为 sockaddr 类型,后边会讲解为什么这样做。
sockaddr_in结构体
接下来不妨先看一下 sockaddr_in 结构体,它的成员变量如下:
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
uint16_t sin_port; //16位的端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用,一般用0填充
};
-
sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致。
-
sin_prot 为端口号。uint16_t 的长度为两个字节,理论上端口号的取值范围为 0~65536,但 0~1023 的端口一般由系统分配给特定的服务程序,例如 Web 服务的http 端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。
-
端口号需要用 htons() 函数转换
-
sin_addr 是
struct in_addr结构体类型的变量,下面会详细讲解 -
sin_zero[8] 是多余的8个字节,没有用,一般使用 memset() 函数填充为 0。
上面的代码中 第3个成员是 in_addr 类型的结构体,该结构体只包含一个成员,如下所示:
struct in_addr{
in_addr_t s_addr; //32位的IP地址
};
in_addr_t 在头文件 <netinet/in.h> 中定义,等价于 unsigned long,长度为4个字节。也就是说,s_addr 是一个整数,而IP地址是一个字符串,所以需要 inet_addr() 函数进行转换,例如:
unsigned long ip = inet_addr("127.0.0.1");
printf("%ld\n", ip);
//输出 16777343

为什么使用 sockaddr_in 而不使用 sockaddr ?
bind() 第二个参数的类型为 sockaddr,而代码中却使用 sockaddr_in,然后再强制转换为 sockaddr,这是为什么呢?
sockaddr 结构体的定义如下:
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family),也就是地址类型
char sa_data[14]; //IP地址和端口号
};
下图是 sockaddr 与 sockaddr_in 的对比(括号中的数字表示所占用的字节数):

可以认为,sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而 sockaddr_in 是专门用来保存 IPv4 地址的结构体。另外还有 sockaddr_in6,用来保存 IPv6 地址,它的定义如下:
struct sockaddr_in6 {
sa_family_t sin6_family; //(2)地址类型,取值为AF_INET6
in_port_t sin6_port; //(2)16位端口号
uint32_t sin6_flowinfo; //(4)IPv6流信息
struct in6_addr sin6_addr; //(4)具体的IPv6地址
uint32_t sin6_scope_id; //(4)接口范围ID
};
正是由于通用结构体 sockaddr 使用不便,才针对不同的地址类型定义了不同的结构体。
TCP连接服务器
这里我们是用客户端向服务器发送http协议,所以我们要使用connect。
此外,我们要设置sockfd是非阻塞的,为什么呢?
阻塞vs非阻塞
- 阻塞是指当某个函数执行成功的条件当前不满足时,该函数会阻塞当前执行线程,程序被挂起,一直等待条件成立才会继续执行。
- 非阻塞模式相反,即使某个函数执行成功的条件当前不满足,该函数也不会阻塞当前执行线程,而是立即返回,继续执行程序流。
那对于socket来说,
- 如果socket是阻塞,假设这个socket里没有数据,read这个阻塞IO的时候,整个线程就会因为读的这个动作被挂起,一直等到这个IO里有数据的时候,才会执行后续的程序。
- 而如果socket是非阻塞的,即使当前IO没有数据,也会立即返回,我们的线程就不会被挂起。
所以我们在做服务器/编程的时候,基本都是使用非阻塞的IO。
// 实现TCP连接服务器
int http_create_socket(char *ip) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0); //SOCK_STREAM表示用TCP连接
struct sockaddr_in sin = {0};
sin.sin_family = AF_INET;
sin.sin_port = htons(80); //默认http协议的端口号为80
sin.sin_addr.s_addr = inet_addr(ip); //ip-->hostname
if(0 != connect(sockfd, (struct sockaddr*)&sin, sizeof(struct sockaddr_in))) {
return -1;
}
fcntl(sockfd, F_SETFL, O_NONBLOCK); //设置为非阻塞
return sockfd;
}
3.Send http Request
与目标服务器建立连接后,我们的客户端要向服务器发送http协议的Request
HTTP简介
HTTP 是一个基于 TCP/IP 通信协议,在TCP连接,socket连接的基础上来传递数据的协议
HTTP 三点注意事项:
-
HTTP 是无连接:无连接的含义是限制每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。
-
HTTP 是媒体独立的:这意味着,只要客户端和服务器知道如何处理的数据内容,任何类型的数据都可以通过 HTTP 发送。客户端以及服务器指定使用适合的 MIME-type 内容类型。
-
HTTP 是无状态:HTTP 协议是无状态协议。无状态是指协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。另一方面,在服务器不需要先前信息时它的应答就较快。
发送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
请求方法GET 空格 请求资源URL 空格 协议版本 \r \n
头部字段名有多个(根据自己需求设置) \r \n
\r \n
hostname:相应的域名,如 “github.com”; resource:相应的资源,如 “/Xiaomostream”
解析主机名到 IP 地址,函数创建一个 TCP 套接字并连接到指定 IP 地址的 HTTP 服务器
使用格式化字符串写入字符buffer:
其中CONNETION_TYPE "Connection: close\r\n"连接类型:操作连接即刻中断
发送 HTTP 请求send(),等待服务器返回数据
发送http请求代码
char *ip = host_to_ip(hostname);
int sockfd = http_create_socket(ip);
char buffer[BUFFER_SIZE] = {0}; //buffer里面的数据格式为http协议格式
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); //发送给web服务器http协议格式的buffer数据
4.Recv http Response
由于我们设置IO是非阻塞的,当IO里没有数据时,如果直接使用recv是不能接收到数据的,所以要使用select检测来监听网络IO里是否有数据,使用select函数就可以实现非阻塞编程。
select函数
select函数是一个轮循函数,循环询问文件节点,可设置超时时间,超时时间到了就跳过代码继续往下执行。
select原理:select需要驱动程序的支持,驱动程序实现fops内的poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行。
函数定义:
int select(int maxfd, fd_set* readset, fd_set* writeset, fe_set* exceptset, struct timeval* timeout);
返回值:
返回fd的总数,错误时返回SOCKET_ERROR
参数:
maxfd 需要检查的文件描述字个数, 通常设置为所有fd中最大值+1。
readset 用来检查可读性的一组文件描述字。
writeset 用来检查可写性的一组文件描述字。
exceptset 用来检查是否有异常条件出现的文件描述字。(注:错误不包括在异常条件之内)
timeout 超时,填NULL为阻塞,填0为非阻塞,其他为一段超时时间
select 函数使我们可以执行I/O多路转接。传给 select 的参数告诉内核∶
-
我们所关心的描述符;
-
对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件);
-
愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。
select 返回时,内核告诉我们∶
-
已准备好的描述符的总数量;
-
对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。
使用这种返回信息,就可调用相应的 I/O函数,并且确知该函数不会阻塞。
fd_set结构体:
fd_set其实这是一个数组的宏定义,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(socket、文件、管道、设备等)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪个句柄可读。参考链接:https://blog.csdn.net/Fuel_Ming/article/details/122931926
struct timeval结构体:
struct timeval{
long tv_sec;//second
long tv_usec;//minisecond
}
结构体成员有两个,第一个单位是秒,第二个单位是微妙 ,作用是时间为两个之和;
FD函数
系统提供了FD_SET, FD_CLR, FD_ISSET, FD_ZERO进行操作,声明如下:
FD_SET(int fd, fd_set *fdset); //将fd加入set集合
FD_CLR(int fd, fd_set *fdset); //将fd从set集合中清除
FD_ISSET(int fd, fd_set *fdset); //检测fd是否在set集合中,不在则返回0
FD_ZERO(fd_set *fdset); //将set清零使集合中不含任何fd
使用select来监听IO里是否有数据
通过FD_SET(sockfd, &fdread)绑定刚刚客户端通过send给服务器的sockfd, 因为一次可能读不完,所以需要多次通过循环select操作监听是否sockfd这个IO有可读的数据, 如果确认有数据了,就可以通过recv来读取sockfd中的数据buffer,并把buffer追加到char *result数组里,知道没有数据可读或者断开连接。
//recv(); 由于IO是非阻塞的,空数据会立马返回, 需要使用select检测监听网络IO是否为空数据, 只要集合中有一个非空就会返回非空
//select检测, 网络IO里面有没有可读的数据?
fd_set fdread; //可以看作是由0/1组成的数组,1表示第几位有数据,这样就可以建立映射关系
FD_ZERO(&fdread); //先对fdset置空
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) {
/*select(maxfd+1, &rret, &wset, &eset, &tv)
返回IO数, maxfd表示最大可读集合,最大的fd是多少;
rset: 一个可读的集合, 关注哪写IO可读; wset: 一个可写的结合,关注哪些IO可写
eset: 判断哪个IO出错; tv: 表示多长时间轮回一次,即多长遍历一次所有的IO
*/
int selection = select(sockfd+1, &fdread, NULL, NULL, &tv ); //sockfd+1 表示多开一位数组
if(!selection || !FD_ISSET(sockfd, &fdread)) { //
break;
} else {
memset(buffer, 0, BUFFER_SIZE);
int len = recv(sockfd, buffer, BUFFER_SIZE, 0); //这里有可能出现一次recv读不完一条数据,可能需要多次recv读
if(len == 0) { //disconnect
break;
}
result = realloc(result, (strlen(result) + len + 1) * sizeof(char));
strncat(result, buffer, len);
}
}
三.完整代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <fcntl.h>
#define BUFFER_SIZE 4096
#define HTTP_VERSION "HTTP/1.1"
#define CONNETION_TYPE "Connection: close\r\n"
// 获得域名的ip地址
char *host_to_ip(const char *hostname) {
struct hostent *host_entry = gethostbyname(hostname); //查一下struct hosten
// 点分十进制, 14.215.177.39
// 通过inet_ntoa将 unsigned char --> char *
// 0x12121212 --> "18.18.18.18"
if(host_entry) {
//hostname -> ip
return inet_ntoa(*(struct in_addr*)*host_entry->h_addr_list);
}
return NULL;
}
// 实现TCP连接服务器
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); //默认http协议的端口号为80
sin.sin_addr.s_addr = inet_addr(ip); //ip-->hostname
if(0 != connect(sockfd, (struct sockaddr*)&sin, sizeof(struct sockaddr_in))) {
return -1;
}
fcntl(sockfd, F_SETFL, O_NONBLOCK); //设置为非阻塞
return sockfd;
}
// 发送http协议格式的请求给服务器, 服务器会返回response
// 如www.github.com/Xiaomostream,hostname: github.com; resource: /Xiaomostream
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}; //buffer里面的数据格式为http协议格式
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); //发送给web服务器http协议格式的buffer数据
//recv(); 由于IO是非阻塞的,空数据会立马返回, 需要使用select检测监听网络IO是否为空数据, 只要集合中有一个非空就会返回非空
//select检测, 网络IO里面有没有可读的数据?
fd_set fdread; //可以看作是由0/1组成的数组,1表示第几位有数据,这样就可以建立映射关系
FD_ZERO(&fdread); //先对fdset置空
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) {
/*select(maxfd+1, &rret, &wset, &eset, &tv)
返回IO数, maxfd表示最大可读集合,最大的fd是多少;
rset: 一个可读的集合, 关注哪写IO可读; wset: 一个可写的结合,关注哪些IO可写
eset: 判断哪个IO出错; tv: 表示多长时间轮回一次,即多长遍历一次所有的IO
*/
int selection = select(sockfd+1, &fdread, NULL, NULL, &tv ); //sockfd+1 表示多开一位数组
if(!selection || !FD_ISSET(sockfd, &fdread)) { //
break;
} else {
memset(buffer, 0, BUFFER_SIZE);
int len = recv(sockfd, buffer, BUFFER_SIZE, 0); //这里有可能出现一次recv读不完一条数据,可能需要多次recv读
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;
//printf("%s\n", host_to_ip(argv[1]));
char *response = http_send_request(argv[1], argv[2]);
printf("response: %s\n", response);
free(response);
return 0;
}

浙公网安备 33010602011771号