07_http客户端请求

一.http协议的介绍

当我们在浏览器里,想要请求目标网站里的资源(图片、数据、应用接口等),这些资源来自于服务器,而这个请求过程需要http客户端来实现。本文将通过C语言实现基于TCP连接的http客户端请求。
步骤如下:

  1. 建立tcp连接 (a.通过DNS请求获得其ip地址 b. tcp连接这个ip地址)
  2. 在tcp连接,socket的基础上,发送http协议请求
  3. 服务器在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结构体可以用这张图来形象地表示:
image

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

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

c542fc12-45fb-424d-a906-4fbdea9d3b08

为什么使用 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 的对比(括号中的数字表示所占用的字节数):
image
可以认为,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)、空行和请求数据四个部分组成,下图给出了请求报文的一般格式。

image

下面实例是使用 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;
}
posted @ 2025-11-11 17:17  Xiaomostream  阅读(6)  评论(0)    收藏  举报