[C++项目] 简单的HTTP服务器

1. 搭建C/S

先实现一个简单的ECHO服务器,只需要把任何收到的数据原封不动地发回去即可

客户端要做的事情也十分简单,读取用户输入的一个字符串并发送给服务端,然后把接收到的数据输出出来即可。

在socket编程中,服务端和客户端是靠socket进行连接的。服务端在建立连接之前需要做的有:

  • 创建socket
  • 将socket与指定的IP和端口(以下简称为port)绑定
  • 让socket在绑定的端口处监听请求(等待客户端连接到服务端绑定的端口)

而客户端发送连接请求并成功连接之后,服务端便会得到客户端的套接字,于是所有的收发数据便可以在这个客户端的套接字上进行了。

而收发数据其实就是:

  • 接收数据:使用客户端套接字拿到客户端发来的数据,并将其存于buff中。
  • 发送数据:使用客户端套接字,将buff中的数据发回去。

什么是socket?
socket就是套接字函数: 创建一个绑定到特定传输服务提供者的套接字。

将底层复杂的协议体系,执行流程,进行了封装,封装完的结果,就是一个SOCKET了,也就是说,SOCKET是我们调用协议进行通信的操作接口。

本质就是一种数据类型,到文件定义下看类型

  • 就是一个整数(unsigned int)
  • 但是这个数是唯一的
    • 标识着我们当前的应用程序,协议特点等信息
    • ID,门牌号

socket() 函数参数介绍

SOCKET WSAAPI socket(int af,//地址族规范。 地址族的可能值在Winsock2.h头文件中定义。
    int type,//新套接字的类型规范。
    int protocol//要使用的协议。
);
复制代码
  • af 当前常用的值为AF_INETAF_INET6,这是IPv4和IPv6的Internet地址族格式。

  • type 当前常用的值为SOCK_STREAMSOCK_DGRAM,分别是TCP和UDP的套接字类型。

    • SOCK_SEQPACKET:有序分组套接字,适用于SCTP协议
    • SOCK_RAW:原始套接字,适用于绕过传输层直接与网络层协议(IPv4/IPv6)通信
  • protocol 如果指定的值为0,则调用者不希望指定协议,服务提供商将选择要使用的协议。常见值 IPPROTO_ICMP IPPROTO_IGMP IPPROTO_TCP

  • 返回值:
    如果没有发生错误,socket将返回一个引用新socket的描述符。否则,将返回-1,失败的时候可以通过输出errno来详细查看具体错误类型。

通常一个内核函数运行出错的时候,它会定义全局变量errno并赋值。当我们引入errno.h头文件时便可以使用这个变量, 借助strerror()函数,使用strerror(errno)得到一个具体描述其错误的字符串。

sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个TCP连接的套接字
复制代码

在数据结构 struct sockaddr_in 中, sin_addrsin_port 需要转换为网络字节顺序,而sin_family 不需要

sin_addrsin_port 分别封装在包的 IP 和 UDP 层。因此,它们必须要是网络字节顺序。但是 sin_family 域只是被内核 (kernel) 使用来决定在数据结构中包含什么类型的地址,所以它必须是本机字节顺序。

#include<arpa/inet.h> 
//将主机字节序转换为网络字节序
unit32_t htonl (unit32_t hostlong);
unit16_t htons (unit16_t hostshort); 
//将网络字节序转换为主机字节序
unit32_t ntohl (unit32_t netlong);
unit16_t ntohs (unit16_t netshort);
复制代码

接着将套接字绑定到端口

struct sockaddr_in servaddr; // 用于存放ip和端口的结构
bzero(&servaddr, sizeof(servaddr));    // 将该结构体的所有数据置零
servaddr.sin_family = AF_INET;    // 指定其协议族为IPv4协议族
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);    // 指定IP地址为通配地址
servaddr.sin_port = htons(16555);    // 指定端口号为16555
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
    printf("Bind error(%d): %s\n", errno, strerror(errno));
    return -1;
}
复制代码

INADDR_ANY 转换过来就是0.0.0.0,泛指本机的意思,也就是表示本机的所有IP,因为有些机子不止一块网卡,多网卡的情况下,这个就表示所有网卡ip地址的意思。

connect()bind() 系统调用将套接字文件描述符“关联”到一个地址(通常是ip /端口组合)。 他们的原型是:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
复制代码

bind()将套接字与其本地地址关联 (用于服务器端绑定,以便客户端可以使用该地址连接到服务器)

connect()用于连接到远程[server]地址,用于客户端,使用connect [读取为:连接到服务器]

如果不调用 bind() ,则在调用 connect() (客户端)或 listen() (服务器)时,将为您隐式分配端口和地址并将其绑定到本地计算机上。 但是,这是两者的副作用,而不是目的。 以这种方式分配的端口是短暂的。

这里的重要一点是,不需要绑定客户端,因为客户端连接到服务器,因此即使您使用的是临时端口,服务器也将知道客户端的地址和端口,而不是绑定到特定的端口。

另一方面,尽管服务器可以在不调用 bind() 情况下调用 listen() ,但是在这种情况下,它们将需要发现分配的临时端口,并将其传达给它要连接的任何客户端。

接着令服务器监听对应接口

if (-1 == listen(sockfd, MAXLINK))
{
    printf("Listen error(%d): %s\n", errno, strerror(errno));
    return -1;
}
复制代码

最后持续执行操作直至退出

while(true)
{
    signal(SIGINT, stopServerRunning); //这句用于在输入Ctrl+C的时候关闭服务器
    //对应伪代码中的connfd . accept(sockfd);
    connfd = accept(sockfd, NULL, NULL);
    if (-1 == connfd) 
    {
        printf("Accept error(%d): %s\n", errno, strerror(errno));
        return -1;  
    }
    // END

    bzero(buff, BUFFSIZE);
    //对应伪代码中的recv(connfd, buff);
    recv(connfd, buff, BUFFSIZE - 1, 0);
    // END
    printf("Recv: %s\n", buff);
    //对应伪代码中的send(connfd, buff);
    send(connfd, buff, strlen(buff), 0);
    // END
    //对应伪代码中的close(connfd);
    close(connfd) ;
    // END
}
复制代码

上面已经完成了对服务器的定义 类似的 我们可以定义初始客户端
inet_pton函数 将 IPv4 或 IPv6 Internet 网络地址转换为其数字二进制形式

#define SERVER_IP "192.168.19.12" // 指定服务端的IP
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
//指定要连接的端口的ip地址 也就是将地址转换为IPv4地址 并传入servaddr.sin_addr
inet_pton(AF_INET, SERVER_IP, &servaddr.sin_addr)); 
servaddr.sin_port = htons(SERVER_PORT);
复制代码

接着调用connect函数连接服务端端口

if (-1 == connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
    printf("Connect error(%d): %s\n", errno, strerror(errno));
    return -1;
}
复制代码

最后执行发送输出操作 使用sendrecv发送和获取数据 sendto函数一般用作UDP通信

send(SOCKET,BUFF,SIZE,FLAG)
复制代码

参数一:指定发送端套接字描述符;
参数二:存放应用程序要发送数据的缓冲区;
参数三:实际要发送的数据的字节数;
参数四:一般置为0,此时send为阻塞式发送,即发送不成功会一直阻塞,直到被某个信号终端终止,或者直到发送成功为止。

  • 若指定为MSG_NOSIGNAL,表示当连接被关闭时不会产生SIGPIPE信号
  • 若指定为MSG_DONTWAIT 表示非阻塞发送
recv(SOCKET,BUFF,SIZE,FLAG)
复制代码

其他参数类似
参数四一般为 0,其他常见值如下:

  • MSG_PEEK:窥视传入的数据。 数据被复制到缓冲区中,但不会从输入队列中删除。
  • MSG_OOB:处理带外(OOB)数据。
  • MSG_WAITALL:仅当发生以下事件之一时,接收请求才会完成:
    • 调用方提供的缓冲区已完全满。
    • 连接已关闭。
    • 该请求已被取消或发生错误。

利用TCP传递信息时要注意:TCP传输是流的方式:即send 100个字节后对方如果没有及时recv取出,这时又send 100个字节,则recv有可能接收到两次发送叠加的部分或全部数据,所以在传送结构体数据时,应当发送以后睡眠一段时间,使对方recv有足够的实际取走数据,不至于两个结构体数据发生粘连,区分不出发送的是两个结构体数据。

发送的数据将存放到系统缓冲区,当系统缓冲区已满时,send将返回发送的字节数,这时发送的字节数并不是SIZE大小了,同理,recv每次接受的数据也不一定是SIZE大小,而是返回的值大小的字节。

printf("Please input: ");
scanf("%s",buff);
send(sockfd,buff, strlen(buff), 0);
bzero(buff, sizeof(buff));
recv(sockfd, buff, BUFFSIZE-10);
printf("Recv: %s\n", buff);
close(sockfd);
复制代码

2. 搭建HTTP服务器

HTTP请求串其格式如下:

方法名 URL 协议版本 //请求行
字段名:字段值 //消息报头
字段名:字段值 //消息报头
...
字段名:字段值 //消息报头
请求正文 //可选
复制代码

每一行都以\r``\n结尾,表示一个换行。

对应的就有一个叫做HTTP返回串的东西,这个也是有格式规定的:

协议版本 状态码 状态描述 //状态行
字段名:字段值 //消息报头
字段名:字段值 //消息报头
...
字段名:字段值 //消息报头
响应正文 //可选
复制代码

在代码中,我们可以在服务器端写一个函数用于在buff中写入这个返回串:

void
setResponse(char *buff)
{
    bzero(buff, sizeof(buff));
    strcat(buff, "HTTP/1.1 200 0K\r\n");
    strcat(buff, "Connection: close\r\n");
    strcat(buff, "\r\n");
    strcat(buff, "Hello\n");
}
复制代码

使用curl指令可以发送一个HTTP请求(其实就是类似浏览器打开"http://192.168.19.12:16555/"的页面一样):

curl -v "http://192.168.19.12:16555/"
复制代码

结果

3. 压力测试

服务器是HTTP服务器,故这个时候就可以直接使用Apache Bench压力测试工具了。

由于这个工具的测试方式是模拟大量的HTTP请求,故无法适用于之前的裸socket服务器,所以只能测试现在的HTTP服务器。

使用方法很简单,直接运行以下指令即可:

ab -c 1 -n 10000 "http://192.168.19.12:16555/"
复制代码

ab的原理:ab命令会创建多个并发访问线程,模拟多个访问者同时对某一URL地址进行访问。

它的测试目标是基于URL的,因此,它既可以用来测试apache的负载压力,也可以测试nginx、lighthttp、tomcat、IIS等其它Web服务器的压力。

ab是一个命令行工具, ab命令对发出负载的计算机要求很低,它既不会占用很高CPU,也不会占用很多内存。但却会给目标服务器造成巨大的负载,其原理类似CC攻击。

用法

ab [options] [http://]hostname[:port]/path
复制代码

下面是参数

  • -n m 本次测试发起的总请求数m
  • -c n 一次产生的请求数(或并发数)n
  • -t n 测试所进行的最大秒数n,默认没有时间限制。
  • -r 抛出异常继续执行测试任务
  • -p file 包含了需要POST的数据的文件,使用方法是 -p 111.txt

返回结果比较重要的有:

  • Failed requests:失败请求数。
  • Requests per second:每秒处理的请求数,也就是吞吐率。
  • Transfer rate:传输速率,表示每秒收到多少的数据量。
  • 最下面的表:表示百分之xx的请求数的响应时间的分布,可以比较直观的看出请求响应时间分布。