使用 C++ 和 Windows Winsock 的简单客户端-服务器网络
如何使用 C++ 和 Windows Winsock 为多人游戏编程创建客户端-服务器网络
引言
我最近在加州大学圣地亚哥分校完成了一个视频游戏设计与实现课程,并与另一位队友负责视频游戏的网络部分。我想写一个指南来演示如何使用 C++、Windows Winsock 2 库和 TCP 设置一个简单的客户端-服务器网络。通过本指南,您将能够在服务器和客户端之间发送和接收简单的包。
背景
Microsoft 提供了非常有用的指南,说明如何使用其 Winsock 库创建客户端和服务器,但我只是想进一步澄清并指导那些将其用于游戏编程的人,并解释我是如何使用它的。我将所有点连接起来,这样您就不必再费心了
我强烈建议您使用 Microsoft Visual Studio 遵循本教程。我使用的是 2010 Ultimate 版本,我的项目是 Visual C++ Win32 控制台应用程序。
客户端连接到服务器
服务器和客户端都将拥有自己的套接字,它们将使用这些套接字通过 TCP 连接发送和接收数据。首先,我们将创建一个类,它将 Winsock 2 库的发送和接收功能封装成更简单的形式,以提高可读性。我们将在服务器和客户端网络类中使用此类来发送和接收数据。这个类不是完全必要的,但它会使我们的代码在未来更容易理解。
让我们创建一个名为“NetworkServices”的类。在其头文件(“NetworkService.h”)中包含以下库:
#pragma once
#include <winsock2.h>
#include <Windows.h>
上述库包含 Winsock 库的接收和发送功能所需的函数。
这个类不需要构造函数/析构函数。另外,声明以下静态包装函数,这些函数将由服务器和客户端使用:
class NetworkServices
{
public:
static int sendMessage(SOCKET curSocket, char * message, int messageSize);
static int receiveMessage(SOCKET curSocket, char * buffer, int bufSize);
};
现在让我们编写包装函数的定义,并在类中(NetworkServices.cpp)使用 Winsock 库函数来发送和接收数据。
**注意:我在我的 cpp 文件中 #include 了“stdafx.h”,这是一个由 Visual Studio 自动生成的文件。您可能不需要它,在这种情况下您可以删除该行。但是,用户报告在 VS 中工作时不包含此文件时会出现错误。
#include "stdafx.h"
#include "NetworkServices.h"
int NetworkServices::sendMessage(SOCKET curSocket, char * message, int messageSize)
{
return send(curSocket, message, messageSize, 0);
}
sendMessage() 接受一个套接字类型对象来发送消息,一个指向我们要发送消息的缓冲区的指针,以及消息的大小。然后我们调用 Winsock 库的 send() 并为其提供必要的信息。提供给 send() 的“0”通常用于设置 send() 以不同于其默认方式工作时的标志,但在我们的例子中,我们不需要设置任何标志。send 函数将返回一个 int 值,表示成功发送的字节数,如果通过套接字发送时出现问题,则返回一个错误值。请务必在您的实际应用程序中检查此值。如果您想了解 send 函数的工作原理,请访问此处。
int NetworkServices::receiveMessage(SOCKET curSocket, char * buffer, int bufSize)
{
return recv(curSocket, buffer, bufSize, 0);
}
receiveMessage() 接受一个套接字类型对象,用于检查该套接字上网络中是否有可用数据可供读取。它将把所有读取的数据放入我们的“缓冲区”中,并且需要一个缓冲区大小来指示每次调用 receiveMessage() 时它可以读取的最大量。然后我们将此信息提供给 recv(),它是 Winsock 库中用于从套接字接收数据的函数。它还将返回一个 int 值,表示它读取到我们缓冲区中的字节数,如果从套接字接收时出现问题,则返回一个错误。请务必在您的实际应用程序中检查此值。提供给 recv() 的“0”与 send 中的“0”工作方式相同,此处有更多关于 recv() 的信息。
我们现在创建网络的客户端部分。声明一个名为“ClientNetwork.h”的 ClientNetwork 头文件,并包含以下库:
// Networking libraries
#include <winsock2.h>
#include <Windows.h>
#include "NetworkServices.h"
#include <ws2tcpip.h>
#include <stdio.h>
并定义以下常量和库链接。
// size of our buffer
#define DEFAULT_BUFLEN 512
// port to connect sockets through
#define DEFAULT_PORT "6881"
// Need to link with Ws2_32.lib, Mswsock.lib, and Advapi32.lib
#pragma comment (lib, "Ws2_32.lib")
#pragma comment (lib, "Mswsock.lib")
#pragma comment (lib, "AdvApi32.lib")
在头文件中声明以下变量/函数
class ClientNetwork
{
public:
// for error checking function calls in Winsock library
int iResult;
// socket for client to connect to server
SOCKET ConnectSocket;
// ctor/dtor
ClientNetwork(void);
~ClientNetwork(void);
};
我们已准备好实现客户端连接到服务器。在 ClientNetwork.cpp 中 ClientNetwork 的构造函数中,初始化 Winsock。我不会深入解释其工作原理。如果您想了解更多信息,可以查阅 Microsoft 库。但是,您很可能不需要更改这些值。
#include "stdafx.h"
#include "ClientNetwork.h"
ClientNetwork::ClientNetwork(void) {
// create WSADATA object
WSADATA wsaData;
// socket
ConnectSocket = INVALID_SOCKET;
// holds address info for socket to connect to
struct addrinfo *result = NULL,
*ptr = NULL,
hints;
// Initialize Winsock
iResult = WSAStartup(MAKEWORD(2,2), &wsaData);
if (iResult != 0) {
printf("WSAStartup failed with error: %d\n", iResult);
exit(1);
}
// set address info
ZeroMemory( &hints, sizeof(hints) );
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP; //TCP connection!!!
目前,我已将服务器地址设置为 localhost,您可以根据需要更改它。
//resolve server address and port
iResult = getaddrinfo("127.0.0.1", DEFAULT_PORT, &hints, &result);
if( iResult != 0 )
{
printf("getaddrinfo failed with error: %d\n", iResult);
WSACleanup();
exit(1);
}
// Attempt to connect to an address until one succeeds
for(ptr=result; ptr != NULL ;ptr=ptr->ai_next) {
// Create a SOCKET for connecting to server
ConnectSocket = socket(ptr->ai_family, ptr->ai_socktype,
ptr->ai_protocol);
if (ConnectSocket == INVALID_SOCKET) {
printf("socket failed with error: %ld\n", WSAGetLastError());
WSACleanup();
exit(1);
}
// Connect to server.
iResult = connect( ConnectSocket, ptr->ai_addr, (int)ptr->ai_addrlen);
if (iResult == SOCKET_ERROR)
{
closesocket(ConnectSocket);
ConnectSocket = INVALID_SOCKET;
printf ("The server is down... did not connect");
}
}
// no longer need address info for server
freeaddrinfo(result);
// if connection failed
if (ConnectSocket == INVALID_SOCKET)
{
printf("Unable to connect to server!\n");
WSACleanup();
exit(1);
}
这是一个重要的信息。我们将把我们的套接字设置为非阻塞模式,这样当没有数据可发送/接收时,它就不会在 send() 和 receive() 函数上等待。这对于我们的多人游戏是必要的,因为我们希望在没有数据可发送或从客户端接收时,游戏能够继续进行。
// Set the mode of the socket to be nonblocking
u_long iMode = 1;
iResult = ioctlsocket(ConnectSocket, FIONBIO, &iMode);
if (iResult == SOCKET_ERROR)
{
printf("ioctlsocket failed with error: %d\n", WSAGetLastError());
closesocket(ConnectSocket);
WSACleanup();
exit(1);
}
我们还将禁用 Nagle 算法。(这不是强制性的)
//disable nagle
char value = 1;
setsockopt( ConnectSocket, IPPROTO_TCP, TCP_NODELAY, &value, sizeof( value ) );
}
这是我们 ClientNetwork 构造函数的结尾。
现在让我们转到设置我们的 ServerNetwork 类。
在头文件中,包含以下库:
#include <winsock2.h>
#include <Windows.h>
#include "NetworkServices.h"
#include <ws2tcpip.h>
#include <map>
using namespace std;
#pragma comment (lib, "Ws2_32.lib")
并定义以下常量
#define DEFAULT_BUFLEN 512
#define DEFAULT_PORT "6881"
我们还需要在头文件中声明这些内容:
class ServerNetwork
{
public:
ServerNetwork(void);
~ServerNetwork(void);
// Socket to listen for new connections
SOCKET ListenSocket;
// Socket to give to the clients
SOCKET ClientSocket;
// for error checking return values
int iResult;
// table to keep track of each client's socket
std::map<unsigned int, SOCKET> sessions;
};
让我们在 cpp 文件中定义 ServerNetwork 构造函数
#include "stdafx.h"
#include "ServerNetwork.h"
ServerNetwork::ServerNetwork(void)
{
// create WSADATA object
WSADATA wsaData;
// our sockets for the server
ListenSocket = INVALID_SOCKET;
ClientSocket = INVALID_SOCKET;
// address info for the server to listen to
struct addrinfo *result = NULL;
struct addrinfo hints;
// Initialize Winsock
iResult = WSAStartup(MAKEWORD(2,2), &wsaData);
if (iResult != 0) {
printf("WSAStartup failed with error: %d\n", iResult);
exit(1);
}
// set address information
ZeroMemory(&hints, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP; // TCP connection!!!
hints.ai_flags = AI_PASSIVE;
服务器不需要地址,因为它将在本地计算机上。
// Resolve the server address and port
iResult = getaddrinfo(NULL, DEFAULT_PORT, &hints, &result);
if ( iResult != 0 ) {
printf("getaddrinfo failed with error: %d\n", iResult);
WSACleanup();
exit(1);
}
// Create a SOCKET for connecting to server
ListenSocket = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
if (ListenSocket == INVALID_SOCKET) {
printf("socket failed with error: %ld\n", WSAGetLastError());
freeaddrinfo(result);
WSACleanup();
exit(1);
}
// Set the mode of the socket to be nonblocking
u_long iMode = 1;
iResult = ioctlsocket(ListenSocket, FIONBIO, &iMode);
if (iResult == SOCKET_ERROR) {
printf("ioctlsocket failed with error: %d\n", WSAGetLastError());
closesocket(ListenSocket);
WSACleanup();
exit(1);
}
// Setup the TCP listening socket
iResult = bind( ListenSocket, result->ai_addr, (int)result->ai_addrlen);
if (iResult == SOCKET_ERROR) {
printf("bind failed with error: %d\n", WSAGetLastError());
freeaddrinfo(result);
closesocket(ListenSocket);
WSACleanup();
exit(1);
}
// no longer need address information
freeaddrinfo(result);
// start listening for new clients attempting to connect
iResult = listen(ListenSocket, SOMAXCONN);
if (iResult == SOCKET_ERROR) {
printf("listen failed with error: %d\n", WSAGetLastError());
closesocket(ListenSocket);
WSACleanup();
exit(1);
}
}
这完成了我们的服务器初始化。现在我们需要接受尝试连接的客户端。
将以下声明添加到 ServerNetwork 头文件中:
// accept new connections
bool acceptNewClient(unsigned int & id);
这是定义。此函数将接受一个 ID(我们跟踪特定客户端的方式),一旦客户端连接,它将把分配的套接字和 ID 添加到我们的表中。如果添加了新客户端,它将返回 true。
// accept new connections
bool ServerNetwork::acceptNewClient(unsigned int & id)
{
// if client waiting, accept the connection and save the socket
ClientSocket = accept(ListenSocket,NULL,NULL);
if (ClientSocket != INVALID_SOCKET)
{
//disable nagle on the client's socket
char value = 1;
setsockopt( ClientSocket, IPPROTO_TCP, TCP_NODELAY, &value, sizeof( value ) );
// insert new client into session id table
sessions.insert( pair<unsigned int, SOCKET>(id, ClientSocket) );
return true;
}
return false;
}
现在我们将创建一个 ServerGame 类。在该类中,我们将声明并初始化一个 ServerNetwork 对象,该对象将用作我们的网络。在游戏的更大范围内,ServerGame 应该在服务器上保存所有关于游戏的信息,包括网络上客户端的信息。您会随着我们的进展而看到。创建 ServerGame.h 文件。
#include "ServerNetwork.h"
class ServerGame
{
public:
ServerGame(void);
~ServerGame(void);
void update();
private:
// IDs for the clients connecting for table in ServerNetwork
static unsigned int client_id;
// The ServerNetwork object
ServerNetwork* network;
};
在其 cpp 文件中
#include "stdafx.h"
#include "ServerGame.h"
unsigned int ServerGame::client_id;
ServerGame::ServerGame(void)
{
// id's to assign clients for our table
client_id = 0;
// set up the server network to listen
network = new ServerNetwork();
}
void ServerGame::update()
{
// get new clients
if(network->acceptNewClient(client_id))
{
printf("client %d has been connected to the server\n",client_id);
client_id++;
}
}
ServerGame 构造函数中的代码将设置我们之前在 ServerNetwork 中完成的所有网络功能,并使其套接字监听传入连接。在 ServerGame::update() 中,我们将接受尝试连接的新客户端,并为它们分配当前 client_id,如果 ID 已分配给它们,则递增以准备下一个客户端。
让我们创建一个 ClientGame,它应该包含所有客户端信息,包括网络。
创建一个名为“ClientGame.h”的头文件
#include <winsock2.h>
#include <Windows.h>
#include "ClientNetwork.h"
并声明这些变量/函数
class ClientGame
{
public:
ClientGame();
~ClientGame(void);
ClientNetwork* network;
};
在 cpp 文件中
#include "stdafx.h"
#include "ClientGame.h"
ClientGame::ClientGame(void)
{
network = new ClientNetwork();
}
我们终于准备好检查客户端是否真的会连接。
创建一个 main.cpp 并在顶部包含这些库:
// may need #include "stdafx.h" in visual studio
#include "stdafx.h"
#include "ServerGame.h"
#include "ClientGame.h"
// used for multi-threading
#include <process.h>
并在 main.cpp 中创建这些全局变量/函数
void serverLoop(void *);
void clientLoop(void);
ServerGame * server;
ClientGame * client;
这是主函数
int main()
{
// initialize the server
server = new ServerGame();
我们将在一个单独的线程中运行服务器,以便它始终检查新客户端。它将来还将发送和接收数据。
// create thread with arbitrary argument for the run function
_beginthread( serverLoop, 0, (void*)12);
// initialize the client
client = new ClientGame();
这是我们的游戏循环。它将保留控制台,以便我们可以看到 ServerNetwork 的输出,并且可以扩展以执行其他操作。
clientLoop();
}
在 main.cpp 中添加这些函数,serverLoop 将由服务器线程运行,clientLoop 将由主线程运行。
void serverLoop(void * arg)
{
while(true)
{
server->update();
}
}
void clientLoop()
{
while(true)
{
//do game stuff
//will later run client->update();
}
}
恭喜!您现在已将客户端连接到服务器。如果您运行程序,您应该会在控制台中看到一条消息,说明客户端 0 已连接到服务器。
发送和接收数据
现在我们需要设置一些要发送的数据。让我们创建一个名为“NetworkData.h”的头文件。我们不需要 cpp 文件。此文件将只为我们定义一些数据类型。
在此头文件中
#include <string.h>
#define MAX_PACKET_SIZE 1000000
enum PacketTypes {
INIT_CONNECTION = 0,
ACTION_EVENT = 1,
};
现在我们将定义一个包结构,作为我们发送数据的一个容器。
struct Packet {
unsigned int packet_type;
void serialize(char * data) {
memcpy(data, this, sizeof(Packet));
}
void deserialize(char * data) {
memcpy(this, data, sizeof(Packet));
}
};
packet_type 字段将使用我们刚刚创建的枚举器中的值填充。
serialize 函数用于将 packet_type 数据转换为可以通过网络发送的字节。
deserialize 函数用于将通过网络接收到的字节转换回我们可以解释的 packet_type 数据。
您需要在 ServerGame、ServerNetwork、ClientGame 和 ClientNetwork 类中包含 NetworkData.h!
让我们让客户端在首次连接到服务器时,在其构造函数中发送一个 INIT_CONNECTION 包。
ClientGame::ClientGame(void)
{
network = new ClientNetwork();
// send init packet
const unsigned int packet_size = sizeof(Packet);
char packet_data[packet_size];
Packet packet;
packet.packet_type = INIT_CONNECTION;
packet.serialize(packet_data);
NetworkServices::sendMessage(network->ConnectSocket, packet_data, packet_size);
}
我们刚刚创建了一个数据包并将其赋予 INIT_CONNECTION 类型,然后使用我们之前编写的 sendMessage() 函数通过网络发送了它。我们使用了客户端连接到服务器的套接字。
现在让我们在服务器端读取该消息。
将以下函数添加到 ServerNetwork 的头文件中:
在 public 下方
// receive incoming data
int receiveData(unsigned int client_id, char * recvbuf);
然后是 cpp 定义
// receive incoming data
int ServerNetwork::receiveData(unsigned int client_id, char * recvbuf)
{
if( sessions.find(client_id) != sessions.end() )
{
SOCKET currentSocket = sessions[client_id];
iResult = NetworkServices::receiveMessage(currentSocket, recvbuf, MAX_PACKET_SIZE);
if (iResult == 0)
{
printf("Connection closed\n");
closesocket(currentSocket);
}
return iResult;
}
return 0;
}
上述函数将接收指定客户端 ID 的套接字上等待的数据,并用从网络读取的数据填充传入的缓冲区 (recvbuf)。
让我们从 ServerGame 调用它来读取从客户端发送的数据。
将以下内容添加到 ServerGame 的头文件中:
在 public 下方
void receiveFromClients();
在 private 下方
// data buffer
char network_data[MAX_PACKET_SIZE];
在 cpp 文件中定义 receiveFromClients()。在此函数中,我们遍历之前连接时保存在会话表中的所有客户端。然后我们对它们全部调用 receiveData(),并将数据放入我们的 network_data 缓冲区中。然后我们反序列化数据包并根据它是 INIT 数据包还是 ACTION 数据包进行切换。以同样的方式,您可以扩展此程序以处理不同数据包和不同大小的结构容器,以执行不同的操作。
void ServerGame::receiveFromClients()
{
Packet packet;
// go through all clients
std::map<unsigned int, SOCKET>::iterator iter;
for(iter = network->sessions.begin(); iter != network->sessions.end(); iter++)
{
// get data for that client
int data_length = network->receiveData(iter->first, network_data);
if (data_length <= 0)
{
//no data recieved
continue;
}
int i = 0;
while (i < (unsigned int)data_length)
{
packet.deserialize(&(network_data[i]));
i += sizeof(Packet);
switch (packet.packet_type) {
case INIT_CONNECTION:
printf("server received init packet from client\n");
break;
case ACTION_EVENT:
printf("server received action event packet from client\n");
break;
default:
printf("error in packet types\n");
break;
}
}
}
}
现在在 ServerGame::update() 中调用 receiveFromClients()
void ServerGame::update()
{
// get new clients
if(network->acceptNewClient(client_id))
{
printf("client %d has been connected to the server\n",client_id);
client_id++;
}
receiveFromClients();
}
如果您现在运行程序,您应该会看到一条消息,说明客户端正在连接到服务器,然后另一条消息说明服务器已收到 INIT 数据包。
让我们扩展程序,让客户端向服务器发送 ACTION 数据包,服务器向客户端发送 ACTION 数据包。
客户端将发送一个 INIT 包,然后服务器将发送一个 ACTION 包,收到后,客户端将发送一个 ACTION 包,然后服务器将接收 ACTION 包,并发送另一个,依此类推。
在 ServerNetwork 中,添加一个函数,该函数将向所有连接的客户端发送消息。
在头文件中
// send data to all clients
void sendToAll(char * packets, int totalSize);
在 cpp 文件中
// send data to all clients
void ServerNetwork::sendToAll(char * packets, int totalSize)
{
SOCKET currentSocket;
std::map<unsigned int, SOCKET>::iterator iter;
int iSendResult;
for (iter = sessions.begin(); iter != sessions.end(); iter++)
{
currentSocket = iter->second;
iSendResult = NetworkServices::sendMessage(currentSocket, packets, totalSize);
if (iSendResult == SOCKET_ERROR)
{
printf("send failed with error: %d\n", WSAGetLastError());
closesocket(currentSocket);
}
}
}
在 ServerGame.h 中,在 public 字段下添加以下声明:
void sendActionPackets();
在 ServerGame.cpp 中,添加以下函数,该函数将向所有连接的客户端发送动作包。
void ServerGame::sendActionPackets()
{
// send action packet
const unsigned int packet_size = sizeof(Packet);
char packet_data[packet_size];
Packet packet;
packet.packet_type = ACTION_EVENT;
packet.serialize(packet_data);
network->sendToAll(packet_data,packet_size);
}
将 ServerGame::receiveFromClients() 修改为以下内容:
void ServerGame::receiveFromClients()
{
Packet packet;
// go through all clients
std::map<unsigned int, SOCKET>::iterator iter;
for(iter = network->sessions.begin(); iter != network->sessions.end(); iter++)
{
int data_length = network->receiveData(iter->first, network_data);
if (data_length <= 0)
{
//no data recieved
continue;
}
int i = 0;
while (i < (unsigned int)data_length)
{
packet.deserialize(&(network_data[i]));
i += sizeof(Packet);
switch (packet.packet_type) {
case INIT_CONNECTION:
printf("server received init packet from client\n");
sendActionPackets();
break;
case ACTION_EVENT:
printf("server received action event packet from client\n");
sendActionPackets();
break;
default:
printf("error in packet types\n");
break;
}
}
}
}
让我们为客户端添加接收来自服务器数据包的功能。
在 ClientNetwork.h 中,在 public 字段下添加以下内容:
int receivePackets(char *);
并在其 cpp 文件中
int ClientNetwork::receivePackets(char * recvbuf)
{
iResult = NetworkServices::receiveMessage(ConnectSocket, recvbuf, MAX_PACKET_SIZE);
if ( iResult == 0 )
{
printf("Connection closed\n");
closesocket(ConnectSocket);
WSACleanup();
exit(1);
}
return iResult;
}
在 ClientGame 的头文件中,在 public 字段下添加以下内容:
void sendActionPackets();
char network_data[MAX_PACKET_SIZE];
void update();
在 cpp 文件中,添加 sendActionPackets() 函数,类似于 ServerGame 中的函数。
void ClientGame::sendActionPackets()
{
// send action packet
const unsigned int packet_size = sizeof(Packet);
char packet_data[packet_size];
Packet packet;
packet.packet_type = ACTION_EVENT;
packet.serialize(packet_data);
NetworkServices::sendMessage(network->ConnectSocket, packet_data, packet_size);
}
然后编写 update 函数,该函数将持续接收来自服务器的动作包并发送响应的动作包。
void ClientGame::update()
{
Packet packet;
int data_length = network->receivePackets(network_data);
if (data_length <= 0)
{
//no data recieved
return;
}
int i = 0;
while (i < (unsigned int)data_length)
{
packet.deserialize(&(network_data[i]));
i += sizeof(Packet);
switch (packet.packet_type) {
case ACTION_EVENT:
printf("client received action event packet from server\n");
sendActionPackets();
break;
default:
printf("error in packet types\n");
break;
}
}
}
最后,在 main.cpp 的 clientLoop 中调用客户端的 update 函数,以便网络能够发送和接收。
void clientLoop()
{
while(true)
{
//do game stuff
client->update();
}
}
恭喜!!我们终于完成了。如果您运行程序,您应该能够看到客户端和服务器之间正在发送和接收消息!!
最后几点注意事项。我没有介绍关闭连接,但这应该非常简单。您需要从我们的会话表中删除客户端,然后使用 closesocket() 关闭套接字。这我将留给您作为练习。
您可以通过添加更多数据类型(除了我们已经定义的 Packet 类型)来扩展程序。只需将其添加到 NetworkData.h 文件中并编写相应的序列化/反序列化函数。请务必在通过网络接收它们时考虑它们的大小。
另外,我在使用 Visual Studio 构建程序时遇到了一个错误,您可能需要在头文件中使用 "#pragma once"。
祝您事业顺利!