1. 程式人生 > >2.SDL2_net TCP伺服器端和多個客戶端

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的流程大致如下:

  1. 檢測套接字列表中“積極”的套接字個數numReady。“積極”表示存在資料互動/者要建立連線(僅伺服器套接字)。如果沒有或者超時則返回0。
  2. 如果numReady > 0,則檢測積極的是否是伺服器,即有新的連線,如果是,則嘗試建立連線,並使得numReady--。
  3. 如果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,最好不要一直等待,否則會造成主執行緒卡頓,使得遊戲體驗極差。

三.客戶端的編寫

客戶端的編寫相對比較容易,主要是因為其功能相對簡單:

  1. 判斷伺服器是否發出訊息。
  2. 判斷客戶端是否發訊息給伺服器。

客戶端目前並未封裝成類。

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;
}

最後則是一個大迴圈,首先判斷伺服器是否發過來資訊,然後再判斷使用者是否輸入資訊。

本節基本結束。

程式碼:https://github.com/sky94520/SDL_Net/tree/master/