基于C++的简单网络聊天程序
一.实验内容
Socket是最为通用的TCP/IP编程接口,通过调用Socket提供的函数和例程,可以实现TCP/IP网络上的交互。本实验中首先通过调用Socket,实现了一个TCP服务器,可以从客户端循环接收字符串,将之转换为大写后返回给客户端。接着又在此基础上,通过创建线程实现了服务器端对多个客户端的响应。
二.实验原理
1.Socket简介


如图1所示,Socket是应用层与TCP/IP协议族通信的中间软件抽象层,通过其提供的一组接口,将复杂的协议隐藏在其后。网络的Socket数据传输是一种特殊的I/O,Socket具有一个类似于打开文件的函数调用Socket(),该函数返回一个Socket描述符,随后的连接建立、数据传输等操作都是通过该Socket实现。
常见的Socket类型分为两种,分别对应于UDP(SOCK_DGRAM)和TCP(SOCK_STREAM)而图2所示的则是通过Socket实现的TCP通信流程。通过正确调用Socket提供的各种接口,即可实现网络间的程序通信。具体过程如下:
(1)Socket建立:
程序通过调用Socket() 函数来建立Socket,函数原型如下:
int socket(int domain, int type, int protocol);
domain用于指示对应的协议族,一般使用PF_INET用以表示TCP/IP。type则指定socket的类型,对应于TCP的类型为SOCK_STREAM,protocol则一般取0。
Socket()函数返回一个socket描述符,该描述符指向一个内部数据结构,其中包含通信协议、本地协议地址、本地主机端口、远端协议地址以及远端主机端口这一五元组用以表示一个网络连接。
(2)Socket配置:
在使用Socket进行连接之前,必须先对Socket进行配置。在TCP中,客户端通过connect()函数(保存远端及本地信息)而服务器端通过bind()函数(保存本地信息)来实现Socket配置。其中bind()函数原型如下:
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
sockfd是待填充的Socket描述符,而my_addr则指向一个包含本机端口和IP的sockaddr结构,addrlen则一般为sizeof(struct sockaddr)。connect()函数的原型与bind()基本一致,但是其填充的则是服务器即远端的IP和端口信息,而本地的IP和端口信息则由函数自动填充。
(3)连接建立:
服务端在配置好Socket则在对应端口进行监听,等待客户端的连接请求,此时使用的函数为listen(),该函数为对应Socket建立一个输入数据队列,将到达的服务请求保存在队列中,直到程序对之进行处理,即等待accept()。accept()函数原型如下:
int accept(int sockfd, void *addr, int *addrlen);
Sockfd即为被监听的Socket描述符,addr指针则一般指向sockaddr_in变量,该变量存储远端主机的信息。通过accept()建立一个新的Socket连接本地与远端程序,同时仍可以监听之前的socket。
(4)数据传输:
在TCP中,使用send()和recv()两个函数实现数据传输。两函数原型如下:
int send(int sockfd, const void *msg, int len, int flags);
int recv(int sockfd, void *buf, int len, unsigned int flags);
其中sockfd是用于数据传输的Socket描述符,msg和buf指针则指向传输的数据或存储数据的缓冲区,len则是以字节为单位的数据长度。两个函数均返回实际传输的字节数,可以通过将返回值与希望传输的字节数进行比较以确定传输是否成功。
(5)结束连接:
可以通过调用close()函数来关闭socket并释放,也可以通过shutdown来实现单方向关闭连接,其原型如下:
int shutdown(int socket, int how);
其中参数how提供如下选择:0——不允许继续接受数据;1——不允许继续发送数据;2——不允许继续发送和接收数据。
2.C/S通信模型
即客户端/服务器模型,目前大多数的网路通信程序以及应用都属于这种模式。C/S模式将一个网络事务的处理分为两部分,一部分为客户端,负责为用户提供网络请求接口,另一部分为服务器端,负责接收用户的请求,并将服务提供给用户。
从程序实现的角度,服务程序一般预先在一个事先公布的地址进行监听,并等待客户进程与之联系,此时程序被唤醒,建立连接后接收客户进程的请求并做出响应,而在客户端结束连接后,服务器程序也不会终止。由此可知,服务器端与客户端进程的作用是非对称的,其编码实现也不同。
在本次实验中,服务器端和客户端的工作流程分别如下图所示:


三.开发流程及关键代码分析
1.实现服务器端
(1)启动Winsock:
要想在Windows上进行Socket进行编程,首先要启动Winsock。而要实现这一点,首先就要引入动态链接库ws2_32.lib。而在VS中引入方法大致如下:进入工程,打开属性页面,依次选择链接,输入,然后在编辑中加入ws2_32.lib。完成之后就可以在头文件中加入<Winsock2.h>来调用winsock,相应代码如下:

值得注意的一点是,在编译过程中vs会报错C4996,即调用的部分函数过于陈旧,不符合vs的安全规范,需要加上一行#pragma waring(disable:4996)来抑制报错。
为了检查winsock是否已被正确链接到程序中,还可以通过如下代码检查协议栈安装情况:

调用WSAStartup函数,这个函数是连接应用程序与ws2_32.dll的第一个调用.其中,第一个参数是WINSOCK 版本号,第二个参数是指向WSADATA的指针.该函数返回一个INT型值,通过检查这个值来确定初始化是否成功。
在结束调用后,则可以通过WSACleanup()来中止ws2_32.lib的使用。
(2)获取本机IP地址
为了实现网络程序间的交互,服务器端首先需要获取自身的IP地址以填充Socket,这就需要依次调用gethostname()函数获取主机名称,再通过gethostbyname()函数获得主机名称对应的IP地址。相应代码如下:

在获取IP地址之后,还要进行一些处理,将32位的IPAddr转换为由小数点分隔的十进制字符串。
(3)创建套接字与绑定端口
在获取IP地址后,就需要将IP地址和端口等相关信息填入套接字中,以生成用于监听的套接字。具体代码如下:

首先初始化了一个Socket,并且通过相关字段指明该Socket工作于TCP/IP网络上的TCP中(AF_INET,SOCK_STREAM)。接着又完成了服务器端地址的填充,其中包括协议族,端口和IP地址三个内容。而该地址则用于接下来的绑定端口中:

通过上述步骤,就完成了用于监听指定端口的套接字的创建,接下来就进入监听端口以等待连接请求从而建立连接的阶段。
值得注意的是在sockaddr()结构体的赋值过程中,sin_family字段是唯一没有进行网络字节顺序和主机字节顺序转换的,其原因在于,该字段仅是用于指导协议层和网络层进行填充而不同于sin_port和sin_addr字段需要写入消息和报文中进行传输,因此不必进行字节顺序的转换。
(4)监听端口与建立链接
listen() 函数的主要作用就是将 socket() 函数得到的 sockfd 变成一个被动监听的套接字, 用来被动等待客户端的连接, 而参数 backlog 的作用就是设置连接队列的长度。如果有客户端通过 connect() 发起连接请求, 内核就会通过三次握手建立连接, 然后将建立好的连接放到一个已连接队列中。
而accept() 函数的作用就是在已完成连接队列中取出一个已经建立好的连接,进而生成一个新的套接字以实现该客户端与服务器端的链接。如果这个队列中已经没有已完成连接的套接字, 那么 accept() 就会一直阻塞, 直到取得一个已经建立连接的套接字。具体代码如下:

需要注意的是,用于监听的是一个Socket,而用于连接的则是另一个。每次连接成功都会生成一个新的Socket,而用于监听的Socket仍然绑定端口继续监听。
(5)发送和接受数据
在本次实验中,我们通过send()函数和recv()函数来实现函数的收发。其中要注意的是,无论是send()还是recv()实现的都只是和本机协议层的交互而非和远端主机的交互。如send函数只是把buf中的数据成功copy到Socket的发送缓冲的剩余空间里后就返回了,但是此时这些数据并不一定马上被传到连接的另一端。 如果协议在后续的传送过程中出现网络错误的话,那么下一个Socket函数就会返回SOCKET_ERROR。
此外值得注意的是,在进行TCP协议传输的时候,要注意数据流传输的特点,recv和send不一定是一一对应的(一般情况下是一一对应),也就是说并不是send一次,就一定recv一次就接收完,有可能send一次,recv多次才接收完,也可能send多次,一次recv就接收完了。相关代码如下:

此处还使用了循环体来实现对多条信息的收发。
(6)结束链接
在程序的最后需要调用shutdown()关闭套接字,closesock()释放套接字,以及使用WSACleanup()结束ws2_32.lib的调用,相关代码如下:

2.实现客户端
客户端的实现大体上和服务器端一致,在本次实验中,客户端相较于服务器端有两点区别,具体如下。
(1)指定IP和端口
不同于服务器端无需预先了解客户端的地址信息,客户端则应该预先了解服务器端所在的IP地址和端口,因此在本次实验中通过如下代码实现了这一功能:

(2)通过connect( )建立套接字
同样不同于服务器端需要使用多个Socket分别用于监听和链接,简单的客户端只需要通过一个Socket建立连接即可。需要注意的是,该套接字中显式填充的是服务器端的地址信息,而客户端的地址信息则由程序隐式填充,因此客户端也不需要像服务器端事先查询本地IP地址并指定自身端口,具体代码如下:

3.实现服务器端与客户端的一对多交互
首先分析原先的服务器端代码,可以发现程序会在accept()处进入阻塞,直到接收到连接请求。因此首先就应该将accept()放入循环之中,反复接收来自客户端的连接请求。而在建立连接后,创建一个线程处理该连接的数据传输和连接管理,主程序重新循环等待下一个客户端的连接请求,相关代码如下

线程部分调用了WINAPI提供的函数CreateThread()函数,该函数原型如下:

结合之前的代码,即将accept()创建的套接字ClientSocket作为参数传递给线程执行的回调函数ServerThread(),而Servethread()相关代码如下:

其内容大致与单线程时一致,值得注意的是套接字的关闭与释放也是由各线程单独完成,而主线程只需要处理监听套接字。
最后进行检验,程序运行效果如下:


浙公网安备 33010602011771号