Protocol Buffers(Protobuf) 는 실제 라이브 서비스에서도 많이 쓰이는 직렬화 메커니즘이다.
프로토 버프를 프로젝트에 올려가면서 직렬화에 대해 공부했던 내용과 비교하며 익혀보자.
1. 다운로드
protobuf는 공식 Github Repo에서 받을 수 있다.
릴리즈 페이지로 가서 아래의 파일을 다운해야 한다.
지금은 최신 버전이 아니라 구버전을 사용할 것이다.
저 파일을 받아주자. 뒤의 c는 컴파일러라는 의미이다.
구조를 던져주면 그걸 컴파일하여 자동으로 코드를 생성해 준다고 생각해도 될 것이다.
저 파일을 다운 받아서 적당한 곳에 압축을 풀어주자.
압축을 풀고 나서 bin 폴더 안에 보면 exe 파일 하나만 덩그러니 있을 것이다.
그 파일이 소스코드를 자동으로 만들어 줄 것이다.
2. 코드 자동 생성을 위한 준비
우리는 2개의 파일을 새로 만들 것이다.
- protocol.proto
- 패킷의 구조가 기록될 파일 - PacketGen.bat
- proto 파일을 넘겨줘 컴파일러를 실행시키기 위한 배치 파일
2-1. protocol.proto
이전에 만들었던 PDL 파일을 열어보자.
<?xml version="1.0" encoding="utf-8"?>
<PDL>
<Packet name="S_TEST" desc="테스트 용도">
<Field name="id" type="uint64" desc=""/>
<Field name="hp" type="uint32" desc=""/>
<Field name="attack" type="uint16" desc=""/>
<List name="buffs" desc="">
<Field name="buffId" type="uint64" desc=""/>
<Field name="remainTime" type="float" desc=""/>
<List name="victims">
<Field name="userId" type="uint64"/>
</List>
</List>
</Packet>
</PDL>
이 구조를 그대로 프로토버프가 알아들을 수 있는 형식으로 작성할 것이다.
아래가 완성된 양식이다.
syntax = "proto3";
package Protocol;
message BuffData
{
uint64 buffId = 1;
float remainTime = 2;
repeated uint64 victims = 3;
}
message S_TEST
{
uint64 id = 1;
uint32 hp = 2;
uint16 attack = 3;
repeated BuffData buffs = 4; // 가변 데이터는 repeated 키워드를 붙임
}
구조체를 만드는 것과 매우 유사하다.
가변 길이 데이터의 앞에는 「reapeated」 키워드를 붙여주면 된다.
victims의 앞에도 해당 키워드를 붙여줬다.
2-2. PacketGen.bat
컴파일러를 실행시킬 배치 파일을 만들어 보자.
공식 문서의 Generating Your Classes 부분을 참고하자.
protoc.exe -I=./ --cpp_out=./ ./Protocol.proto
IF ERRORLEVEL 1 PAUSE
일단 지금 당장은 C++에 대한 것만 필요하기에 위와 같이 작성해 준다.
2-3. 실행해 보면?
이런 문제가 생길 것이다.
Protocol.proto:16:9: "uint16" is not defined.
uint16을 찾을 수 없다는 소리다.
아무래도 프로토버프는 uint16을 지원하지 않는 것 같다.
그럼 뭘 써야 할까?
공식 문서의 Scalar Value Types를 보며 답을 찾아보자.
uint32 말곤 선택지가 없는 것 같다.
그럼 결국 그 반절의 공간을 낭비하는 게 아닌가?
라고 생각할 수 있지만 그렇지 않다.
데이터의 크기에 따라 낭비되는 공간을 최소화할 수 있게 유동적으로 움직인다.
고정적인 자료형 크기가 필요하다면 「sfixed~」 자료형을 사용하면 된다.
message S_TEST
{
uint64 id = 1;
uint32 hp = 2;
uint32 attack = 3; // uint16이 지원되지 않아서 uint32로 대체
repeated BuffData buffs = 4; // 가변 데이터는 repeated 키워드를 붙임
}
위와 같이 uint32로 수정해 생성하면 정상적으로 생성된다.
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: Protocol.proto
#include "Protocol.pb.h"
#include <algorithm>
#include "google/protobuf/io/coded_stream.h"
#include "google/protobuf/extension_set.h"
#include "google/protobuf/wire_format_lite.h"
#include "google/protobuf/descriptor.h"
#include "google/protobuf/generated_message_reflection.h"
#include "google/protobuf/reflection_ops.h"
#include "google/protobuf/wire_format.h"
// @@protoc_insertion_point(includes)
// Must be included last.
#include "google/protobuf/port_def.inc"
PROTOBUF_PRAGMA_INIT_SEG
namespace _pb = ::PROTOBUF_NAMESPACE_ID;
namespace _pbi = ::PROTOBUF_NAMESPACE_ID::internal;
namespace Protocol {
template <typename>
PROTOBUF_CONSTEXPR BuffData::BuffData(
::_pbi::ConstantInitialized): _impl_{
/*decltype(_impl_.victims_)*/ {}
,/* _impl_._victims_cached_byte_size_ = */ { 0 }
, /*decltype(_impl_.buffid_)*/ ::uint64_t{0u}
, /*decltype(_impl_.remaintime_)*/ 0
, /*decltype(_impl_._cached_size_)*/{}} {}
struct BuffDataDefaultTypeInternal {
PROTOBUF_CONSTEXPR BuffDataDefaultTypeInternal() : _instance(::_pbi::ConstantInitialized{}) {}
~BuffDataDefaultTypeInternal() {}
union {
BuffData _instance;
};
};
// ...
이런 식으로 .cc 파일과 헤더 파일이 생성된다.
생성된 파일을 프로젝트에서 사용할 수 있게 복사해 오자.
여기선 서버와 클라이언트 프로젝트에 「Protocol」이라는 필터를 만들어 거기에 둔다.
3. 프로젝트 세팅
프로토버프는 소스파일만 넣었다고 동작하지 않는다.
사용자가 직접 라이브러리를 빌드하고 프로젝트에 연결해 줘야만 사용할 수 있다.
연결하기 전에 먼저 폴더를 정리할 것이다.
지금까진 서버와 클라이언트를 구동하는데 필요한 라이브러리는 코어 라이브러리뿐이었지만,
이젠 프로토버프의 라이브러리도 사용하게 된다.
기존에 있던 「Libraries」 폴더 안의 내용물을 이제 「Include」와「Libs」로 나눌 것이다.
다시 「Libs」 안에서 「Protobuf」와 「ServerCore」로 나누고 그 폴더에 비로소 .lib 파일을 넣을 것이다.
이러면 라이브러리들을 보다 직관적으로 관리할 수 있을 것이다.
그리고 프로젝트 설정에서 변경된 경로를 잡아주자.
아래와 같이 수정하면 된다.
경로가 올바른지 궁금하다면 입력칸 아래의 「평가 값:」을 보고 확인할 수 있다.
pch도 변경된 경로에 맞게 수정해 주어야 한다.
#ifdef _DEBUG
#pragma comment(lib, "ServerCore\\Debug\\ServerCore.lib")
#else
#pragma comment(lib, "ServerCore\\Release\\ServerCore.lib")
#endif
앞에 「ServerCore\\」만 추가해 주면 된다.
4. 라이브러리 빌드
아까 다운받았던 컴파일러의 버전은 23.4였다.
소스코드도 이 버전에 맞는 것을 받는 게 좋을 것이다.
23.X 브랜치를 찾아 소스코드를 다운로드 받고 적당한 데 저장하자.
프로토버프는 솔루션 파일을 제공하는 것이 아닌 CMake를 사용해야 한다.
CMake가 설치되어 있지 않다면 설치하고 실행하자.
실행하면 아래와 같이 경로를 잡아준다.
「CMakeLists.txt」라는 파일이 있는 곳을 경로로 잡아주어야 한다.
「Configure」를 눌러 2022로 세팅해 준다. 작업을 2022로 하고 있기 때문.
그렇게 작업을 시작하면 에러가 날 텐데 당황하지 말자.
이제 우리에게 필요한 설정을 만진 후 다시 Generate 할 것이다.
「PROTOC_BINARIES」는 아까 다운 받았던 컴파일러를 나타낸다. 이렇게 소스코드를 받아서 직접 빌드할 수도 있다.
정말 우리에게 필요한 것은 「SHARED_LIBS」다. 이 친구가 라이브러리 파일을 만들어 줄 것이다.
솔루션을 디버그/릴리즈 양쪽 다 빌드한다.
빌드해서 생성된 .dll 및 .lib 파일을 아까 생성한 Libs 폴더 안에 디버그/릴리즈를 구분하여 넣어준다.
그리고 이제 Include에 넣을 소스코드가 필요하다.
아까 다운받았던 소스코드 폴더로 돌아가면 「src」 폴더 안에 「google」이라는 폴더가 보일 것이다.
이 폴더를 통으로 복사해 Include 폴더 안에 붙여 넣기 하면 끝.
그럼 다시 pch로 돌아가서 추가된 내용에 대해 알려줘야 한다.
#ifdef _DEBUG
#pragma comment(lib, "ServerCore\\Debug\\ServerCore.lib")
#pragma comment(lib, "Protobuf\\Debug\\libprotobufd.lib")
#else
#pragma comment(lib, "ServerCore\\Release\\ServerCore.lib")
#pragma comment(lib, "Protobuf\\Release\\libprotobuf.lib")
#endif
추가된 프로토버프 라이브러리 파일의 경로를 써주면 끝.
5. Protobuf 사용
프로토버프는 예전에 만들었던 임시 객체를 생성에 거기 복사하는 방법과 매우 유사하다.
패킷 객체를 생성하고 어떤 함수가 있는지 훑어보자.
이름으로 대충 역할들이 예상이 갈 것이다.
Protocol::S_TEST pkt;
pkt.set_id(1000);
pkt.set_hp(100);
pkt.set_attack(10);
대강 이런 식으로 사용할 수 있다. 확실히 그리 낯설지는 않다.
Protocol::BuffData* data = pkt.add_buffs();
data->set_buffid(100);
data->set_remaintime(1.2f);
data->add_victims(4000);
Protocol::BuffData* data = pkt.add_buffs();
data->set_buffid(200);
data->set_remaintime(2.5f);
data->add_victims(1000);
data->add_victims(2000);
나머지 가변길이 데이터들도 이런 식으로 넣을 수 있다.
add_buffs()라는 함수를 보니 아무런 인자도 받지 않는다.
구현이 어떻게 돼있는지 살펴보자.
inline ::Protocol::BuffData* S_TEST::_internal_add_buffs() {
return buffs_.Add();
}
inline ::Protocol::BuffData* S_TEST::add_buffs() {
// @@protoc_insertion_point(field_add:Protocol.S_TEST.buffs)
return _internal_add_buffs();
}
BuffData의 포인터를 리턴해 주는 것을 보니, 이전에 만들었던 것처럼 메모리를 예약해 리턴해 주는 것 같다.
패킷 핸들러로 들어와서, 기존에 있던 함수들을 다 날리고 새로운 함수를 만들었다.
// ServerPacketHandler.cpp
SendBufferRef ServerPacketHandler::MakeSendBuffer(Protocol::S_TEST& pkt)
{
return _MakeSendBuffer(pkt, S_TEST);
}
// ============
// ServerPacketHandler.h
#include "Protocol.pb.h"
class ServerPacketHandler
{
public:
static void HandlePacket(BYTE* buffer, int32 len);
static SendBufferRef MakeSendBuffer(Protocol::S_TEST& pkt);
};
template<typename T>
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;
}
함수 이름 앞에 언더바를 붙인 것은 이 함수가 내부에서만 사용된다는 것을 의미한다.
먼저 패킷의 사이즈를 얻어오기 위해서 ByteSizeLong()을 사용한다.
이걸 uint16으로 캐스팅해 넘겨줬다.
패킷의 전체 사이즈는 dataSize에 헤더 사이즈만큼을 더해줘서 구해준다.
그리고 이제 더 이상 Open 할 때 여유롭게 큰 사이즈를 넣어주지 않아도 된다.
이제 사이즈를 완벽하게 알 수 있는 상태기 때문에 딱 그 공간만큼만 쓸 수 있게 됐다.
데이터들을 직렬화하는데 SerializeToArray()를 사용할 것이다.
이름에서 느껴지는 것처럼 배열처럼 한 줄로 쫙 직렬화해주는 함수이다.
헤더는 이미 직렬화 돼 있다고 간주해도 되기 때문에, 진짜 데이터들을 직렬화하는 과정이다.
여기서 위치를 &header[1]로 잡아준 이유는 딱 header의 사이즈만큼만 건너뛰면 바로 데이터가 있기 때문이다.
헤더의 크기는 4바이트고 헤더가 끝나는 위치이자 데이터가 시작되는 위치도 4바이트만큼 이동한 위치이다.
따라서 헤더의 크기만큼만 이동하면 되기 때문에 &header[1]을 넘겨준 것이다.
할 일을 다 마쳤으니 다시 닫아주고 버퍼를 리턴한다.
서버에서 패킷을 만드는 최종적인 코드의 모습은 아래와 같다.
while (true)
{
Protocol::S_TEST pkt;
pkt.set_id(1000);
pkt.set_hp(100);
pkt.set_attack(10);
{
Protocol::BuffData* data = pkt.add_buffs();
data->set_buffid(100);
data->set_remaintime(1.2f);
data->add_victims(4000);
}
{
Protocol::BuffData* data = pkt.add_buffs();
data->set_buffid(200);
data->set_remaintime(2.5f);
data->add_victims(1000);
data->add_victims(2000);
}
SendBufferRef sendBuffer = ServerPacketHandler::MakeSendBuffer(pkt);
GSessionManager.Broadcast(sendBuffer);
this_thread::sleep_for(250ms);
}
데이터를 넣어주고 MakeSendBuffer()를 호출했을 뿐인데, 직렬화까지 다 되어 Broadcast 된다.
이 패킷을 받아줄 클라이언트도 프로토버프를 사용하게끔 수정해야 한다.
void ClientPacketHandler::Handle_S_TEST(BYTE* buffer, int32 len)
{
Protocol::S_TEST pkt;
ASSERT_CRASH(pkt.ParseFromArray(buffer + sizeof(PacketHeader), len - sizeof(PacketHeader)));
cout << pkt.id() << " " << pkt.hp() << " " << pkt.attack() << endl;
cout << "BUFSIZE : " << pkt.buffs_size() << endl;
for (auto& buf : pkt.buffs())
{
cout << "BUFINFO : " << buf.buffid() << " " << buf.remaintime() << endl;
cout << "VICTIMS : " << buf.victims_size() << endl;
for (auto& vic : buf.victims())
{
cout << vic << " ";
}
cout << endl;
}
}
SerializeToArray()가 있다면 Array로부터 Parse 하는 함수도 있어야 할 것이고, 그게 ParseFromArray()다.
HandlePacket()에서 이미 헤더를 읽어 들였다는 사실에 유의하자.
버퍼에서 데이터를 가져오는 방법도, 이름이 매우 직관적이기 때문에 금방 알 수 있다.
데이터 순회도 이터레이터를 통해 간편히 할 수 있다. 정말 편하다 싶다.
이제 실제로 실행해 볼 차례다.
6. 동작 테스트
의도한 대로 프로그램이 움직여 주는지 테스트해 보자.
클라이언트가 데이터를 잘 출력해 주는 것을 보니 서버에서도 잘 보내준 것 같다.
자체 구현 버퍼에서 프로토버프로의 교체는 성공적이다.
사실 23.X 버전으로 하려다 도저히 이해를 할 수 없는 에러와 몇 시간을 싸웠다.
이건 일단 다음에 더 고민해 보기로 했다.
'Study > C++ & C#' 카테고리의 다른 글
[C++] IOCP를 활용한 채팅 서버 구현 (0) | 2023.07.21 |
---|---|
[C++/Python] 패킷 자동화 (0) | 2023.07.13 |
[C++] Packet Serialization (0) | 2023.07.12 |
[C++] Unicode / Encoding (0) | 2023.07.10 |
[C++] Packet Handler (0) | 2023.07.08 |