2.SDL2_net TCP伺服器端和多個客戶端
上一節初步瞭解到了伺服器和客戶端的通訊,並且由於受到程式碼的限制,只能是單個客戶端,而且伺服器無法向客戶端傳送資訊,本節使用SDL_Net的套接字列表(Socket Set)特性來實現比上一節功能更強的程式碼,即一個伺服器對應多臺客戶端。
一.專案結構CMakeLists.txt的編寫
上一節客戶端和伺服器分成了兩個資料夾的結構清晰,但程式碼相對比較重複,其實可以合成為一個資料夾,不過CMakeLists.txt需要生成客戶端和伺服器兩個可執行檔案。
1.專案結構
如上圖所示,build資料夾存放的是中間檔案,即我們在編譯的時候,可以把build當做工作路徑,然後執行:
cmake ..
這樣cmake的快取檔案和編譯的中間檔案都會儲存在build資料夾下,便於管理,也便於刪除。
2.CMakeLists.txt的編寫
由於本次示例需要一個CMakeList.txt來編譯出兩個可執行檔案,而這兩個檔案需求的原始檔也不同,因此需要特別指定,不能簡單地使用下面這個命令:
aux_source_directory(. SRC_LIST)
aux_source_directory()的作用就是獲取對應目錄的原始檔,並放入SRC_LIST變數中。
具體編碼如下:
#工程所需最小版本號 cmake_minimum_required(VERSION 3.10) project(multiple-server) #除錯 Debug Release set(CMAKE_BUILD_TYPE "Debug") SET(CMAKE_CXX_FLAGS_DEBUG "$ENV{CXXFLAGS} -O0 -Wall -g -ggdb") SET(CMAKE_CXX_FLAGS_RELEASE "$ENV{CXXFLAGS} -O3 -Wall") #設定搜尋路徑 set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${CMAKE_SOURCE_DIR}/cmake") #找到SDL2_net庫 find_package(SDL2 REQUIRED) find_package(SDL2_net REQUIRED) #新增對應的標頭檔案搜尋目錄 include_directories(${SDL2_NET_INCLUDE_DIR}) #生成可執行檔案 set(COMMON_LIST "./StringUtils.cpp;./tcputil.cpp") add_executable(server "${COMMON_LIST};./server.cpp;./TCPServer.cpp") #連結對應的函式庫 target_link_libraries(server ${SDL2_NET_LIBRARY} ${SDL2_LIBRARY}) add_executable(client "${COMMON_LIST};./client.cpp") #連結對應的函式庫 target_link_libraries(client ${SDL2_NET_LIBRARY} ${SDL2_LIBRARY}) #設定生成路徑在源路徑下 set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR})
注意這一句:
set(COMMON_LIST "./StringUtils.cpp;./tcputil.cpp")
cmake的變數是有型別的,如果加入分號別表示列表型別,否則為字串型別;字串型別會導致無法找出對應的原始檔。
二.伺服器端的編寫
本次的伺服器端的內容較多,因此我稍微封裝為一個類,其名稱為TCPServer,表示為採用TCP的一個伺服器。下面拆開進行講解。本節主要用到了SocketSet的相關函式和結構體,顧名思義,它是一個套接字列表,官方wiki上解釋大致如下:套接字列表的相關函式主要用於處理多個套接字,當一個套接字存在資料互動或者想要建立連線時才會“通知”你去處理,類似於事件輪訓。
注:這裡翻譯用到了“通知”,其實還是需要在程式碼中進行檢測,而不是函式回撥。
1.標頭檔案TCPServer.h
#ifndef __TCPServer_H__
#define __TCPServer_H__
#include <vector>
#include <string>
#include <cstring>
#include <algorithm>
#include "SDL.h"
#include "SDL_net.h"
#include "tcputil.h"
#include "StringUtils.h"
//class TCPServer
//...
#endif
巨集是為了避免類的二次定義,然後就是添加了必要的標頭檔案。
struct Client
{
std::string name;
TCPsocket socket;
public:
Client(const std::string& name, TCPsocket socket)
:name(name),
socket(socket)
{}
};
Client結構體用來儲存客戶端的名稱和對應的套接字,然後多個客戶端就使用vector<Client>(注:一開始我使用的是map<string, TCPsocket> 但是發現如果要修改它的鍵的話,會比較麻煩,所以後來改為使用vector)。
/*TCP伺服器端,可有多個客戶端*/
class TCPServer
{
private:
//伺服器和多個客戶端
TCPsocket _server;
std::vector<Client> _clients;
SDLNet_SocketSet _set;
unsigned int _setNum;
public:
TCPServer();
~TCPServer();
bool init(Uint16 port);
/**
* 監聽
* @param dt 一幀的時間
* @param timeout 檢測套接字集合的毫秒
*/
void update(float dt, Uint32 timeout);
std::vector<Client>::iterator doCommand(const std::string& msg, Client* client);
/**
* 傳送資訊給所有的客戶端 如果傳送失敗則移除該client
* @param text 傳送的資訊
*/
void sendAll(const std::string& text);
/**
* 給對應的名字的client傳送資訊
* @param name 對應名字的客戶端
* @param text 要傳送的文字
* @return 傳送成功返回true,否則返回false
*/
bool sendTo(const std::string& name, const std::string& text);
private:
//建立或者擴充套件socketSet
void checkSocketSet();
//如果名稱合法,則新增該客戶端
Client* addClient(TCPsocket client, const std::string& name);
//移除客戶端
std::vector<Client>::iterator removeClient(std::vector<Client>::iterator);
std::vector<Client>::iterator removeClient(Client* client);
//使用者名稱是否唯一
bool isUniqueNick(const std::string& name);
};
TCPServer類中,外部用得到的介面主要是init和update函式,其他的函式用得應該比較少,不過這裡暫時未把其餘函式改為私有函式。
2.原始檔TCPServer.cpp
TCPServer::TCPServer()
:_server(nullptr),
_set(nullptr),
_setNum(0)
{
}
TCPServer::~TCPServer()
{
if (_set != nullptr)
{
SDLNet_FreeSocketSet(_set);
_set = nullptr;
}
for (auto it = _clients.begin(); it != _clients.end();)
{
SDLNet_TCP_Close(it->socket);
it = _clients.erase(it);
}
if (_server != nullptr)
{
SDLNet_TCP_Close(_server);
_server = nullptr;
}
}
建構函式負責初始化;解構函式則負責一些回收操作。
①.SDLNet_FreeScoketSet()
void SDLNet_FreeSocketSet(SDLNet_SocketSet set)
釋放套接字集合所佔有的記憶體。
bool TCPServer::init(Uint16 port)
{
IPaddress ip;
if (SDLNet_Init() != 0)
{
printf("SDLNet_Init:%s\n", SDLNet_GetError());
return false;
}
//填充IPaddress
if (SDLNet_ResolveHost(&ip, nullptr, port) != 0)
{
printf("SDLNet_ResolveHost:%s\n", SDLNet_GetError());
return false;
}
//output
Uint32 ipaddr = SDL_SwapBE32(ip.host);
printf("IP Address: %d.%d.%d.%d\n",
ipaddr>>24,
(ipaddr>>16) & 0xff,
(ipaddr>>8) & 0xff,
(ipaddr & 0xff));
//獲取域名
const char* host = SDLNet_ResolveIP(&ip);
if (host != nullptr)
printf("Hostname : %s\n", host);
else
printf("Hostname : N/A\n");
//建立伺服器套接字
_server = SDLNet_TCP_Open(&ip);
return true;
}
init()函式中同上一節相同,進行初始化操作,並建立一個伺服器套接字。
然後就是update的操作,其大致流程大致如下(暫無流程圖,未發現ubuntu下好用的繪圖軟體):
update的流程大致如下:
- 檢測套接字列表中“積極”的套接字個數numReady。“積極”表示存在資料互動/者要建立連線(僅伺服器套接字)。如果沒有或者超時則返回0。
- 如果numReady > 0,則檢測積極的是否是伺服器,即有新的連線,如果是,則嘗試建立連線,並使得numReady--。
- 如果numReady > 0 && 客戶端列表的個數大於0,則遍歷尋找“積極”的客戶端,並進行相應處理。
void TCPServer::update(float dt, Uint32 timeout)
{
int numReady = 0;
TCPsocket socket = nullptr;
this->checkSocketSet();
//檢測套接字集合中積極的套接字個數
numReady = SDLNet_CheckSockets(_set, timeout);
if (numReady == -1)
{
printf("SDLNet_CheckSockets: %s\n", SDLNet_GetError());
return ;
}
//沒有積極的套接字 退出
if (numReady == 0)
return ;
用到的類私有函式之後講解。
②.SDLNet_CheckSockets()
int SDLNet_CheckSockets(SDLNet_SocketSet set, Uint32 timeout)
檢測套接字列表中“積極”的套接字的個數,返回-1則發生錯誤。
- set 套接字列表。
- timeout 檢查的毫秒數。
//伺服器積極 代表有客戶端連線
if (SDLNet_SocketReady(_server))
{
numReady--;
//嘗試獲取client
if ((socket = SDLNet_TCP_Accept(_server)) != nullptr)
{
char* name = nullptr;
//從客戶端獲取名稱
if (getMsg(socket, &name) != nullptr)
{
Client* client = this->addClient(socket, name);
if (client != nullptr)
doCommand("WHO", client);
}
else
{
SDLNet_TCP_Close(socket);
}
}
}
上述程式碼功能如第二個步驟,只不過這裡規定,客戶端要申請加入時,第一個傳送的必為它的名字;而putMsg是對SDLNet_TCP_Send()函式的簡單封裝,getMsg()則是對SDLNet_TCP_Recv()封裝。
③.SDLNet_TCP_SocketReady()
int SDLNet_SocketReady(sock)
檢測此套接字是否準備好了,即是否是“積極”的。這個函式應該僅僅被用在在套接字列表中的套接字,並且應該已經經過了SDLNet_CheckSockets()的處理。
//遍歷客戶端 即獲取資訊
char* message = nullptr;
for (auto it = _clients.begin(); numReady != 0 && it != _clients.end();)
{
std::string name = it->name;
TCPsocket socket = it->socket;
auto it2 = _clients.end();
if (SDLNet_SocketReady(socket))
{
//獲取文字
if (getMsg(socket, &message) != nullptr)
{
numReady--;
auto index = it - _clients.begin();
//命令 執行某些命令可能會使得迭代器失效
if (message[0] == '/' && strlen(message) > 1)
{
it2 = doCommand(message + 1, &_clients[index]);
}
else
{
auto text = StringUtils::format("<%s>%s%",
name.c_str(),
message);
printf("<%s> says:%s\n", name.c_str(), message);
sendAll(text);
}
}
else
{
it = this->removeClient(it);
}
}
it = (it2 == _clients.end()) ? ++it : it2;
}
遍歷找到積極的客戶端套接字,然後獲取其發來的字串,之後判斷是否是命令(命令以“/”開頭),是文字則發給所有客戶端(包括髮送此文字的客戶端);是命令則交給doCommand()函式處理。另外,注意doCommand的返回值,由於doCommand函式可能會刪除客戶端,故返回值型別為迭代器型別。
之後則是doCommand函式,此函式負責一些命令,大致如下:
- /NICK newName 修改客戶端名稱。
- /MSG other [message] 僅僅把message傳送給某個人(私聊)。
- /WHO 列出當前除了自己的所有線上的客戶端的名稱 IP地址和埠號。
- /QUIT [message] 退出。
具體程式碼如下。
std::vector<Client>::iterator TCPServer::doCommand(const std::string& msg, Client* client)
{
if (msg.empty() || client == nullptr)
return _clients.end();
//找到第一個空格
auto first = msg.find(' ');
std::string command;
//獲取命令
if (first != std::string::npos)
command = msg.substr(0, first).c_str();
else
command = msg.c_str();
if (strcasecmp(command.c_str(), "NICK") == 0)
{
if (first == std::string::npos)
{
std::string text = "Invalid Nickname!";
putMsg(client->socket, text.c_str());
}
else
{
auto oldName = client->name;
auto name = msg.substr(first + 1);
std::string text;
if (!this->isUniqueNick(name))
{
text = "Duplicate Nickname!";
putMsg(client->socket, text.c_str());
}
else
{
client->name = name;
text = StringUtils::format("%s->%s", oldName.c_str(), name.c_str());
sendAll(text);
}
}
}
首先,獲取命令的名字,接著忽略大小寫判斷是否是NICK。如果是,則判斷其合法性,即不能為空或者重名,之後把此改名資訊傳送給所有客戶端。
//退出
else if (strcasecmp(command.c_str(), "QUIT") == 0)
{
if (first != std::string::npos)
{
auto text = msg.substr(first + 1);
text = StringUtils::format("%s quits : %s", client->name.c_str(), text.c_str());
sendAll(text);
}
else
{
auto text = StringUtils::format("%s quits", client->name.c_str());
sendAll(text);
}
return this->removeClient(client);
}
客戶端退出,這裡用到了removeClient函式,此函式負責釋放記憶體並返回新的迭代器。
//client =》client
else if (strcasecmp(command.c_str(), "MSG") == 0)
{
if (first == std::string::npos)
{
putMsg(client->socket, "Format:/MSG Nickname message");
}
else
{
auto second = msg.find(' ', first + 1);
std::string name = msg.substr(first + 1, second - first - 1);
auto text = msg.substr(second + 1);
text = StringUtils::format("<%s> %s", name.c_str(), text.c_str());
//傳送到
if (!this->sendTo(name, text))
putMsg(client->socket, "no found the client of name");
}
}
客戶端與客戶端的通訊,此命令比較有用,既可以用於玩家的通訊,也可以用於交易,比如傳遞裝備,則可以傳送一個可識別的文字。
//輸出誰線上
else if (strcasecmp(command.c_str(), "WHO") == 0)
{
IPaddress* ipaddr = nullptr;
Uint32 ip;
std::string text;
for (auto it = _clients.begin(); it != _clients.end(); it++)
{
//除去自己
if (it->name == client->name)
continue;
ipaddr = SDLNet_TCP_GetPeerAddress(it->socket);
if (ipaddr == nullptr)
continue;
ip = SDL_SwapBE32(ipaddr->host);
text = StringUtils::format("%s %u.%u.%u.%u:%u", it->name.c_str(),
ip>>24,
(ip>>16) & 0xff,
(ip>>8) & 0xff,
ip & 0xff,
ipaddr->port);
putMsg(client->socket, text.c_str());
}
}
輸出所有線上的客戶端(除了請求的客戶端)。
else
{
auto text = StringUtils::format("Invalid Command:%s", command.c_str());
putMsg(client->socket, text.c_str());
}
return _clients.end();
如果傳送了未知的命令,則提示客戶端該命令未知,可以把每個功能分成單個的函式進行處理,使得邏輯更為清晰,也便於擴充套件命令。
執行到結尾返回的是_clients.end()。這裡約定,返回end則表示並未刪除_clients中的元素。
void TCPServer::sendAll(const std::string& text)
{
if (text.empty() || _clients.size() == 0)
return ;
for (auto it = _clients.begin(); it != _clients.end();)
{
auto& client = *it;
TCPsocket socket = client.socket;
putMsg(socket, text.c_str());
it++;
}
}
遍歷所有的客戶端,併發送資訊。
bool TCPServer::sendTo(const std::string& name, const std::string& text)
{
//查詢
auto it = find_if(_clients.begin(), _clients.end(), [&name](const Client& client)
{
return name == client.name;
});
if (it == _clients.end())
return false;
putMsg(it->socket, text.c_str());
return true;
}
給指定的客戶端傳送資訊,如果name對應的客戶端未找到,則返回false,否則返回true。
void TCPServer::checkSocketSet()
{
bool ret = false;
if (_set == nullptr)
{
_set = SDLNet_AllocSocketSet(_clients.size() + 1);
ret = true;
}
else if (_setNum != _clients.size() + 1)
{
SDLNet_FreeSocketSet(_set);
_set = SDLNet_AllocSocketSet(_clients.size() + 1);
ret = true;
}
//只有在重新建立時才會填充
if (!ret)
return;
_setNum = _clients.size() + 1;
SDLNet_TCP_AddSocket(_set, _server);
for (auto it = _clients.begin(); it != _clients.end(); it++)
SDLNet_TCP_AddSocket(_set, it->socket);
}
此函式主要負責適配地建立套接字列表,因為要把伺服器和所有客戶端全部放入該列表中,因此申請的大小應該為客戶端的個數+1。
③.SDLNet_AllocSocketSet()
SDLNet_SocketSet SDLNet_AllocSocketSet(int maxsockets)
建立能夠被檢查的能儲存maxsockets的套接字列表。
Client* TCPServer::addClient(TCPsocket socket, const std::string& name)
{
//名稱為空
if (name.empty())
{
char text[] = "Invalid Nickname...bye bye!";
putMsg(socket, text);
SDLNet_TCP_Close(socket);
return nullptr;
}
if (!this->isUniqueNick(name))
{
char text[] = "Duplicate Nickname...bye bye!";
putMsg(socket, text);
SDLNet_TCP_Close(socket);
return nullptr;
}
//新增
_clients.push_back(Client(name, socket));
printf("--> %s\n", name.c_str());
sendAll(StringUtils::format("--->%s", name.c_str()));
return &_clients.back();
}
該函式負責把socket加入到_clients,然後傳送資訊給其餘所有客戶端。
std::vector<Client>::iterator TCPServer::removeClient(std::vector<Client>::iterator it)
{
const std::string& name = it->name;
TCPsocket socket = it->socket;
it = _clients.erase(it);
SDLNet_TCP_Close(socket);
//傳送資料
printf("<-- %s\n", name.c_str());
std::string text = StringUtils::format("<--%s", name.c_str());
sendAll(text.c_str());
return it;
}
std::vector<Client>::iterator TCPServer::removeClient(Client* client)
{
auto it = find_if(_clients.begin(), _clients.end(), [client](const Client& c)
{
return c.name == client->name;
});
return this->removeClient(it);
}
客戶端的移除函式,為了避免發生迭代器失效錯誤,因此返回新的迭代器。
bool TCPServer::isUniqueNick(const std::string& name)
{
auto it = find_if(_clients.begin(), _clients.end(), [&name](const Client& client)
{
return client.name == name;
});
return it == _clients.end();
}
此函式則是遍歷來判斷name是否是唯一的。
TCPServer類目前大致完成,接下來就是主函數了,其名稱為server.cpp。
#include<iostream>
#include "TCPServer.h"
int main(int argc, char** argv)
{
TCPServer* server = new TCPServer();
bool running = true;
server->init(2000);
while (running)
{
server->update(0.016f, 1000);
}
delete server;
}
當前的伺服器會一直執行,注意當前的timeout=1000,即1秒,在本例中不需要檢查過快。如果是在遊戲中的主執行緒中進行檢測的話,則需要把timeout=0,最好不要一直等待,否則會造成主執行緒卡頓,使得遊戲體驗極差。
三.客戶端的編寫
客戶端的編寫相對比較容易,主要是因為其功能相對簡單:
- 判斷伺服器是否發出訊息。
- 判斷客戶端是否發訊息給伺服器。
客戶端目前並未封裝成類。
client.cpp
#include <cstdio>
#include <string>
#include <SDL.h>
#include <SDL_net.h>
#include <termios.h>
#include <unistd.h>
#include <sys/types.h>
#include "tcputil.h"
using namespace std;
/*linux下需要自行配置,Windows下可#include <conio.h>*/
int kbhit (void)
{
struct timeval tv;
fd_set rdfs;
//無等待
memset(&tv, 0, sizeof(tv));
FD_ZERO(&rdfs);
FD_SET(fileno(stdin), &rdfs);
select(fileno(stdin) + 1, &rdfs, NULL, NULL, &tv);
return FD_ISSET(fileno(stdin), &rdfs);
}
本示例在ubuntu下執行,因此添加了一些linux特有的標頭檔案,其主要是檢測是否有文字輸入,在windows下存在kbhit函式,在#include <conio.h>標頭檔案中,可根據編譯器提示刪除對應的不存在的標頭檔案。
int main(int argc, char**argv)
{
IPaddress ip;
TCPsocket socket;
SDLNet_SocketSet set;
bool running = true;
char text[1024];
const char* host = "localhost";
Uint16 port = 2000;
const char* name = "sky";
if (argc > 1)
host = argv[1];
if (argc > 2)
port = (Uint16)atoi(argv[2]);
if (argc > 3)
name = argv[3];
SDL_Init(0);
SDLNet_Init();
if (SDLNet_ResolveHost(&ip, host, port) != 0)
{
printf("SDLNet_ResolveHost: %s\n", SDLNet_GetError());
return 1;
}
socket = SDLNet_TCP_Open(&ip);
set = SDLNet_AllocSocketSet(1);
if (socket == nullptr || set == nullptr)
{
printf("error: %s\n", SDLNet_GetError());
return 1;
}
//返回設定成功的個數 -1為錯誤
if (SDLNet_TCP_AddSocket(set, socket) == -1)
{
printf("SDLNet_AddSocket: %s\n", SDLNet_GetError());
return 1;
}
主函式的前一部分如上一節所示,不過這裡雖然僅僅只有一個客戶端,但還是需要把客戶端放入套接字列表中,以便於可以使用相應的檢測函式。
//先發送名稱
if (putMsg(socket, name) == 0)
{
SDLNet_TCP_Close(socket);
return 1;
}
這部分程式碼是伺服器與客戶端的約定,即客戶端如果想申請加入的話,必須要首先發送一個唯一的名稱。
while (running)
{
int numReady = SDLNet_CheckSockets(set, 100);
char* str = nullptr;
if (numReady == -1)
{
printf("SDLNet_CheckSockets: %s\n", SDLNet_GetError());
break;
}
if (numReady == 1 && SDLNet_SocketReady(socket))
{
if (getMsg(socket, &str) == nullptr)
break;
printf("%s\n", str);
}
//使用者輸入
if (kbhit() != 0)
{
if (!fgets(text, 1024, stdin))
break;
//迴圈刪去換行符等
while (strlen(text) && strchr("\n\r\t", text[strlen(text) - 1]))
text[strlen(text) - 1] = '\0';
if (strlen(text))
putMsg(socket, text);
}
}
SDLNet_TCP_Close(socket);
SDLNet_FreeSocketSet(set);
SDLNet_Quit();
SDL_Quit();
return 0;
}
最後則是一個大迴圈,首先判斷伺服器是否發過來資訊,然後再判斷使用者是否輸入資訊。
本節基本結束。