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上解释大致如下:套接字列表的相关函数主要用于处理多个套接字,当一个套接字存在数据交互或者想要建立连接时才会“通知”你去处理,类似于事件轮训。

扫描二维码关注公众号,回复: 4230911 查看本文章

注:这里翻译用到了“通知”,其实还是需要在代码中进行检测,而不是函数回调。

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/

猜你喜欢

转载自blog.csdn.net/bull521/article/details/84452455
今日推荐