所在的位置: C++ >> C++优势 >> 从零开始的C网络编程

从零开始的C网络编程

北京皮炎哪家医院好 http://m.39.net/baidianfeng/a_9057338.html

导语:本文主要介绍如何从零开始搭建简单的C++客户端/服务器,并进行简单的讲解和基础的压力测试演示。该文章相对比较入门,主要面向了解计算机网络但未接触过网络编程的同学。

本文主要分为四个部分:

搭建C/S:用C++搭建一个最简单的,基于socket网络编程的客户端和服务器

socket库函数浅析:基于上一节搭建的客户端和服务器的代码介绍相关的库函数

搭建HTTP服务器:基于上一节的介绍和HTTP工作过程将最开始搭建的服务器改为HTTP服务器

压力测试入门:优化一下服务器,并使用ab工具对优化前后的服务器进行压力测试并对比结果

.搭建C/S

本节主要讲述如何使用C++搭建一个简单的socket服务器和客户端。

为了能更加容易理解如何搭建,本节会省略许多细节和函数解释,对于整个连接的过程的描述也会比较抽象,细节和解析会留到之后再讲。

服务端和客户端的预期功能

这里要实现的服务端的功能十分简单,只需要把任何收到的数据原封不动地发回去即可,也就是所谓的ECHO服务器。

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

服务端搭建

将上面的需求转化一下就可以得到如下形式:

while(true){buff=接收到的数据;将buff的数据发回去;}

当然,上面的伪代码是省略掉网络连接和断开的过程。这个例子使用的连接形式为TCP连接,而在一个完整的TCP连接中,服务端和客户端通信需要做三件事:

服务端与客户端进行连接

服务端与客户端之间传输数据

服务端与客户端之间断开连接

将这些加入伪代码中,便可以得到如下伪代码:

while(true){与客户端建立连接;buff=接收到从客户端发来的数据;将buff的数据发回客户端;与客户端断开连接;}

首先需要解决的就是,如何建立连接。

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

创建socket(伪代码中简称为socket())

将socket与指定的IP和端口(以下简称为port)绑定(伪代码中简称为bind())

让socket在绑定的端口处监听请求(等待客户端连接到服务端绑定的端口)(伪代码中简称为listen())

而客户端发送连接请求并成功连接之后(这个步骤在伪代码中简称为accept()),服务端便会得到客户端的套接字,于是所有的收发数据便可以在这个客户端的套接字上进行了。

而收发数据其实就是:

接收数据:使用客户端套接字拿到客户端发来的数据,并将其存于buff中。(伪代码中简称为recv())

发送数据:使用客户端套接字,将buff中的数据发回去。(伪代码中简称为send())

在收发数据之后,就需要断开与客户端之间的连接。在socket编程中,只需要关闭客户端的套接字即可断开连接。(伪代码中简称为close())

将其补充进去得到:

sockfd=socket();//创建一个socket,赋给sockfdbind(sockfd,ip::port和一些配置);//让socket绑定端口,同时配置连接类型之类的listen(sockfd);//让socket监听之前绑定的端口while(true){connfd=accept(sockfd);//等待客户端连接,直到连接成功,之后将客户端的套接字返回出来recv(connfd,buff);//接收到从客户端发来的数据,并放入buff中send(connfd,buff);//将buff的数据发回客户端close(connfd);//与客户端断开连接}

这便是socket服务端的大致流程。详细的C++代码如下所示:

#includecstdio#includecstring#includecstdlib#includesys/socket.h#includesys/unistd.h#includesys/types.h#includesys/errno.h#includenetinet/in.h#includesignal.h#defineBUFFSIZE08#defineDEFAULT_PORT//指定端口为#defineMAXLINK08intsockfd,connfd;//定义服务端套接字和客户端套接字voidstopServerRunning(intp){close(sockfd);printf("CloseServer\n");exit(0);}intmain(){structsockaddr_inservaddr;//用于存放ip和端口的结构charbuff[BUFFSIZE];//用于收发数据//对应伪代码中的sockfd=socket();sockfd=socket(AF_INET,SOCK_STREAM,0);if(-==sockfd){printf("Createsocketerror(%d):%s\n",errno,strerror(errno));return-;}//END//对应伪代码中的bind(sockfd,ip::port和一些配置);bzero(servaddr,sizeof(servaddr));servaddr.sin_family=AF_INET;servaddr.sin_addr.s_addr=htonl(INADDR_ANY);servaddr.sin_port=htons(DEFAULT_PORT);if(-==bind(sockfd,(structsockaddr*)servaddr,sizeof(servaddr))){printf("Binderror(%d):%s\n",errno,strerror(errno));return-;}//END//对应伪代码中的listen(sockfd);if(-==listen(sockfd,MAXLINK)){printf("Listenerror(%d):%s\n",errno,strerror(errno));return-;}//ENDprintf("Listening...\n");while(true){signal(SIGINT,stopServerRunning);//这句用于在输入Ctrl+C的时候关闭服务器//对应伪代码中的connfd=accept(sockfd);connfd=accept(sockfd,NULL,NULL);if(-==connfd){printf("Accepterror(%d):%s\n",errno,strerror(errno));return-;}//ENDbzero(buff,BUFFSIZE);//对应伪代码中的recv(connfd,buff);recv(connfd,buff,BUFFSIZE-,0);//ENDprintf("Recv:%s\n",buff);//对应伪代码中的send(connfd,buff);send(connfd,buff,strlen(buff),0);//END//对应伪代码中的close(connfd);close(connfd);//END}return0;}客户端搭建

客户端相对于服务端来说会简单一些。它需要做的事情有:

创建socket

使用socket和已知的服务端的ip和port连接服务端

收发数据

关闭连接

其收发数据也是借助自身的套接字来完成的。

转换为伪代码如下:

sockfd=socket();//创建一个socket,赋给sockfdconnect(sockfd,ip::port和一些配置);//使用socket向指定的ip和port发起连接scanf("%s",buff);//读取用户输入send(sockfd,buff);//发送数据到服务端recv(sockfd,buff);//从服务端接收数据close(sockfd);//与服务器断开连接

这便是socket客户端的大致流程。详细的C++代码如下所示:

#includecstdio#includecstring#includecstdlib#includesys/socket.h#includesys/unistd.h#includesys/types.h#includesys/errno.h#includenetinet/in.h#includearpa/inet.h#defineBUFFSIZE08#defineSERVER_IP"9.68.9."//指定服务端的IP,记得修改为你的服务端所在的ip#defineSERVER_PORT//指定服务端的portintmain(){structsockaddr_inservaddr;charbuff[BUFFSIZE];intsockfd;sockfd=socket(AF_INET,SOCK_STREAM,0);if(-==sockfd){printf("Createsocketerror(%d):%s\n",errno,strerror(errno));return-;}bzero(servaddr,sizeof(servaddr));servaddr.sin_family=AF_INET;inet_pton(AF_INET,SERVER_IP,servaddr.sin_addr));servaddr.sin_port=htons(SERVER_PORT);if(-==connect(sockfd,(structsockaddr*)servaddr,sizeof(servaddr))){printf("Connecterror(%d):%s\n",errno,strerror(errno));return-;}printf("Pleaseinput:");scanf("%s",buff);send(sockfd,buff,strlen(buff),0);bzero(buff,sizeof(buff));recv(sockfd,buff,BUFFSIZE-,0);printf("Recv:%s\n",buff);close(sockfd);return0;}效果演示

将服务端TrainServer.cpp和客户端TrainClient.cpp分别放到机子上进行编译:

g++TrainServer.cpp-oTrainServer.og++TrainClient.cpp-oTrainClient.o

编译后的文件列表如下所示:

lsTrainClient.cppTrainClient.oTrainServer.cppTrainServer.o

接着,先启动服务端:

./TrainServer.oListening...

然后,再在另一个命令行窗口上启动客户端:

./TrainClient.oPleaseinput:

随便输入一个字符串,例如说Re0_CppNetworkProgramming:

./TrainClient.oPleaseinput:Re0_CppNetworkProgrammingRecv:Re0_CppNetworkProgramming

此时服务端也收到了数据并显示出来:

./TrainServer.oListening...Recv:Re0_CppNetworkProgramming

你可以在服务端启动的时候多次打开客户端并向服务端发送数据,服务端每当收到请求都会处理并返回数据。

当且仅当服务端下按ctrl+c的时候会关闭服务端。

.socket库函数浅析

本节会先从TCP连接入手,简单回顾一下TCP连接的过程。然后再根据上一节的代码对这个简单客户端/服务器的socket通信涉及到的库函数进行介绍。

注意:本篇中所有函数都按工作在TCP连接的情况下,并且socket默认为阻塞的情况下讲解。

TCP连接简介什么是TCP协议

在此之前,需要了解网络的协议层模型。这里不使用OSI七层模型,而是直接通过网际网协议族进行讲解。

在网际网协议族中,协议层从上往下如下图所示:

这个协议层所表示的意义为:如果A机和B机的网络都是使用(或可以看作是)网际网协议族的话,那么从机子A上发送数据到机子B所经过的路线大致为:

A的应用层→A的传输层(TCP/UDP)→A的网络层(IPv,IPv6)→A的底层硬件(此时已经转化为物理信号了)→B的底层硬件→B的网络层→B的传输层→B的应用层

而我们在使用socket(也就是套接字)编程的时候,其实际上便是工作于应用层和传输层之间,此时我们可以屏蔽掉底层细节,将网络传输简化为:

A的应用层→A的传输层→B的传输层→B的应用层

而如果使用的是TCP连接的socket连接的话,每个数据包的发送的过程大致为:

数据通过socket套接字构造符合TCP协议的数据包

在屏蔽底层协议的情况下,可以理解为TCP层直接将该数据包发往目标机器的TCP层

目标机器解包得到数据

其实不单是TCP,其他协议的单个数据发送过程大致也是如此。

TCP协议和与其处在同一层的UDP协议的区别主要在于其对于连接和应用层数据的处理和发送方式。

如上一节所述,要使用TCP连接收发数据需要做三件事:

建立连接

收发数据

断开连接

下面将对这三点展开说明:

建立连接:TCP三次握手

在没进行连接的情况下,客户端的TCP状态处于CLOSED状态,服务端的TCP处于CLOSED(未开启监听)或者LISTEN(开启监听)状态。

TCP中,服务端与客户端建立连接的过程如下:

客户端主动发起连接(在socket编程中则为调用connect函数),此时客户端向服务端发送一个SYN包

这个SYN包可以看作是一个小数据包,不过其中没有任何实际数据,仅有诸如TCP首部和TCP选项等协议包必须数据。可以看作是客户端给服务端发送的一个信号

此时客户端状态从CLOSED切换为SYN_SENT

服务端收到SYN包,并返回一个针对该SYN包的响应包(ACK包)和一个新的SYN包。

在socket编程中,服务端能收到SYN包的前提是,服务端已经调用过listen函数使其处于监听状态(也就是说,其必须处于LISTEN状态),并且处于accept函数等待连接的阻塞状态。

此时服务端状态从LISTEN切换为SYN_RCVD

客户端收到服务端发来的两个包,并返回针对新的SYN包的ACK包。

此时客户端状态从SYN_SENT切换至ESTABLISHED,该状态表示可以传输数据了。

服务端收到ACK包,成功建立连接,accept函数返回出客户端套接字。

此时服务端状态从SYN_RCVD切换至ESTABLISHED

收发数据

当连接建立之后,就可以通过客户端套接字进行收发数据了。

断开连接:TCP四次挥手

在收发数据之后,如果需要断开连接,则断开连接的过程如下:

双方中有一方(假设为A,另一方为B)主动关闭连接(调用close,或者其进程本身被终止等情况),则其向B发送FIN包

此时A从ESTABLISHED状态切换为FIN_WAIT_状态

B接收到FIN包,并发送ACK包

此时B从ESTABLISHED状态切换为CLOSE_WAIT状态

A接收到ACK包

此时A从FIN_WAIT_状态切换为FIN_WAIT_状态

一段时间后,B调用自身的close函数,发送FIN包

此时B从CLOSE_WAIT状态切换为LAST_ACK状态

A接收到FIN包,并发送ACK包

此时A从FIN_WAIT_状态切换为TIME_WAIT状态

B接收到ACK包,关闭连接

此时B从LAST_ACK状态切换为CLOSED状态

A等待一段时间(两倍的最长生命周期)后,关闭连接

此时A从TIME_WAIT状态切换为CLOSED状态

socket函数

根据上节可以知道,socket函数用于创建套接字。其实更严谨的讲是创建一个套接字描述符(以下简称sockfd)。

套接字描述符本质上类似于文件描述符,文件通过文件描述符供程序进行读写,而套接字描述符本质上也是提供给程序可以对其缓存区进行读写,程序在其写缓存区写入数据,写缓存区的数据通过网络通信发送至另一端的相同套接字的读缓存区,另一端的程序使用相同的套接字在其读缓存区上读取数据,这样便完成了一次网络数据传输。

而socket函数的参数便是用于设置这个套接字描述符的属性。

该函数的原型如下:

#includesys/socket.hintsocket(intfamily,inttype,intprotocol);family参数

该参数指明要创建的sockfd的协议族,一般比较常用的有两个:

AF_INET:IPv协议族

AF_INET6:IPv6协议族

type参数

该参数用于指明套接字类型,具体有:

SOCK_STREAM:字节流套接字,适用于TCP或SCTP协议

SOCK_DGRAM:数据报套接字,适用于UDP协议

SOCK_SEQPACKET:有序分组套接字,适用于SCTP协议

SOCK_RAW:原始套接字,适用于绕过传输层直接与网络层协议(IPv/IPv6)通信

protocol参数

该参数用于指定协议类型。

如果是TCP协议的话就填写IPPROTO_TCP,UDP和SCTP协议类似。

也可以直接填写0,这样的话则会默认使用family参数和type参数组合制定的默认协议

(参照上面type参数的适用协议)

返回值

socket函数在成功时会返回套接字描述符,失败则返回-。

失败的时候可以通过输出errno来详细查看具体错误类型。

关于errno

通常一个内核函数运行出错的时候,它会定义全局变量errno并赋值。

当我们引入errno.h头文件时便可以使用这个变量。并利用这个变量查看具体出错原因。

一共有两种查看的方法:

直接输出errno,根据输出的错误码进行Google搜索解决方案

当然也可以直接翻man手册

借助strerror()函数,使用strerror(errno)得到一个具体描述其错误的字符串。一般可以通过其描述定位问题所在,实在不行也可以拿这个输出去Google搜索解决方案

bind函数

根据上节可以知道,bind函数用于将套接字与一个ip::port绑定。或者更应该说是把一个本地协议地址赋予一个套接字。

该函数的原型如下:

#includesys/socket.hintbind(intsockfd,conststructsockaddr*myaddr,socklen_taddrlen);

这个函数的参数表比较简单:第一个是套接字描述符,第二个是套接字地址结构体,第三个是套接字地址结构体的长度。其含义就是将第二个的套接字地址结构体赋给第一个的套接字描述符所指的套接字。

接下来着重讲一下套接字地址结构体

套接字地址结构体

在bind函数的参数表中出现了一个名为sockaddr的结构体,这个便是用于存储将要赋给套接字的地址结构的通用套接字地址结构。其定义如下:

#includesys/socket.hstructsockaddr{uint8_tsa_len;sa_family_tsa_family;//地址协议族charsa_data[];//地址数据};

当然,我们一般不会直接使用这个结构来定义套接字地址结构体,而是使用更加特定化的IPv套接字地址结构体或IPv6套接字地址结构体。这里只讲前者。

IPv套接字地址结构体的定义如下:

#includenetinet/in.hstructin_addr{in_addr_ts_addr;//位IPv地址};structsockaddr_in{uint8_tsin_len;//结构长度,非必需sa_family_tsin_family;//地址族,一般为AF_****格式,常用的是AF_INETin_port_tsin_port;//6位TCP或UDP端口号structin_addrsin_addr;//位IPv地址charsin_zero[8];//保留数据段,一般置零};

值得注意的是,一般而言一个sockaddr_in结构对我们来说有用的字段就三个:

sin_family

sin_addr

sin_port

可以看到在第一节的代码中也是只赋值了这三个成员:

#defineDEFAULT_PORT//...structsockaddr_inservaddr;//定义一个IPv套接字地址结构体//...bzero(servaddr,sizeof(servaddr));//将该结构体的所有数据置零servaddr.sin_family=AF_INET;//指定其协议族为IPv协议族servaddr.sin_addr.s_addr=htonl(INADDR_ANY);//指定IP地址为通配地址servaddr.sin_port=htons(DEFAULT_PORT);//指定端口号为//调用bind,注意第二个参数使用了类型转换,第三个参数直接取其sizeof即可if(-==bind(sockfd,(structsockaddr*)servaddr,sizeof(servaddr))){printf("Binderror(%d):%s\n",errno,strerror(errno));return-;}

其中有三个细节需要注意:

在指定IP地址的时候,一般就是使用像上面那样的方法指定为通配地址,此时就交由内核选择IP地址绑定。指定特定IP的操作在讲connect函数的时候会提到。

在指定端口的时候,可以直接指定端口号为0,此时表示端口号交由内核选择(也就是进程不指定端口号)。但一般而言对于服务器来说,不指定端口号的情况是很罕见的,因为服务器一般都需要暴露一个端口用于让客户端知道并作为连接的参数。

注意到不管是赋值IP还是端口,都不是直接赋值,而是使用了类似htons()或htonl()的函数,这便是字节排序函数。

字节排序函数

首先,不同的机子上对于多字节变量的字节存储顺序是不同的,有大端字节序和小端字节序两种。

那这就意味着,将机子A的变量原封不动传到机子B上,其值可能会发生变化(本质上数据没有变化,但如果两个机子的字节序不一样的话,解析出来的值便是不一样的)。这显然是不好的。

故我们需要引入一个通用的规范,称为网络字节序。引入网络字节序之后的传递规则就变为:

机子A先将变量由自身的字节序转换为网络字节序

发送转换后的数据

机子B接到转换后的数据之后,再将其由网络字节序转换为自己的字节序

其实就是很常规的统一标准中间件的做法。

在Linux中,位于netinet/in.h中有四个用于主机字节序和网络字节序之间相互转换的函数:

#includenetinet/in.huint6_thtons(uint6_thost6bitvalue);//hosttonetwork,6bituint_thtonl(uint_thostbitvalue);//hosttonetwork,bituint6_tntohs(uint6_tnet6bitvalue);//networktohost,6bituint_tntohl(uint_tnetbitvalue);//networktohost,bit返回值

若成功则返回0,否则返回-并置相应的errno。

比较常见的错误是错误码EADDRINUSE("Addressalreadyinuse",地址已使用)。

listen函数

listen函数的作用就是开启套接字的监听状态,也就是将套接字从CLOSE状态转换为LISTEN状态。

该函数的原型如下:

#includesys/socket.hintlisten(intsockfd,intbacklog);

其中,sockfd为要设置的套接字,backlog为服务器处于LISTEN状态下维护的队列长度和的最大值。

关于backlog

这是一个可调参数。

其意义为,服务器套接字处于LISTEN状态下所维护的未完成连接队列(SYN队列)和已完成连接队列(Accept队列)的长度和的最大值。

↑这个是原本的意义,现在的backlog仅指Accept队列的最大长度,SYN队列的最大长度由系统的另一个变量决定。

这两个队列用于维护与客户端的连接,其中:

客户端发送的SYN到达服务器之后,服务端返回SYN/ACK,并将该客户端放置SYN队列中(第一次+第二次握手)

当服务端接收到客户端的ACK之后,完成握手,服务端将对应的连接从SYN队列中取出,放入Accept队列,等待服务器中的accept接收并处理其请求(第三次握手)

backlog调参

backlog是由程序员决定的,不过最后的队列长度其实是min(backlog,/proc/sys/net/core/somaxconn,net.ipv.tcp_max_syn_backlog),后者直接读取对应位置文件就有了。

不过由于后者是可以修改的,故这里讨论的backlog实际上是这两个值的最小值。

至于如何调参,可以参考这篇博客:




转载请注明:http://www.aierlanlan.com/grrz/459.html