KoreanFoodie's Study

[C++ 게임 서버] 5-9. 패킷 자동화 #2 본문

Game Dev/Game Server

[C++ 게임 서버] 5-9. 패킷 자동화 #2

GoldGiver 2023. 12. 18. 23:26

Rookiss 님의 '[C++과 언리얼로 만드는 MMORPG 게임 개발 시리즈] Part4: 게임 서버' 를 들으며 배운 내용을 정리하고 있습니다. 관심이 있으신 분은 꼭 한 번 들어보시기를 추천합니다!

[C++ 게임 서버] 5-9. 패킷 자동화 #2

핵심 :

1. 파이썬과 jinja2 를 이용해, 패킷 핸들러 코드를 자동으로 생성해 보자.

2. 이 포스팅에서 만든 ProtoParser 는 Protocol.proto 의 패킷 명세를 읽어 PacketHandler 의 코드를 자동으로 생성한다. 

저번 시간에 패킷 자동화를 위한 밑작업(?) 을 보여준 바 있다.

사실 제대로 패킷 자동화를 구축하기 위해서는, data-driven 하게 작업이 이루어져야 할 것이다.

간단한 Flow 를 생각하면 다음과 같다 :

  1. Protocol.proto 에 우리가 원하는 패킷(메시지)을 선언한다.
  2. 자동화 툴이 돌아가서 Packet 과 PacketHandler 클래스를 생성한다.
  3. 프로그래머는 각 패킷에 대한 커스텀 동작만 직접 구현한다.

 이전에 만든 Protocol.proto 의 경우 다음과 같이 생겼는데...

syntax = "proto3";
package Protocol;

import "Enum.proto";
import "Struct.proto";

message C_TEST
{
	uint64 id = 1;
}

message C_MOVE
{
	uint64 id = 1;
	uint32 pos = 2;
}

message S_TEST
{
	uint64 id = 1;
	uint32 hp = 2;
	uint32 attack = 3;
	repeated BuffData buffs = 4;

	//enum PacketId { NONE = 0; PACKET_ID = 1; }
}

message S_LOGIN
{
	//enum PacketId { NONE = 0; PACKET_ID = 1;}
}

우리는 이 proto 파일을 읽어, 적절한 패킷을 형식에 맞게 만들고, 자동으로 일부 코드도 생성하도록 만들 것이다.

음... 그런데 어떻게 하면 좋을까? 🤔

 

사실 툴을 만들 때 가장 많이 사용하는 언어가 바로 Python 인데, 여기서도 python 프로젝트를 이용해 툴 프로젝트를 하나 추가해 줄 것이다!

대충 우리의 솔루션 아래에, Tools 라는 폴더를 만들고 파이썬 프로젝트를 추가해 주자.

위처럼, PacketGenerator 와 ProtoParser 두 녀석을 추가한다. ProtoParser 는 .proto 파일을 읽어서 파싱을 해 주는 녀석으로, 파싱한 결과는 PacketGenerator 에 전달되어 PacketHandler.h 라는 샘플 코드와 같은 느낌으로 결과물이 만들어질 것이다. 거두절미하고, 바로 코드를 보자.

 

ProtoParser.py

class ProtoParser():
	def __init__(self, start_id, recv_prefix, send_prefix):
		self.recv_pkt = []	# 수신 패킷 목록
		self.send_pkt = [] # 송신 패킷 목록
		self.total_pkt = [] # 모든 패킷 목록
		self.start_id = start_id
		self.id = start_id
		self.recv_prefix = recv_prefix
		self.send_prefix = send_prefix

	def parse_proto(self, path):
		f = open(path, 'r')
		lines = f.readlines()

		for line in lines:
			if line.startswith('message') == False:
				continue

			pkt_name = line.split()[1].upper()
			if pkt_name.startswith(self.recv_prefix):
				self.recv_pkt.append(Packet(pkt_name, self.id))
			elif pkt_name.startswith(self.send_prefix):
				self.send_pkt.append(Packet(pkt_name, self.id))
			else:
				continue

			self.total_pkt.append(Packet(pkt_name, self.id))
			self.id += 1

		f.close()

class Packet:
	def __init__(self, name, id):
		self.name = name
		self.id = id

파이썬 코드는 좀 오랜만인데... 일단 parse_proto 쪽을 보면, 'message' 로 시작하는 줄을 읽어서 pkt_name 과 id(계속 1 씩 증가함) 를 이용해 패킷 목록을 각각 recv_pkt/send_pkt/total_pkt 에 담아 주는 것을 알 수 있다.

 

PacketGenerator.py

import argparse
import jinja2
import ProtoParser

def main():

	arg_parser = argparse.ArgumentParser(description = 'PacketGenerator')
	arg_parser.add_argument('--path', type=str, default='C:/Rookiss/CPP_Server/Server/Common/Protobuf/bin/Protocol.proto', help='proto path')
	arg_parser.add_argument('--output', type=str, default='TestPacketHandler', help='output file')
	arg_parser.add_argument('--recv', type=str, default='C_', help='recv convention')
	arg_parser.add_argument('--send', type=str, default='S_', help='send convention')
	args = arg_parser.parse_args()

	parser = ProtoParser.ProtoParser(1000, args.recv, args.send)
	parser.parse_proto(args.path)
	file_loader = jinja2.FileSystemLoader('Templates')
	env = jinja2.Environment(loader=file_loader)

	template = env.get_template('PacketHandler.h')
	output = template.render(parser=parser, output=args.output)

	f = open(args.output+'.h', 'w+')
	f.write(output)
	f.close()

	print(output)
	return

if __name__ == '__main__':
	main()

그리고 PacketGenerator 는, 파싱한 결과를 읽어서 jinja2 라이브러리를 이용해 렌더링을 해 주는데... 이때 PacketHandler.h 를 템플릿으로 사용하고 있다.

이제 대망의 PacketHandler.h 를 보자.

 

PacketHandler.h

#pragma once
#include "Protocol.pb.h"

using PacketHandlerFunc = std::function<bool(PacketSessionRef&, BYTE*, int32)>;
extern PacketHandlerFunc GPacketHandler[UINT16_MAX];

enum : uint16
{
{%- for pkt in parser.total_pkt %}
	PKT_{{pkt.name}} = {{pkt.id}},
{%- endfor %}
};

// Custom Handlers
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);

{%- for pkt in parser.recv_pkt %}
bool Handle_{{pkt.name}}(PacketSessionRef& session, Protocol::{{pkt.name}}& pkt);
{%- endfor %}

class {{output}}
{
public:
	static void Init()
	{
		for (int32 i = 0; i < UINT16_MAX; i++)
			GPacketHandler[i] = Handle_INVALID;

{%- for pkt in parser.recv_pkt %}
		GPacketHandler[PKT_{{pkt.name}}] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::{{pkt.name}}>(Handle_{{pkt.name}}, session, buffer, len); };
{%- endfor %}
	}

	static bool HandlePacket(PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
		return GPacketHandler[header->id](session, buffer, len);
	}

{%- for pkt in parser.send_pkt %}
	static SendBufferRef MakeSendBuffer(Protocol::{{pkt.name}}& pkt) { return MakeSendBuffer(pkt, PKT_{{pkt.name}}); }
{%- endfor %}

private:
	template<typename PacketType, typename ProcessFunc>
	static bool HandlePacket(ProcessFunc func, PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketType pkt;
		if (pkt.ParseFromArray(buffer + sizeof(PacketHeader), len - sizeof(PacketHeader)) == false)
			return false;

		return func(session, pkt);
	}

	template<typename T>
	static SendBufferRef MakeSendBuffer(T& pkt, uint16 pktId)
	{
		const uint16 dataSize = static_cast<uint16>(pkt.ByteSizeLong());
		const uint16 packetSize = dataSize + sizeof(PacketHeader);

		SendBufferRef sendBuffer = GSendBufferManager->Open(packetSize);
		PacketHeader* header = reinterpret_cast<PacketHeader*>(sendBuffer->Buffer());
		header->size = packetSize;
		header->id = pktId;
		ASSERT_CRASH(pkt.SerializeToArray(&header[1], dataSize));
		sendBuffer->Close(packetSize);

		return sendBuffer;
	}
};

음... 뭔가 엄청 화려해 보이는데, 사실 이 부분만 잘 보면 된다 :

enum : uint16
{
{%- for pkt in parser.total_pkt %}
	PKT_{{pkt.name}} = {{pkt.id}},
{%- endfor %}
};

결국 위 코드 스니펫을 보면... parser 라는, 우리가 아까 파싱한 녀석으로부터 인자를 꺼내와서 루프를 돌면서 코드를 작성하는 것이기 때문이다! {{ }} 로 감싸서 변수를 조회하고, {% %} 로 루프를 표현하는 것을 확인할 수 있다.

그리고 {%- ... 에서 '-' 는 줄바꿈 관련 옵션인데... 자세한 건 직접 찾아보도록 하자! 🤣

어쨌든, 위와 같이 사용하면 파이썬 코드를 이용해(jinja2 라이브러리) cpp 코드를 자동으로 생성해 줄 수 있다! 😁

참, GenPackets.bat 파일의 경우는 아래와 같이 조금 수정해 주도록 하자 :

pushd %~dp0
protoc.exe -I=./ --cpp_out=./ ./Enum.proto
protoc.exe -I=./ --cpp_out=./ ./Struct.proto
protoc.exe -I=./ --cpp_out=./ ./Protocol.proto

GenPackets.exe --path=./Protocol.proto --output=ClientPacketHandler --recv=C_ --send=S_
GenPackets.exe --path=./Protocol.proto --output=ServerPacketHandler --recv=S_ --send=C_

IF ERRORLEVEL 1 PAUSE

XCOPY /Y Enum.pb.h "../../../GameServer"
XCOPY /Y Enum.pb.cc "../../../GameServer"
XCOPY /Y Struct.pb.h "../../../GameServer"
XCOPY /Y Struct.pb.cc "../../../GameServer"
XCOPY /Y Protocol.pb.h "../../../GameServer"
XCOPY /Y Protocol.pb.cc "../../../GameServer"
XCOPY /Y ClientPacketHandler.h "../../../GameServer"

XCOPY /Y Enum.pb.h "../../../DummyClient"
XCOPY /Y Enum.pb.cc "../../../DummyClient"
XCOPY /Y Struct.pb.h "../../../DummyClient"
XCOPY /Y Struct.pb.cc "../../../DummyClient"
XCOPY /Y Protocol.pb.h "../../../DummyClient"
XCOPY /Y Protocol.pb.cc "../../../DummyClient"
XCOPY /Y ServerPacketHandler.h "../../../DummyClient"

DEL /Q /F *.pb.h
DEL /Q /F *.pb.cc
DEL /Q /F *.h

PAUSE

 

이제 자동화 스크립트가 만들어주는 Handler 부분을 보자. 참고로, 이번 글부터 ServerPacketHandler 와 ClientPacketHandler 의 이름을 뒤바꾸어 주었다. 왜냐면... 이름 그대로, ClientPacketHandler 는 GameServer 에서, ClientPacket 받았을 때 어떻게 행동하면 좋은지 정의하면 이름의 정의에 좀 더 부합하기 때문이다!

어쨌든, 대표적인 예시로 ClientPacketHandler 를 보자 :

ClientPacketHandler.h

#pragma once
#include "Protocol.pb.h"

using PacketHandlerFunc = std::function<bool(PacketSessionRef&, BYTE*, int32)>;
extern PacketHandlerFunc GPacketHandler[UINT16_MAX];

enum : uint16
{
	PKT_C_TEST = 1000,
	PKT_C_MOVE = 1001,
	PKT_S_TEST = 1002,
	PKT_S_LOGIN = 1003,
};

// Custom Handlers
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);
bool Handle_C_TEST(PacketSessionRef& session, Protocol::C_TEST& pkt);
bool Handle_C_MOVE(PacketSessionRef& session, Protocol::C_MOVE& pkt);

class ClientPacketHandler
{
public:
	static void Init()
	{
		for (int32 i = 0; i < UINT16_MAX; i++)
			GPacketHandler[i] = Handle_INVALID;
		GPacketHandler[PKT_C_TEST] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::C_TEST>(Handle_C_TEST, session, buffer, len); };
		GPacketHandler[PKT_C_MOVE] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::C_MOVE>(Handle_C_MOVE, session, buffer, len); };
	}

	static bool HandlePacket(PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
		return GPacketHandler[header->id](session, buffer, len);
	}
	static SendBufferRef MakeSendBuffer(Protocol::S_TEST& pkt) { return MakeSendBuffer(pkt, PKT_S_TEST); }
	static SendBufferRef MakeSendBuffer(Protocol::S_LOGIN& pkt) { return MakeSendBuffer(pkt, PKT_S_LOGIN); }

private:
	template<typename PacketType, typename ProcessFunc>
	static bool HandlePacket(ProcessFunc func, PacketSessionRef& session, BYTE* buffer, int32 len)
	{
		PacketType pkt;
		if (pkt.ParseFromArray(buffer + sizeof(PacketHeader), len - sizeof(PacketHeader)) == false)
			return false;

		return func(session, pkt);
	}

	template<typename T>
	static SendBufferRef MakeSendBuffer(T& pkt, uint16 pktId)
	{
		const uint16 dataSize = static_cast<uint16>(pkt.ByteSizeLong());
		const uint16 packetSize = dataSize + sizeof(PacketHeader);

		SendBufferRef sendBuffer = GSendBufferManager->Open(packetSize);
		PacketHeader* header = reinterpret_cast<PacketHeader*>(sendBuffer->Buffer());
		header->size = packetSize;
		header->id = pktId;
		ASSERT_CRASH(pkt.SerializeToArray(&header[1], dataSize));
		sendBuffer->Close(packetSize);

		return sendBuffer;
	}
};

위 코드가 자동 생성 되었다는 것이 새삼 놀라울 뿐이다.. 😎

자동화 스크립트가 구축되었으면, 프로그래머는 이제 각 패킷에 대한 로직(예를 들어 Handle_C_MOVE 함수)만 작성해 주면 된다!

#include "ClientPacketHandler.h"

PacketHandlerFunc GPacketHandler[UINT16_MAX];

// 직접 컨텐츠 작업자

bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len)
{
	PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
	// TODO : Log
	return false;
}

bool Handle_C_TEST(PacketSessionRef& session, Protocol::C_TEST& pkt)
{
	// TODO

	return true;
}

bool Handle_C_MOVE(PacketSessionRef& session, Protocol::C_MOVE& pkt)
{
	// TODO

	return true;
}

이것으로, 대략적인 패킷 자동화에 대한 설명을 마치도록 한다.

사실 Protobuf 와 패킷 자동화 부분에 대한 설명은 세팅에 대한 부분을 포스팅에 그대로 적진 않았다. 좀 복잡하기도 하고, 실제로 한 번 따라해 보면 되지, 이걸 암기할 필요는 없기 때문이다. 😂

그러니 관심이 생긴다면, Rookiss 님 강의를 직접 들어보면 어떨까 싶다! 😊

Comments