最近因为工作需要学习了Winsocket客户端服务器模型程序的设计。在学习的过程中,我发现学习Winsocket的资料不多并且十分的零散。我一直没有找到一本学习Winsocket方面的经典国外著作。而且这些资料中并没有提供源代码文件,所以我只有将这些源代码在自己敲一遍。在敲代码的过程中,我发现了这些源代码中的一些错误的地方和一些已经过时的Windows程序的输写方法(Win16?)。现将学习经验和通过阅读各种资料总结出来的模型以及代码分享出来。希望对学习Winsocket的初学者有一定的帮助。
我们首先来了解一下什么是Winsocket。Winsocket是unix/linux下的berkeley socket在Windows下的实现。unix/linux下的berkeley socket是网络通讯方面的基石,应用程序通过调用berkeley socket的API进行相互通讯,berkeley socket则利用具体的网络通讯协议和操作系统的调用来为我们完成具体的通讯工作。Winsocket保留了berkeley socket的所有内容,并且为了其能在Win32消息机制和多线程的环境下更好的工作。Winsocket在berkeley socket原有的基础上对其进行了扩充。如我们可以利用WSAAsyncSelect对Socket消息进行订阅,以及使用WSAGetLastError对多线程环境下的Winsocket错误进行捕获。
接着再让我们来了解一下服务器/客户端应用程序模型。该模型是构建分布式系统的模型之一。服务器程序一直处于监听的状态,等待客户端程序的连接。客户端程序像服务器程序发送连接请求,服务器程序接受该连接请求,同时与客户端程序建立连接。此时客户端程序就可以向服务器发送具体的请求,获取相关的数据。服务器/客户端模型有三种连接方式,一种是面向连接的(TCP),面向连接的服务是一种可靠的服务,它通过数据流进行数据的传输,面向连接的服务实现了无差错无重复的顺寻数据发送。一种是面向无连接的(UDP),面向无连接的服务是一种不可靠的服务,它通过数据报进行数据传输,由于数据报进行传输时的顺序是无序的,所以它是不可靠的服务。最后一种是多播的方式,及服务器程序主动向多个客户端程序发送信息。面向连接的服务器/客户端应用程序模型的程序流程图如下所示:
在此模型的阻塞模式中,服务端程序在执行accept操作、客户端程序connect操作、以及服务端/客户端在进行read和write操作时,如果这些操作既没有成功也没有失败,应用程序会在执行这些操作的地方一直阻塞着。所以我们应该在服务端应用程序的主线程中不停的调用accept操作,以使服务端程序能不停地接受客户端程序发送过来的连接请求。而在接受了一个客户端的连接请求后,我们应改为每一个接受的连接请求开辟一个专门的线程来接受客户端程序发送的请求以及为具体的请求返回特定的信息。
根据以上的程序流程图以及说明,我们可以写出以下的服务端程序源代码:
//////////////////////////////////////////////////////////////////////////////////////////////////// /// /file ServerMultThread/ServerMultThread.cpp /// /// /brief 阻塞式多线程服务器程序。每当客户端程序请求与服务端连接时,服务端程序开放一个线程接受客户端程序的请求 /// 并且向客户端回馈请求的信息。客户端请求的信息输出到控制台中. //////////////////////////////////////////////////////////////////////////////////////////////////// #include <iostream> #include <cassert> #include <WinSock2.h> #include <process.h> #pragma comment(lib, "ws2_32.lib" ) #define ASSERT assert #define THREAD HANDLE #define EVENT HANDLE #define CloseThread CloseHandle #define CloseEvent CloseHandle using std::cin; using std::cout; using std::endl; //////////////////////////////////////////////////////////////////////////////////////////////////// /// /struct tagServerRecv /// /// /brief 线程函数参数结构体,其中包含已建立连接的socket. /// /// /author Shining100 /// /date 2010-05-18 //////////////////////////////////////////////////////////////////////////////////////////////////// typedef struct tagServerRecv { SOCKET skAccept; // 已建立连接的socket CRITICAL_SECTION *pcs; // 同步控制台输出的临界区 EVENT e; // 保证结构体各个字段在结构体字段改变之前将其拷贝到线程中的信号量 THREAD t; // 当前线程的内核对象 DWORD dwThreadID; // 当前线程的ID }SERVER_RECV, *PSERVER_RECV; //////////////////////////////////////////////////////////////////////////////////////////////////// /// /fn static int ServerRecv(LPVOID lParam) /// /// /brief 服务器与建立连接的客户端进行通讯. /// /// /author Shining100 /// /date 2010-05-18 /// /// /param lParam 线程函数参数, 详细信息见上面说明. /// /// /return 总是返回0. //////////////////////////////////////////////////////////////////////////////////////////////////// static int ServerRecv(LPVOID lParam); static const int c_iPort = 10001; int main() { int iRet = SOCKET_ERROR; // 初始化Winsocket,所有Winsocket程序必须先使用WSAStartup进行初始化 WSADATA data; ZeroMemory(&data, sizeof(WSADATA)); iRet = WSAStartup(MAKEWORD(2, 0), &data); ASSERT(SOCKET_ERROR != iRet); // 建立服务端程序的监听套接字 SOCKET skListen = INVALID_SOCKET; skListen = socket(AF_INET, SOCK_STREAM, 0); ASSERT(INVALID_SOCKET != skListen); // 初始化监听套接字地址信息 sockaddr_in adrServ; // 表示网络地址 ZeroMemory(&adrServ, sizeof(sockaddr_in)); adrServ.sin_family = AF_INET; // 初始化地址格式,只能为AF_INET adrServ.sin_port = htons(c_iPort); // 初始化端口,由于网络字节顺序和主机字节顺序相反,所以必须使用htons将主机字节顺序转换成网络字节顺序 adrServ.sin_addr.s_addr = INADDR_ANY; // 初始化IP,由于是服务器程序,所以可以将INADDR_ANY赋给该字段,表示任意的IP // 绑定监听套接字到本地 iRet = bind(skListen, (sockaddr*)&adrServ, sizeof(sockaddr_in)); ASSERT(SOCKET_ERROR != iRet); // 使用监听套接字进行监听 iRet = listen(skListen, SOMAXCONN); // SOMAXCONN表示可以连接到该程序的最大连接数 ASSERT(SOCKET_ERROR != iRet); // 输出控制台缓冲区,由于可能有多个客户端程序可能同时向缓冲区发送请求信息 // 为了保证输出时能够一次性完整的输出完一个客户端的请求信息,所以在输出客 // 户程序的信息到控制台时,必须使用临界区阻塞其它线程 CRITICAL_SECTION cs; InitializeCriticalSection(&cs); // 保证结构体各个字段在结构体字段改变之前将其拷贝到线程中的信号量 // 因为当该结构体拷贝到线程中之前, 有可能有新的连接到来并改变了结构体的值 // 所以我们必须先保证值拷贝过后再接受连接 EVENT e = NULL; e = CreateEvent(NULL, FALSE, FALSE, NULL); ASSERT(NULL != e); for(;;) { // 客户端向服务器端发送连接请求,服务器端接受客户端的连接 SOCKET skAccept = INVALID_SOCKET; sockaddr_in adrClit; ZeroMemory(&adrClit, sizeof(sockaddr_in)); int iLen = sizeof(sockaddr_in); skAccept = accept(skListen, (sockaddr*)&adrClit, &iLen); // 如果没有客户端程序请求连接,服务端程序会一直阻塞在这里等待连接 ASSERT(INVALID_SOCKET != skAccept); SERVER_RECV sr; // 成功创建连接后创建一个独立的线程应答客户请求,以防止应用程序因为阻塞无法应答新的客户请求 // 我们应该先将线程挂起,以便我们能够在线程执行之前初始化线程所需要的结构体变量中的各个字段 THREAD hThread = NULL; DWORD dwThreadID = 0; hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ServerRecv, &sr, CREATE_SUSPENDED, &dwThreadID); ASSERT(NULL != hThread); // 初始化结构体字段 sr.skAccept = skAccept; sr.pcs = &cs; sr.e = e; sr.t = hThread; sr.dwThreadID = dwThreadID; // 启动线程 DWORD dwRet = ResumeThread(hThread); ASSERT(-1 != dwRet); // 保证结构体被拷贝到线程中后再应答新的连接 dwRet = WaitForSingleObject (e, INFINITE); ASSERT(WAIT_FAILED != dwRet); } // 清理线程同步资源 DeleteCriticalSection(&cs); BOOL bRet = FALSE; bRet = CloseEvent(e); ASSERT(bRet); // 关闭该套接字的连接 iRet = shutdown(skListen, SD_SEND); ASSERT(SOCKET_ERROR != iRet); // 清理该套接字的资源 iRet = closesocket(skListen); ASSERT(SOCKET_ERROR != iRet); // 清理Winsocket资源 iRet = WSACleanup(); ASSERT(SOCKET_ERROR != iRet); cin.get(); return 0; } int ServerRecv(LPVOID lParam) { // 拷贝结构体各个字段到线程中 PSERVER_RECV psr = (PSERVER_RECV)lParam; SERVER_RECV sr = { psr->skAccept, psr->pcs, psr->e, psr->t, psr->dwThreadID }; // 设置信号量, 使主线程能够接受新的连接 BOOL bRet = FALSE; bRet = SetEvent(sr.e); ASSERT(bRet); const int c_iBufLen = 512; char szBuf[c_iBufLen + 1] = {'/0'}; const char c_szPrefix[] = "Server recv:"; const int c_iPrefLen = strlen(c_szPrefix); char szRely[c_iBufLen + 16 + 1] = {'/0'}; strcpy(szRely, c_szPrefix); int iRet = SOCKET_ERROR; for(;;) { iRet = recv(sr.skAccept, szBuf, c_iBufLen, 0); // 接收客户端发送的信息, 如果客户端不发送信息,则线程会阻塞到此处 if(0 == iRet) // 客户端优雅的关闭了此连接 { cout << "Connection " << sr.dwThreadID << " shutdown." << endl; break; } else if(SOCKET_ERROR == iRet) // 客户端粗鲁的关闭了此连接或者接受信息出错 { cout << "Connection " << sr.dwThreadID << " recv error." << endl; break; } szBuf[iRet] = '/0'; EnterCriticalSection(sr.pcs); cout << "Connection " << sr.dwThreadID << " says:" << szBuf << endl; // 输出接收到的信息 LeaveCriticalSection(sr.pcs); // 向客户端发送信息 strcpy(szRely + c_iPrefLen, szBuf); iRet = send(sr.skAccept, szRely, strlen(szRely), 0); // 客户端如果没有足够的缓冲区接受信息,则线程会阻塞到此处 if(SOCKET_ERROR == iRet) { cout << "Connection " << sr.dwThreadID << " send error." << endl; break; } } // 关闭该套接口 iRet = shutdown(sr.skAccept, SD_SEND); while(recv(sr.skAccept, szBuf, c_iBufLen, 0) > 0); ASSERT(SOCKET_ERROR != iRet); // 清理该套接口的资源 iRet = closesocket(sr.skAccept); ASSERT(SOCKET_ERROR != iRet); // 关闭该线程对象 bRet = CloseThread(sr.t); ASSERT(bRet); cout << "Connection " << sr.dwThreadID << " exit." << endl; return 0; }
在用Winsocket编写程序时,我们首先必须要进行如下的操作,以为该进程初始化Winsocket和Ws2_32.dll,而使后面的函数调用有效。
WSADATA data; ZeroMemory(&data, sizeof(WSADATA)); iRet = WSAStartup(MAKEWORD(2, 0), &data); ASSERT(SOCKET_ERROR != iRet);
WSAStartup第一个参数为要使用的Winsocket的版本,MAKEWORD(2, 0)表示我们使用Winsocket2.0。第二个参数在WSAStartup初始化后,可以获得一些Winsocket相关信息,如该版本Winsocket所支持的最大socket数量以及UDP包的最大大小。
在初始化了Winsocket后,我们就可以创建一个socket监听客户端的连接请求了。
SOCKET skListen = INVALID_SOCKET; skListen = socket(AF_INET, SOCK_STREAM, 0); ASSERT(INVALID_SOCKET != skListen);
socket函数分配相应的资源并将该socket绑定到一个特定的传输服务提供者。socket的第一个参数为网络地址族,该参数只能为AF_INET,第二个参数可以为SOCK_STREAM或者SOCK_DGRAM。SOCK_STREAM为一个流式套接口,它提供双向可靠、面向连接的TCP服务。SOCK_DGRAM为一个数据报套接口,它提供不可靠、面向无连接的UDP服务。第三个参数一般选择为0,表示由Winsocket选择具体的协议使用。
在建立了一个监听socket后,我们就可以将该套接口与本地地址进行绑定,已将其设置成为网络中一个独一无二的地址。
iRet = bind(skListen, (sockaddr*)&adrServ, sizeof(sockaddr_in)); ASSERT(SOCKET_ERROR != iRet);
在绑定了本地地址后,我们就可以将该socket设置为监听状态,以使该socket可以检测到来自客户端程序的连接请求。
iRet = listen(skListen, SOMAXCONN); // SOMAXCONN表示可以连接到该程序的最大连接数 ASSERT(SOCKET_ERROR != iRet);
接下来我们我们就可以利用套接口接受来自客户端程序的连接了。我们以该套接口为参数调用accept函数,accept函数调用成功后,将建立一个可以接受和发送数据的套接口skAccept。
// 客户端向服务器端发送连接请求,服务器端接受客户端的连接 SOCKET skAccept = INVALID_SOCKET; sockaddr_in adrClit; ZeroMemory(&adrClit, sizeof(sockaddr_in)); int iLen = sizeof(sockaddr_in); skAccept = accept(skListen, (sockaddr*)&adrClit, &iLen); // 如果没有客户端程序请求连接,服务端程序会一直阻塞在这里等待连接 ASSERT(INVALID_SOCKET != skAccept);
在成功的建立了新套接口后,我们就可以利用该套接口在我们的线程函数中接收和发送数据了。
iRet = recv(sr.skAccept, szBuf, c_iBufLen, 0); // 接收客户端发送的信息, 如果客户端不发送信息,则线程会阻塞到此处 if(0 == iRet) // 客户端优雅的关闭了此连接 { cout << "Connection " << sr.dwThreadID << " shutdown." << endl; break; } else if(SOCKET_ERROR == iRet) // 客户端粗鲁的关闭了此连接或者接受信息出错 { cout << "Connection " << sr.dwThreadID << " recv error." << endl; break; } szBuf[iRet] = '/0';
strcpy(szRely + c_iPrefLen, szBuf); iRet = send(sr.skAccept, szRely, strlen(szRely), 0); // 客户端如果没有足够的缓冲区接受信息,则线程会阻塞到此处 if(SOCKET_ERROR == iRet) { cout << "Connection " << sr.dwThreadID << " send error." << endl; break; }
在客户端关闭该套接口或者出现接收发送数据错误的时候,我们都应该关闭该套接口。请注意在调用关闭套接口的closesocket函数之前,我们应该先调用shutdown函数,以使对方可以收到该套接口已经关闭的信息。在调用shutdown函数之后,我们应该使用recv函数读取在队列之中仍未读完的数据,最后我们就可以使用closesocket函数关闭该套接口了,这就是所谓的优雅关闭。
// 关闭该套接口 iRet = shutdown(sr.skAccept, SD_SEND); while(recv(sr.skAccept, szBuf, c_iBufLen, 0) > 0); ASSERT(SOCKET_ERROR != iRet); // 清理该套接口的资源 iRet = closesocket(sr.skAccept); ASSERT(SOCKET_ERROR != iRet);
监听socket的关闭也与上面套接口关闭的方法一致。在关闭了监听套接口后,我们的服务器程序应该调用WSACleanup函数,已完成对Winsocket和ws2_32.dll的清理。上述就是该类型服务器程序应用程序执行的全过程了。客户端程序的代码跟服务器程序的代码相似,程序代码如下所示:
//////////////////////////////////////////////////////////////////////////////////////////////////// /// /file ClientBlock/ClientBlock.cpp /// /// /brief 连接服务器并向服务器发送信息,然后接受服务器发送的信息. //////////////////////////////////////////////////////////////////////////////////////////////////// #include <iostream> #include <cassert> #include <WinSock2.h> #pragma comment(lib, "ws2_32.lib" ) #define ASSERT assert using std::cin; using std::cout; using std::endl; static const char c_szIP[] = "127.0.0.1"; static const int c_iPort = 10001; int main() { int iRet = SOCKET_ERROR; // 初始化Winsocket,所有Winsocket程序必须先使用WSAStartup进行初始化 WSADATA data; ZeroMemory(&data, sizeof(WSADATA)); iRet = WSAStartup(MAKEWORD(2, 0), &data); ASSERT(SOCKET_ERROR != iRet); // 建立连接套接字 SOCKET skClient = INVALID_SOCKET; skClient = socket(AF_INET, SOCK_STREAM, 0); ASSERT(INVALID_SOCKET != skClient); // 初始化连接套接字地址信息 sockaddr_in adrServ; // 表示网络地址 ZeroMemory(&adrServ, sizeof(sockaddr_in)); adrServ.sin_family = AF_INET; // 初始化地址格式,只能为AF_INET adrServ.sin_port = htons(c_iPort); // 初始化端口,由于网络字节顺序和主机字节顺序相反,所以必须使用htons将主机字节顺序转换成网络字节顺序 adrServ.sin_addr.s_addr = inet_addr(c_szIP); // 初始化IP, 由于网络字节顺序和主机字节顺序相反,所以必须使用inet_addr将主机字节顺序转换成网络字节顺序 // 使用连接套接字进行连接 iRet = connect(skClient, (sockaddr*)&adrServ, sizeof(sockaddr_in)); ASSERT(SOCKET_ERROR != iRet); const int c_iBufLen = 512; char szBuf[c_iBufLen + 16 + 1] = {'/0'}; for(;;) { cout << "what you will say:"; cin >> szBuf; if(0 == strcmp("exit", szBuf)) { break; } // 向服务器端发送信息 iRet = send(skClient, szBuf, strlen(szBuf), 0); // 服务器端如果没有足够的缓冲区接受信息,则线程会阻塞到此处 if(SOCKET_ERROR == iRet) { cout << "send error." << endl; break; } // 接收服务器端发送的信息 iRet = recv(skClient, szBuf, c_iBufLen, 0); // 如果服务器端没有发送数据,则会阻塞到此处 if(0 == iRet) { cout << "connection shutdown." << endl; break; } else if(SOCKET_ERROR == iRet) { cout << "recv error." << endl; break; } szBuf[iRet] = '/0'; cout << szBuf << endl; } // 关闭该套接口 iRet = shutdown(skClient, SD_SEND); while(recv(skClient, szBuf, c_iBufLen, 0) > 0); ASSERT(SOCKET_ERROR != iRet); // 清理该套接口的资源 iRet = closesocket(skClient); ASSERT(SOCKET_ERROR != iRet); // 清理Winsocket资源 iRet = WSACleanup(); ASSERT(SOCKET_ERROR != iRet); while(cin.get()); return 0; }
只是需要注意的是,客户端程序需要使用connect建立与服务器程序的连接,在建立了该连接后,我们会得到一个建立了连接的socket,我们可以使用该socket接收和发送数据。
// 初始化连接套接字地址信息 sockaddr_in adrServ; // 表示网络地址 ZeroMemory(&adrServ, sizeof(sockaddr_in)); adrServ.sin_family = AF_INET; // 初始化地址格式,只能为AF_INET adrServ.sin_port = htons(c_iPort); // 初始化端口,由于网络字节顺序和主机字节顺序相反,所以必须使用htons将主机字节顺序转换成网络字节顺序 adrServ.sin_addr.s_addr = inet_addr(c_szIP); // 初始化IP, 由于网络字节顺序和主机字节顺序相反,所以必须使用inet_addr将主机字节顺序转换成网络字节顺序 // 使用连接套接字进行连接 iRet = connect(skClient, (sockaddr*)&adrServ, sizeof(sockaddr_in)); ASSERT(SOCKET_ERROR != iRet);
以上就是建立多线程阻塞式服务器和阻塞式客户端程序的全部过程,但是该类程序模型却一般不在一般的服务器程序上使用。因为多线程序频繁的创建和关闭线程会产生大量的内存碎片。服务器程序一般使用的是非阻塞式模型,我会在教程的下一集中介绍该类型的程序模型。