c++版简易服务器
unity/c++ mmo大型多人在线游戏开发系列教程,本节代码在此。
本节我们来把python版的简易服务器换成c++版本的实现。
CMake
我们的项目想要在各个平台都能编译,因此需要使用CMake,在不同平台上生成不同的构建工程。如果你不熟悉CMake,可以先去简单了解一下。
cmake_minimum_required(VERSION 3.8)
project(ZeroServer)
set(CMAKE_BUILD_TYPE "Debug")
set(CMAKE_CXX_STANDARD 20)
set(THIRDPARTY_DIR ${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/${CMAKE_SYSTEM_NAME})
include_directories(${THIRDPARTY_DIR}/include/libevent)
link_directories(${THIRDPARTY_DIR}/lib)
set(THIRDPARTY_LIBS event_core event_extra)
IF (CMAKE_SYSTEM_NAME MATCHES "Windows")
list(APPEND SYSTEM_LIBS
ws2_32
)
ENDIF()
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/src)
source_group(network ${CMAKE_CURRENT_SOURCE_DIR}/src/network)
file(GLOB GAME_SERVER_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/src/game_server.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/network/*.cpp)
file(GLOB GAME_SERVER_HEADERS ${CMAKE_CURRENT_SOURCE_DIR}/src/network/*.h)
add_executable(game_server ${GAME_SERVER_SRCS} ${GAME_SERVER_HEADERS})
target_link_libraries(game_server ${THIRDPARTY_LIBS} ${SYSTEM_LIBS})
IF (MSVC)
set(MY_PATH "PATH=%PATH%" ${THIRDPARTY_DIR}/lib)
set_target_properties(game_server PROPERTIES VS_DEBUGGER_ENVIRONMENT "${MY_PATH}")
ENDIF()
libevent
注意到,python版本的实现只能处理一个客户端,这里我们再正式一点,需要能同时处理多个客户端。
默认情况下,对网络的读写会阻塞进程,因此,想要同时跟多个客户端进行通信,可选的方式有:多线程、非阻塞模式编程、IO多路复用。
各有优缺点,这里选择使用IO多路复用来解决问题。
本教程并不会对网络编程作深入探讨,多人游戏网络编程与普通的网络编程,更主要的差别还是在于游戏这两字上。
我并不想自己去从头到尾写一个网络层,无论是网络底层的各种细节,还是跨平台的兼容处理,都不有趣,因此这里选择了libevent来减轻一下生活压力。
使用libevent我们实现了TcpServer及TcpClient,TcpServer启动时监听指定端口,为每条新连接建立一个TcpClient。
消息处理
到目前为止,基础的联网已经打通了,但echo服务只是一个最简单的测试,我们是时候为以后真正的消息处理流程做准备了。
对于客户端,之前我们对消息的处理只是打印了一下字符串,放在网络线程自然没什么问题。但真正真正的消息可能是让客户端移动角色之类,在网络线程处理会跟主线程产生冲突。因此,我们需要把消息同步回主线程,让它在合适的时机处理就好:
void Update()
{
HandleNetworkMsg();
}
private void HandleNetworkMsg()
{
while (true)
{
byte[] bytes = null;
// _msgQueue是一个ConcurrentQueue
if (_msgQueue.TryDequeue(out bytes!))
{
string msg = Encoding.UTF8.GetString(bytes);
Debug.Log("Recv: " + msg);
}
else
{
break;
}
}
}
private void RecvThreadFunc()
{
byte[] buffer = new byte[2048];
while (_isConnected)
{
try
{
int nReads = _stream.Read(buffer, 0, buffer.Length);
if (nReads == 0)
break;
byte[][] msgs = _recvBuffer.Recv(buffer, nReads);
foreach (byte[] bytes in msgs)
{
// 把消息放到队列中,主线程在update时再进行处理
_msgQueue.Enqueue(bytes);
}
}
catch (Exception e)
{
Debug.LogError(e.ToString());
break;
}
}
if (_isConnected)
OnLostServer();
}
而对于服务端,之前我们只是把消息原路返回,连消息解析都没做,因此我们要先把客户端的那个RecvBuffer
搬过来:
void TcpConnection::handle_data(const char* buffer, size_t n)
{
std::vector<std::string> msgs = _recvBuffer.recv(buffer, n);
for (std::string& msg : msgs) {
std::cout << "recv: " << msg << std::endl;
send_msg(msg.c_str(), msg.size());
}
}
数据压缩
另一个问题是,现在我们给每条消息都添加了2个字节的固定长度,2个字节的表示范围是[0, 65535],对于短字符串来说,其实1个字节就够了,但对超长字符串来说,2个字节也不够。所以这个固定长度有可能造成浪费,也可能限制了消息的表达。
我们可以对这个消息的长度也规定解释的方法,让其尽量紧凑,BinaryWriter.Write7BitEncodedInt
就是一个很好的方案,我们照着搬一下就好:
public void Send(string msg)
{
Debug.Log("Send: " + msg);
using (MemoryStream mem = new MemoryStream())
{
using (BinaryWriter binaryWriter = new BinaryWriter(mem))
{
byte[] bytes = Encoding.UTF8.GetBytes(msg);
// 现在以Write7BitEncodedInt的方式去写消息长度
Write7BitEncodedInt(binaryWriter, bytes.Length);
binaryWriter.Write(bytes);
}
byte[] data = mem.ToArray();
_sendBuffer.Send(data);
}
}
相对应的,RecvBuffer
解析消息长度的逻辑也要改一下:
public byte[][] Recv(byte[] bytes, int n)
{
int bindex = 0;
List<byte[]> msgs = new List<byte[]>();
while (_needBytes > 0 && bindex < n)
{
switch (_parseStage)
{
case ParseStage.TLEN:
int index = TLEN_SIZE - _needBytes;
_tlenBytes[_tlenBytesPosition++] = bytes[bindex];
// 在未能完全解析消息长度前,_needBytes一直为1
if ((bytes[bindex] & 0x80) == 0)
_needBytes = 0;
bindex += 1;
if (_needBytes == 0)
{
_parseStage = ParseStage.DATA;
_needBytes = CalcPackageDataLength();
_tlenBytesPosition = 0;
// 现在buffer不再是固定大小,所以每次重新分配
buffer = new byte[_needBytes];
}
break;
case ParseStage.DATA:
int leftBytesNum = n - bindex;
if (leftBytesNum < _needBytes)
{
Buffer.BlockCopy(bytes, bindex, buffer, position, leftBytesNum);
_needBytes -= leftBytesNum;
bindex += leftBytesNum;
position += leftBytesNum;
}
else
{
Buffer.BlockCopy(bytes, bindex, buffer, position, _needBytes);
bindex += _needBytes;
position = 0;
// finish one msg
byte[] msg = new byte[_needBytes];
Buffer.BlockCopy(buffer, 0, msg, 0, _needBytes);
msgs.Add(msg);
// reset to initial state
_parseStage = ParseStage.TLEN;
_needBytes = 1;
}
break;
}
}
return msgs.ToArray();
}
int CalcPackageDataLength()
{
return Read7BitEncodedInt(_tlenBytes);
}
小结
至此,我们能联网,能通信,服务端能处理并发。但是,现在还只是普通的tcp服务器,并不是游戏服务器。下一章,我们让这个tcp服务器提供场景服务。