上一节初步了解到了服务器和客户端的通信,并且由于受到代码的限制,只能是单个客户端,而且服务器无法向客户端发送信息,本节使用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;
}
最后则是一个大循环,首先判断服务器是否发过来信息,然后再判断用户是否输入信息。
本节基本结束。