上一節初步瞭解到了服務器和客戶端的通信,並且由於受到代碼的限制,只能是單個客戶端,而且服務器無法向客戶端發送信息,本節使用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;
}
最後則是一個大循環,首先判斷服務器是否發過來信息,然後再判斷用戶是否輸入信息。
本節基本結束。