5.6 深入理解TCP编程
5.6.1 数据发送和接收涉及的缓冲区
在发送端,数据从调用send函数直到发送出去,主要涉及两个缓冲区:第一个是调用send函数时程序员开辟的缓冲区,需要把这个缓冲区地址传给send函数,这个缓冲区通常称为应用程序发送缓冲区(简称为应用缓冲区);第二个缓冲区是协议栈自己的缓冲区,用于保存send函数传给协议栈的待发送数据和已经发送出去的数据但还没得到确认的数据,这个缓冲区通常称为TCP套接字发送缓冲区(因为处于内核协议栈,所以有时也简称为内核缓冲区)。数据从调用send函数开始到发送出去,涉及两个主要写操作:第一个是把数据从应用程序缓冲区中复制到协议栈的套接字缓冲区;第二个是从套接字缓冲区发送到网络上去。
数据在接收过程中也涉及两个缓冲区,首先数据达到的是TCP套接字的接收缓冲区(也就是内核缓冲区),在这个缓冲区中保存了TCP协议从网络上接收到的与该套接字相关的数据。接着,数据写到应用缓冲区,也就是调用 recv函数时由用户分配的缓冲区(也就是应用缓冲区,这个缓冲区作为recv参数),这个缓冲区用于保存从TCP套接字的接收缓冲区收到并提交给应用程序的网络数据。和发送端一样,两个缓冲区也涉及两个层次的写操作:从网络上接收数据保存到内核缓冲区(TCP套接字的接收缓冲区),然后从内核缓冲区复制数据到应用缓冲区中。
5.6.2 TCP数据传输的特点
(1)TCP是流协议,接收者收到的数据是一个个字节流,没有“消息边界”。
(2)应用层调用发送函数只是告诉内核我需要发送这么多数据,但不是说调用了发送函数,数据马上就发送出去了。发送者并不知道发送数据的真实情况。
(3)真正可以发送多少数据由内核协议栈根据当前网络状态而定。
(4)真正发送数据的时间点也是由内核协议栈根据当前网络状态而定。
(5)接收端在调用接收函数时并不知道接收函数会实际返回多少数据。
5.6.3 数据发送的6种情形
知道了TCP数据传输的特点,我们要进一步结合实际来了解发送数据时可能会产生的6种情形。假设现在发送者调用了2次send函数,分别先后发送了数据A和数据B。我们站在应用层来看,先调用send(A),再调用send(B),想当然地以为A先送出了,然后是B。其实不一定如此。
(1)网络情况良好,A和B的长度没有受到发送窗口、拥塞窗口和TCP最大传输单元的影响。此时协议栈将A和B变成两个数据段发送到网络中。在网络中,它们如图5-4所示。
图5-4
(2)发送A的时候网络状况不好,导致发送A被延迟,此时协议栈将数据A和B合为一个数据段后再发送,并且合并后的长度并未超过窗口大小和最大传输单元。在网络中,它们如图5-5所示。
图5-5
(3)A发送被延迟了,协议栈把A和B合为一个数据,但合并后数据长度超过了窗口大小或最大传输单元。此时协议栈会把合并后的数据进行切分,假如B的长度比A大得多,则切分的地方将发生在B处,即协议栈把B的部分数据进行切割,切割后的数据第二次发送。在网络中,它们如图5-6所示。
图5-6
(4)A发送被延迟了,协议栈把A和B合为一个数据,但合并后数据长度超过了窗口大小或最大传输单元。此时协议栈会把合并后的数据进行切分,如果A的长度比B大得多,则切分的地方将发生在A处,即协议栈把A的部分数据进行切割,切割后的部分A先发送,剩下的部分A和B一起合并发送。在网络中,它们如图5-7所示。
图5-7
(5)接收方的接收窗口很小,内核协议栈会将发送缓冲区的数据按照接收方的接收窗口大小进行切分后再依次发送。在网络中,它们如图5-8所示。
图5-8
(6)发送过程发生了错误,数据发送失败。
5.6.4 数据接收时碰到的情形
前面说了发送数据的时候,内核协议栈在处理发送数据时可能会出现6种情形。现在我们来看接收数据时会碰到哪些情况。对于本次接收函数recv应用缓冲区足够大,它调用后,通常有以下几种情况:
第一,接收到本次达到接收端的全部数据。
注意,这里的全部数据是已经达到接收端的全部数据,不是说发送端发送的全部数据,即本地到达多少数据,接收端就接收本次全部数据。我们根据发送端的几种发送情况来推导达到接收端的可能情况:
·对于发送端(1)的情况,如果到达接收端的全部数据是A,则接收端应用程序就全部收到了A。
·对于发送端(2)的情况,如果到达接收端的全部数据是A和B,则接收端应用程序就全部收到了A和B。
·对于发送端(3)的情况,如果到达接收端的全部数据是A和B1,则接收端应用程序就全部收到了A和B1。
·对于发送端(4)和(5)的情况,如果到达接收端的全部数据是部分A,比如(4)中A1是部分A,(5)中开始的一个矩形条也是部分A,则接收端应用程序收到的是部分A。
第二,接收到达到接收端数据的部分。
如果接收端的应用程序的接收缓冲区较小,就有可能只收到已达到接收端的全部数据中的部分数据。
综上所述,TCP网络内核如何发送数据与应用层调用send函数提交给TCP网络核没有直接关系。我们也无法对接收数据的返回时机和接收到的数量进行预测,为此需要在编程中做正确处理。另外,在使用TCP开发网络程序的时候,不要有“数据边界”的概念,TCP是一个流协议,没有数据边界的概念。这几点值得我们在开发TCP网络程序时多加注意。
第三,没有接收到数据。
表明接收端接收的时候,数据还没有准备好。此时,应用程序将阻塞或recv返回一个“数据不可得”的错误码。通常这种情况发生在发送端出现(6)的那种情况,即发送过程发生了错误,数据发送失败。
通过上面TCP发送和接收的分析,我们可以得出2个“无关”结论,这个“无关”也可理解为独立。
(1)应用程序调用send函数的次数和内核封装数据的个数是无关的。
(2)对于要发送的一定长度的数据而言,发送端调用send函数的次数和接收端调用recv函数的次数是无关的,完全独立的。比如,发送端调用一次send函数,可能接收端会调用多次recv函数来接收。同样,接收端调用一次recv函数也可能收到的是发送端多次调用send后发来的数据。
了解了接收会碰到的情况后,我们写程序时,就要合理地处理多种情况。首先,我们要能正确地处理接收函数recv的返回值。我们来看一下recv函数的调用形式:
char buf[SIZE]; int res = recv(s,buf,SIZE,0);
如果没有出现错误,recv返回接收的字节数,buf参数指向的缓冲区将包含接收的数据。如果连接已正常关闭,那么返回值为零,即res为0。如果出现错误,就将返回SOCKET_ERROR的值,并且可以通过调用函数WSAGetLastError来获得特定的错误代码。
5.6.5 一次请求响应的数据接收
一次请求响应的数据接收,就是接收端接收完全部数据后接收结束,发送端断开连接。我们可以通过连接是否关闭来知道数据接收是否结束。
对于单次数据接收(调用一次recv函数)来讲,recv返回的数据量是不可预测的,也就无法估计接收端在应用层开设的缓冲区是否大于发来的数据量大小,因此我们可以用一个循环的方式来接收。我们可以认为recv返回0就是发送方数据发送完毕了,然后正常关闭连接。其他情况,我们就要不停地去接收数据,这样数据就不会漏收了。接着我们来看一个例子。当客户端连接服务器端成功后,服务器端先向客户端发一段信息,客户端接收后,再向服务器端发一段信息,最后客户端关闭连接。这一来一回相当于一次聊天。其实,以后开发更完善的点对点的聊天程序可以基于这个例子。我们使用小例子,主要是为了演示清楚原理细节。
【例5.5】一个稍完善的服务器客户机通信程序
(1)新建一个控制台程序,将工程命名为server,并把server工程作为服务器端程序。
(2)打开server.cpp,在其中输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") //Winsock库的引入库 #define BUF_LEN 300 int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err, i, iRes; wVersionRequested = MAKEWORD(2, 2); //制作Winsock库的版本号 err = WSAStartup(wVersionRequested, &wsaData); //初始化Winsock库 if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } //创建一个套接字,用于监听客户端的连接 SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0); SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY); //使用当前主机任意可用IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //使用端口8000 bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); //绑定 listen(sockSrv, 5); //监听 SOCKADDR_IN addrClient; int len = sizeof(SOCKADDR); while (1) { printf("--------等待客户端-----------\n"); //从连接请求队列中取出排在最前面的一个客户端请求,如果队列为空就阻塞 SOCKET sockConn = accept(sockSrv, (SOCKADDR*)&addrClient, &len); char sendBuf[100]=""; for (i = 0; i < 10; i++) { sprintf_s(sendBuf, "N0.%d欢迎登录服务器,请问1+1等于几?(客户端IP:%s) ", i + 1, inet_ntoa(addrClient.sin_addr));//组成字符串 send(sockConn, sendBuf, strlen(sendBuf) , 0); //发送字符串给客户端 memset(sendBuf, 0, sizeof(sendBuf)); } // 数据发送结束,调用shutdown()函数声明不再发送数据,此时客户端仍可以接收数据 iRes = shutdown(sockConn, SD_SEND); if (iRes == SOCKET_ERROR) { printf("shutdown failed with error: %d\n", WSAGetLastError()); closesocket(sockConn); WSACleanup(); return 1; } //发送结束,开始接收客户端发来的信息 char recvBuf[BUF_LEN]; // 持续接收客户端数据,直到对方关闭连接 do { iRes = recv(sockConn, recvBuf, BUF_LEN, 0); if (iRes > 0) { printf("\nRecv %d bytes:", iRes); for (i = 0; i < iRes; i++) printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0) printf("\n客户端关闭连接了\n"); else { printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockConn ); WSACleanup(); return 1; } } while (iRes > 0); closesocket(sockConn); //关闭和客户端通信的套接字 puts("是否继续监听?(y/n)"); char ch[2]; scanf_s("%s", ch, 2); //读控制台两个字符,包括回车符 if (ch[0] != 'y') //如果不是y就退出循环 break; } closesocket(sockSrv); //关闭监听套接字 WSACleanup(); //释放套接字库 return 0; }
代码中做了详细注释。我们可以看到,服务器端在接收客户端数据的时候用了循环结构。我们在发送的时候也用了一个for循环,这是为了模拟多次发送。通过后面客户端代码可以看到,发送多少次和客户端接收的次数是没有关系的。值得注意的是,发送完毕后调用shutdown来关闭发送,这样客户端就不会阻塞在recv那里死等了。下面建立客户端工程。
(3)新建一个控制台工程client。打开client.cpp,输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") #define BUF_LEN 300 int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err; u_long argp; char szMsg[] = "你好,服务器,我已经收到你的信息"; wVersionRequested = MAKEWORD(2, 2); //初始化Winsock库 err = WSAStartup(wVersionRequested, &wsaData); if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } SOCKET sockClient = socket(AF_INET, SOCK_STREAM, 0);//新建一个套接字 SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); //服务器的IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //服务器的监听端口 //向服务器发出连接请求 err = connect(sockClient, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); if (SOCKET_ERROR == err) //判断连接是否成功 { printf("连接服务器失败,请检查服务器是否启动\n"); return 0; } char recvBuf[BUF_LEN]; int i, cn = 1, iRes; do { iRes = recv(sockClient, recvBuf, BUF_LEN, 0); //接收来自服务器的信息 if (iRes > 0) { printf("\nRecv %d bytes:", iRes); for (i = 0; i < iRes; i++) printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0)//对方关闭连接 puts("\n服务器端关闭发送连接了。。。\n"); else { printf("recv failed:%d\n", WSAGetLastError()); printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockClient); WSACleanup(); return 1; } } while (iRes > 0); //开始向客户端发送数据 char sendBuf[100]; for (i = 0; i < 10; i++) { sprintf_s(sendBuf, "N0.%d我是客户端,1+1=2 ", i + 1 );//组成字符串 send(sockClient, sendBuf, strlen(sendBuf) + 1, 0); //发送字符串给客户端 memset(sendBuf, 0, sizeof(sendBuf)); } puts("向服务器端发送数据完成"); closesocket(sockClient); //关闭套接字 WSACleanup(); //释放套接字库 system(0); return 0; }
客户端接收也用了循环结构,这样能正确处理接收时的情况(根据recv的返回值)。数据接收完毕后,也多次调用send函数向服务器端发送数据,发送完毕后调用closesocket来关闭套接字,这样服务器端就不会阻塞在recv那里死等了。
(4)保存工程,先运行服务器端,再运行客户端,服务器端运行结果如图5-9所示。
图5-9
看到服务器端一共接收了2次数据,第一次收到了23字节,第二次接收到了208字节。客户端发来的数据都接收下来了。
客户端运行结果如图5-10所示。
图5-10
可以看到,客户端一共接收了3次数据,第一次收到了58字节数据,第二次收到了300字节,第三次收到了223字节数据。服务器端发来的全部数据都接收下来了。
5.6.6 多次请求响应的数据接收
多次请求响应的数据接收就是接收端要多轮接收数据,每轮接收又包含循环多次接收,一轮接收完毕后,连接并不断开,而是等到多轮接收完毕后才断开连接。在这种情况下,我们的循环接收中不能用recv返回值是否为0来判断连接是否结束了,当然可以作为条件之一,还要增加一个条件,那就是本轮是否全部接收完应接收的数据了。该如何判断呢?
有两种方法,第一种方法是通信双方约定好发送数据的长度,这种方法也称定长数据的接收。比如发送方告诉接收方,我要发送n字节的数据,发完我就断开连接了。那么接收端就要等n字节数据全部接收完后才能退出循环,表示接收完毕。下面看一个例子,服务器给客户端发送约定好的固定长度(比如250字节)的数据后并不断开连接,而是等待客户端的接收成功确认信息。此时,客户端就不能根据连接是否断开来判断接收是否结束了(当然,连接是否断开也要进行判断,因为可能会有意外出现),而是要根据是否接收完250字节来判断了,接收完毕后,再向服务器端发送确认消息。这个过程相当于一个简单的、相互约好的交互协议了。
【例5.6】接收定长数据
(1)新建一个控制台工程,工程名是server,该工程是服务器端工程。
(2)打开server.cpp,输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") //Winsock库的引入库 #define BUF_LEN 300 int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err, i, iRes; wVersionRequested = MAKEWORD(2, 2); //制作Winsock库的版本号 err = WSAStartup(wVersionRequested, &wsaData); //初始化Winsock库 if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } //创建一个套接字,用于监听客户端的连接 SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0); SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY); //使用当前主机任意可用IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //使用端口8000 bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); //绑定 listen(sockSrv, 5); //监听 SOCKADDR_IN addrClient; int cn = 0,len = sizeof(SOCKADDR); while (1) { printf("--------等待客户端-----------\n"); //从连接请求队列中取出排在最前的一个客户端请求,如果队列为空就阻塞 SOCKET sockConn = accept(sockSrv, (SOCKADDR*)&addrClient, &len); char sendBuf[111]=""; for (cn = 0; cn < 50; cn++)/ { memset(sendBuf, 'a' , 111); if (cn == 49) sendBuf[110] = 'b'; //让最后一个字符为'b',这样看起来清楚一点 send(sockConn, sendBuf, 111, 0); //发送字符串给客户端 } //发送结束,开始接收客户端发来的信息 char recvBuf[BUF_LEN]; // 持续接收客户端数据,直到对方关闭连接 do { iRes = recv(sockConn, recvBuf, BUF_LEN, 0); if (iRes > 0) { printf("\nRecv %d bytes:", iRes); for (i = 0; i < iRes; i++) printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0) printf("\n客户端关闭连接了\n"); else { printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockConn); WSACleanup(); return 1; } } while (iRes > 0); closesocket(sockConn); //关闭和客户端通信的套接字 puts("是否继续监听?(y/n)"); char ch[2]; scanf_s("%s", ch, 2); //读控制台两个字符,包括回车符 if (ch[0] != 'y') //如果不是y就退出循环 break; } closesocket(sockSrv); //关闭监听套接字 WSACleanup(); //释放套接字库 return 0; }
在上面的代码中,我们向客户端一共发送5550字节的数据,每次发送111个,一共发送50次。这个长度是和服务器端约好的,发完固定的5550字节后,并不关闭连接,而是继续等待客户端的消息,但不要想当然认为客户端每次收到的都是111个。下面看一下客户端的情况。
(3)新建一个控制台工程作为客户端,工程名是client。打开client.cpp,输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") #define BUF_LEN 250 int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err; u_long argp; char szMsg[] = "你好,服务器,我已经收到你的信息"; wVersionRequested = MAKEWORD(2, 2); //初始化Winsock库 err = WSAStartup(wVersionRequested, &wsaData); if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } SOCKET sockClient = socket(AF_INET, SOCK_STREAM, 0);//新建一个套接字 SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); //服务器的IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //服务器的监听端口 //向服务器发出连接请求 err = connect(sockClient, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); if (SOCKET_ERROR == err) //判断连接是否成功 { printf("连接服务器失败,请检查服务器是否启动\n"); return 0; } char recvBuf[BUF_LEN];// BUF_LEN是250 int i, cn = 1, iRes; int leftlen = 50*111;//这个5550是通信双方约好的 while (leftlen>0) { //接收来自服务器的信息,每次最大只能接收BUF_LE N个数据,具体接收多少未知 iRes = recv(sockClient, recvBuf, BUF_LEN, 0); if (iRes > 0) { printf("\nNo.%d:Recv %d bytes:", cn++,iRes); for (i = 0; i < iRes; i++) //打印本次接收到的数据 printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0)//对方关闭连接 puts("\n服务器端关闭发送连接了。。。\n"); else { printf("recv failed:%d\n", WSAGetLastError()); printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockClient); WSACleanup(); return 1; } leftlen = leftlen - iRes; } //开始向服务器端发送数据 char sendBuf[100]; sprintf_s(sendBuf, "我是客户端,我已经完成数据接收了");//组成字符串 send(sockClient, sendBuf, strlen(sendBuf) + 1, 0); //发送字符串给客户端 memset(sendBuf, 0, sizeof(sendBuf)); puts("向服务器端发送数据完成"); closesocket(sockClient); //关闭套接字 WSACleanup(); //释放套接字库 system(0); return 0; }
在代码中,我们定义了一个变量leftlen,用来表示还有多少数据没有接收,开始的时候是5550字节(和服务器端约好的数字),以后每次接收一部分就减去已经接收到的数据。直到等于0,就全部接收完毕。
(4)保存工程。先运行服务器端,再运行客户端。服务器端运行结果如图5-11所示。
图5-11
客户端运行结果截取2张图:图5-12显示第一次接收的情况,图5-13显示第二次接收的情况。可以看到,第一次接收到的数据是111。
图5-12
图5-13
通常有两种方法可以知道要接收多少变长数据。
(1)第一种方法是每个不同长度的数据包末尾跟一个结束标识符,接收端在接收的时候,一旦碰到结束标识符,就知道当前的数据包结束了。这种方法必须保证结束符的唯一性,而且效率比较低,所以不常用。结束符的判断方式在实际项目中貌似不受欢迎,因为得扫描每个字符才行。
(2)第二种方法是在变长的消息体之前加一个固定长度的包头,包头里放一个字段,用来表示消息体的长度。接收的时候,先接收包头,然后解析得到消息体长度,再根据这个长度来接收后面的消息体。
具体开发时,我们可以定义这样的结构体:
struct MyData { int nLen; char data[0]; };
其中,nLen用来标识消息体的长度;data是一个数组名,但该数组没有元素,真实地址紧随结构体MyData之后,而这个地址就是结构体后面数据的地址(如果给这个结构体分配的内容大于这个结构体实际大小,后面多余的部分就是这个data的内容)。这种声明方法可以巧妙地实现C语言里的数组扩展。
实际用时采取如下形式:
struct MyData *p = (struct MyData *)malloc(sizeof(struct MyData )+strlen(str))
这样就可以通过p->data来操作这个str。在这里先插入一个小例子,让大家熟悉一下data[0]的用法,在网络程序中不至于用错。基础不牢,地动山摇。
【例5.7】结构体中data[0]的用法
(1)新建一个控制台工程,工程名是test。
(2)在test.cpp中输入如下代码:
#include "stdafx.h" #include <iostream> using namespace std; struct MyData { int nLen; char data[0]; }; int main() { int nLen = 10; char str[10] = "123456789";//别忘记还有一个'\0',所以是10个字符 cout << "Size of MyData: " << sizeof(MyData) << endl; MyData *myData = (MyData*)malloc(sizeof(MyData) + 10); memcpy(myData->data, str, 10); cout << "myData's Data is: " << myData->data << endl; cout << "Size of MyData: " << sizeof(MyData) << endl; free(myData); return 0; }
在代码中,我们首先打印了结构体MyData的大小,结果是4。因为字段nLen是int型,占4字节,可见data[0]并不占据实际存储空间。然后我们分配了长度为(sizeof(MyData) + 10)的空间,10是为data数组申请的空间大小。然后把字符数组str的内容复制到myData->data中,并把内容打印出来。
(3)保存工程并运行,运行结果如图5-14所示。
图5-14
由这个例子可知,data的地址是紧随结构体之后的。相信通过这个小例子,大家对结构体中data[0]的用法有所了解了。下面我们可以把它运用到网络程序中去了。
【例5.8】接收变长数据
(1)新建一个控制台工程,工程名是server,该工程是服务器端工程。
(2)打开server.cpp,输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") //Winsock库的引入库 #define BUF_LEN 300 struct MyData { int nLen; char data[0]; } ; int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err, i, iRes; wVersionRequested = MAKEWORD(2, 2); //制作Winsock库的版本号 err = WSAStartup(wVersionRequested, &wsaData); //初始化Winsock库 if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } //创建一个套接字,用于监听客户端的连接 SOCKET sockSrv = socket(AF_INET, SOCK_STREAM, 0); SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = htonl(INADDR_ANY); //使用当前主机任意可用IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //使用端口8000 bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); //绑定 listen(sockSrv, 5); //监听 SOCKADDR_IN addrClient; int cn = 0,len = sizeof(SOCKADDR); struct MyData *mydata; while (1) { printf("--------等待客户端-----------\n"); //从连接请求队列中取出排在最前的一个客户端请求,如果队列为空就阻塞 SOCKET sockConn = accept(sockSrv, (SOCKADDR*)&addrClient, &len); cn = 5550; //总共要发送5550字节的消息体,这个长度是发送端设定的,没和接收端约好 { mydata = (MyData*)malloc(sizeof(MyData) + cn); mydata->nLen = htonl(cn); //整型数据要转为网络字节序 memset(mydata->data, 'a', cn); mydata->data[cn - 1] = 'b'; //发送全部数据给客户端 send(sockConn, ( char*)mydata, sizeof(MyData) + cn, 0); free(mydata); } //发送结束,开始接收客户端发来的信息 char recvBuf[BUF_LEN]; // 持续接收客户端数据,直到对方关闭连接 do { iRes = recv(sockConn, recvBuf, BUF_LEN, 0); if (iRes > 0) { printf("\nRecv %d bytes:", iRes); for (i = 0; i < iRes; i++) printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0) printf("\n客户端关闭连接了\n"); else { printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockConn); WSACleanup(); return 1; } } while (iRes > 0); closesocket(sockConn); //关闭和客户端通信的套接字 puts("是否继续监听?(y/n)"); char ch[2]; scanf_s("%s", ch, 2); //读控制台两个字符,包括回车符 if (ch[0] != 'y') //如果不是y就退出循环 break; } closesocket(sockSrv); //关闭监听套接字 WSACleanup(); //释放套接字库 return 0; }
代码的总体架构和先前的例子类似,也是共要发送5550字节的消息体(注意是消息体,实际发送的是5550+4),4是长度字段的字节数,只不过这个长度是发送端设定的,没和接收端约好。所以我们定义了一个结构体,结构体的头部整型字段nLen表示消息体的长度(这里是5550)。由于我们采用了0数组,所以分配的空间是连续的,因此send的时候,可以将结构体地址作为参数代入send函数,但注意长度是sizeof(MyData) + cn,表示长度字段的长和消息体的长。
这样发送出去后,接收端那里先接收4字节的长度字段,然后知道消息体长度就可以准备空间了。准备好空间后,可以按照固定长度的接收来进行。具体看客户端代码。
(3)新建一个控制台工程作为客户端,工程名是client。打开client.cpp,输入如下代码:
#include "stdafx.h" #define _WINSOCK_DEPRECATED_NO_WARNINGS // 为了使用inet_ntoa时不出现警告 #include <Winsock2.h> #pragma comment(lib, "ws2_32.lib") #define BUF_LEN 250 int _tmain(int argc, _TCHAR* argv[]) { WORD wVersionRequested; WSADATA wsaData; int err; u_long argp; char szMsg[] = "你好,服务器,我已经收到你的信息"; wVersionRequested = MAKEWORD(2, 2); //初始化Winsock库 err = WSAStartup(wVersionRequested, &wsaData); if (err != 0) return 0; //判断返回的版本号是否正确 if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) { WSACleanup(); return 0; } SOCKET sockClient = socket(AF_INET, SOCK_STREAM, 0);//新建一个套接字 SOCKADDR_IN addrSrv; addrSrv.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); //服务器的IP addrSrv.sin_family = AF_INET; addrSrv.sin_port = htons(8000); //服务器的监听端口 //向服务器发出连接请求 err = connect(sockClient, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR)); if (SOCKET_ERROR == err) //判断连接是否成功 { printf("连接服务器失败,请检查服务器是否启动\n"); return 0; } char recvBuf[BUF_LEN]; int i, cn = 1, iRes; int leftlen; unsigned char *pdata; //接收来自服务器的信息 iRes = recv(sockClient, (char*)&leftlen, sizeof(int), 0); leftlen = ntohl(leftlen); while (leftlen > 0) { iRes = recv(sockClient, recvBuf, BUF_LEN, 0); //接收来自服务器的信息 if (iRes > 0) { printf("\nNo.%d:Recv %d bytes:", cn++, iRes); for (i = 0; i < iRes; i++) printf("%c", recvBuf[i]); printf("\n"); } else if (iRes == 0)//对方关闭连接 puts("\n服务器端关闭发送连接了。。。\n"); else { printf("recv failed:%d\n", WSAGetLastError()); printf("recv failed with error: %d\n", WSAGetLastError()); closesocket(sockClient); WSACleanup(); return 1; } leftlen = leftlen - iRes; } char sendBuf[100]; sprintf_s(sendBuf, "我是客户端,我已经完成数据接收了");//组成字符串 send(sockClient, sendBuf, strlen(sendBuf) + 1, 0); //发送字符串给客户端 memset(sendBuf, 0, sizeof(sendBuf)); puts("向服务器端发送数据完成"); closesocket(sockClient); //关闭套接字 WSACleanup(); //释放套接字库 system(0); return 0; }
代码和定长接收的例子的客户端类似,只不过多了先接收4字节的消息体长度值,然后分配这个大小空间,后面的接收又和定长接收一样了。
有一点要注意,从recv函数接收下来的长度要转为主机字节序:
leftlen = ntohl(leftlen);
这是因为服务器端程序是把长度转为网络字节序后再发送出去的。有些人可能会觉得这样做多此一举,因为双方不转似乎也能得到正确长度。这是因为这些人是在本机或局域网环境下测试的,并没有经过路由器网络环境。大家最好保持转的习惯,因为路由器和路由器之间都是按网络字节序转发的。大家在编写网络程序时碰到发送整型时,应该转为网络字节序再发送,接收时转为主机字节序再使用。
(4)保存工程。先运行服务器端,再运行客户端。服务器端的运行结果如图5-15所示。客户端的运行结果如图5-16所示。
图5-15
图5-16
收了22次的250字节和最后一次的50字节,加起来正好是5550字节数据。