패킷의 변동 사항과 관련해 그와 관련된 부분을 일일이 수정하는 것은 매우 번거롭고 실수가 잦을 수 있는 일일 것이다.
하지만 자동화한다면 실수도 줄 것이고 개발이 보다 편리해질 것이라 기대할 수 있겠다.
자동화하는 과정을 배워보자.
1. proto 파일 관리
사실 이전에 proto 파일을 작성한 것처럼 모든 구조체를 하나에 때려 박는 것은 좋은 선택이 아니다.
프로토콜, 구조체, 열거형 등등을 각자의 특성에 맞게 나눠서 관리하면 더 직관적일 것이다.
열거형부터 정의해 보자.
syntax = "proto3";
package Protocol;
enum PlayerType
{
// 항상 0번이 있어야 하기 때문에 NONE 등을 0으로 하는 걸 추천
PLAYER_TYPE_NONE = 0;
PLAYER_TYPE_KNIGHT = 1;
PLAYER_TYPE_MAGE = 2;
PLAYER_TYPE_ARCHER = 3;
}
위와 같이 플레이어 타입을 열거형으로 정의했다.
그리고 프로토의 문법에선 열거형엔 항상 0번이 있어야 한다.
어떤 타입도 나타내지 않는 NONE을 앞에 두고 이를 0으로 설정한다.
이젠 구조체를 정의한다.
syntax = "proto3";
package Protocol;
import "Enum.proto";
message BuffData
{
uint64 buffId = 1;
float remainTime = 2;
repeated uint64 victims = 3;
}
파이썬처럼 import로 다른 파일에 있는 데이터를 불러올 수도 있다.
마지막으로 프로토콜.
syntax = "proto3";
package Protocol;
// proto 파일 간 참조 가능
import "Enum.proto";
import "Struct.proto";
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;}
}
구조체 파일을 Import해서 사용할 수 있다.
그리고 주석처리한 라인을 보면 패킷 ID를 정의하고 있는데, 이렇게 ID를 정의하는 방법은 나중에 관리하기 매우 힘들어질 수 있다.
ID는 별도로 관리하고 여기에선 패킷의 구조에 관한 내용만 담을 것이다.
작성이 끝났다면 배치파일을 실행해 생성된 파일들을 프로젝트로 복사해 줘야 한다.
하지만 만들고 복사하고 하는 과정이 너무 귀찮다.
만든 파일을 자동으로 프로젝트 폴더 안에 복사하는 것까지 자동화해 보자.
1-1. 짜잘한 자동화들
pushd %~dp0 REM 현재 배치파일이 실행되는 경로 설정
protoc.exe -I=./ --cpp_out=./ ./Enum.proto
protoc.exe -I=./ --cpp_out=./ ./Struct.proto
protoc.exe -I=./ --cpp_out=./ ./Protocol.proto
IF ERRORLEVEL 1 PAUSE
REM 복사 작업이 번거롭기 때문에 배치파일에서 생성된 파일 복사
REM /Y = 덮어쓰기
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 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/*"
명령어들을 통해 자동으로 프로젝트 폴더에 생성된 파일을 복사하게끔 했다.
/Y 옵션을 사용해 기존에 파일이 있어도 덮어쓸 수 있도록 했다.
경로 끝에 「/*」를 붙여준 이유는 파일/디렉토리 확인 과정을 없애기 위함이다.
하지만 아직 매번 빌드하기 전에 배치파일을 실행해야 하는 번거로움이 있다.
배치파일도 빌드 전에 자동으로 실행되게끔 해 보자.
프로젝트 속성 -> 빌드 전 이벤트로 가서 아래와 같이 설정한다.
CALL $(SolutionDir)Common\Protobuf\bin\GenPackets.bat 를 넣어주면 된다.
경로가 정확한지 확인하기 위해 아래의 「평가 값:」 영역을 참고하자.
서버 및 클라이언트 프로젝트에 다 해주면 되겠다.
이렇게 세팅하고 프로젝트를 빌드해 주면,
알아서 파일을 생성하고 복사까지 해주게 된다.
아직도 남았다.
빌드 시 VS가 proto 파일이 변하는 것을 감지하지 못한다.
proto 파일이 변한 것도 감지해서 빌드할 수 있으면 좋을 것 같다.
먼저 VS를 끄고 프로젝트 파일을 VSCode 등의 텍스트 편집기로 연다.
아래로 쭉 내려 아래와 같은 부분을 찾는다.
<ItemGroup>
<None Include="..\Common\Protobuf\bin\Enum.proto" />
<None Include="..\Common\Protobuf\bin\Protocol.proto" />
<None Include="..\Common\Protobuf\bin\Struct.proto" />
</ItemGroup>
저 None이라도 돼있는 부분을 「UpToDateCheckInput」로 바꾸고 저장한다.
이러면 VS에서 proto 파일의 변경을 감지하고 빌드하게 된다.
2. 그럼 코드에서 자동화가 필요한 부분은?
여하튼 이제 proto 파일을 기반으로 나머지 코드들도 자동으로 완성되게끔 하기 위한 준비가 필요하다.
어떤 부분이 어떤 식으로 자동화가 되어야 할지 생각해 보자.
- 패킷 ID
- 수기로 입력하다가 중복이 일어날 수도 있다.
- 보안을 위한 옵코드 셔플링에도 유용하다. - 핸들러 선언
- 수많은 패킷의 핸들러 함수도 일일이 작성하기 어려우니 자동화한다.
- 함수의 구현은 컨텐츠 작업에서 이루어진다. - 패킷 별 송신버퍼 선언
- 각 패킷 별 버퍼에 내용을 채우는 함수도 숫자가 많을 수밖에 없다. - 최초 핸들러 초기화
- 아래에 계속
여기선 핸들러들을 함수 배열에 넣어 관리할 것이다.
각 인덱스는 각 함수가 처리할 패킷의 ID와 같다.
using PacketHandlerFunc = std::function<bool(PacketSessionRef&, BYTE*, int32)>;
extern PacketHandlerFunc GPacketHandler[UINT16_MAX];
// TODO : 자동화
// Custom Handlers
// 세션 인자가 필요한 이유는 어떤 클라이언트가 요청을 보냈는지 알 수 있어야 하기 때문
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len);
bool Handle_S_TEST(PacketSessionRef& session, Protocol::S_TEST& pkt);
각 핸들러들은 GPacketHandler[]라는 배열에 들어갈 것이다.
그리고 아래와 같이 초기화가 이루어진다.
static void Init()
{
for (int32 i = 0; i < UINT16_MAX; i++)
GPacketHandler[i] = Handle_INVALID;
// PKT_S_TEST는 서버에서 클라로 보내는 패킷이기 때문에 여기서 등록할 것은 아님
// 이런 식으로 코드가 작성될 것이라는 것을 보여주는 견본
GPacketHandler[PKT_S_TEST] = [](PacketSessionRef& session, BYTE* buffer, int32 len) { return HandlePacket<Protocol::S_TEST>(Handle_S_TEST, session, buffer, len); };
}
먼저 모든 핸들러 배열에 「Handle_INVALID」를 넣어줄 것이다.
그다음에 각 패킷 ID에 맞는 인덱스에 진짜 핸들러들을 채워줄 것이다.
진짜 핸들러들을 채워주는 부분도 많이 길어질 것이기 때문에 자동화가 필요한 것이다.
Handle_INVALID 함수는 부정한 패킷 ID에 대한 처리를 하게 된다.
bool Handle_INVALID(PacketSessionRef& session, BYTE* buffer, int32 len)
{
PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
// TODO : Log
return false;
}
어떤 이상한 패킷이 와서 처리가 되지 않았다는 로그를 남기는 게 목적이다.
클래스 내부에서 사용될 HandlePacket()은 많은 종류의 패킷에 대한 일괄적 처리가 필요하므로 템플릿으로 구현한다.
private:
template<typename PacketType, typename ProcessFunc> // PacketSessionRef를 굳이 참조로 넘겨주지 않아도 됨
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);
}
데이터 파싱에 성공하면 해당 함수로 데이터들을 넘겨준다.
최초 데이터 수신 시 이 패킷을 정확한 함수에 넘겨줄 함수도 필요하다.
public:
static bool HandlePacket(PacketSessionRef& session, BYTE* buffer, int32 len)
{
PacketHeader* header = reinterpret_cast<PacketHeader*>(buffer);
return GPacketHandler[header->id](session, buffer, len);
}
// TODO : 자동화
static SendBufferRef MakeSendBuffer(Protocol::S_TEST& pkt) { return MakeSendBuffer(pkt, PKT_S_TEST); }
함수를 그대로 실행할 수 있게 하고 성공 여부를 리턴해 준다.
위의 내부용 HandlePacket()과 혼동하지 말자.
그리고 아래에 MakeSendBuffer()도 아까 말한 대로 자동화가 필요한 부분이다.
나중에 숫자가 많아지면 저 부분도 자동화가 필요하게 될 것이다.
3. 일단 테스트
어느 정도 내용이 채워졌으니 테스트해 보자.
서버의 메인 함수에 핸들러를 초기화하는 부분만 추가해 주면 된다.
int main()
{
ServerPacketHandler::Init();
ServerServiceRef service = MakeShared<ServerService>(
NetAddress(L"127.0.0.1", 7777),
MakeShared<IocpCore>(),
MakeShared<GameSession>,
100);
// ...
}
호출되는 순간
- 배열 전체를 초기화하고
- 각 패킷 ID에 매칭되는 인덱스에 함수를 넣는다.
- HandlePacket()이 호출되길 기다리게 된다.
물론 이번엔 송신한 할 것이기에 그 부분에 대해서만 테스트한다.
결과가 잘 나오는 것을 보니 위에서 생각한 대로 자동화 과정이 이루어져도 문제가 없을 것 같다.
4. 자동화 툴 제작
들어가기 전에 파일 이름을 조금 고치자.
서버로 패킷을 보내는 주체는 클라이언트다.
반대로 클라이언트에 패킷을 보내는 주체는 서버.
서버에서 서버 패킷을 핸들링한다고 하니까 뭔가 말이 맞지 않는 것 같다.
서버에 있는 핸들러의 이름은 ClientPacketHandler로,
클라에 있는 핸들러의 이름은 ServerPacketHandler로 바꿔주자.
그리고 솔루션이 있는 폴더에 Tools라는 폴더를 새로 만들어 거기에서 작업한다.
이제 본격적으로 자동화하기 위해 도구를 만들 것이다.
jinja2와 pyinstaller를 사용할 수 있는 파이썬 환경을 준비했다.
준비가 끝났으면 proto 파일을 파싱 할 클래스를 하나 만들자.
어떻게 파싱해야 할지 일단 proto 파일의 구조를 다시 살펴보면,
message S_TEST
{
uint64 id = 1;
uint32 hp = 2;
uint32 attack = 3;
repeated BuffData buffs = 4;
}
message라는 키워드가 있고 그다음에 컨벤션에 따른 패킷 이름이 있다.
message가 나오는 줄을 찾고 패킷 이름을 먼저 파싱 한다.
그다음 패킷 이름을 컨벤션 별로 분리해 보내는 패킷인지 받는 패킷인지도 구분하면 될 것 같다.
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):
# ...
ProtoParser라는 클래스를 만들고 필요한 변수들을 세팅한다.
- total_pkt
- 패킷 ID는 모든 패킷에 필요하기 때문에 일괄적으로 부여하기 위하여 필요하다. - start_id
- 패킷 ID를 순서대로 매길 때 시작할 번호. - recv/send_prefx
- 주체에 따라서 받는 패킷인지 보내는 패킷인지 구분해 이름을 지어줘야 하기 때문에 필요하다.
아래는 실제 파싱을 위한 코드이다.
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
순서는 아래와 같다.
- 파일을 읽기 전용으로 연다.
- 파일의 모든 라인을 파싱해 배열로 저장.
- 라인을 하나씩 불러오며 그 라인이 "message"로 시작하는지 확인.
- 다르게 시작하면 필요한 라인이 아니기 때문에 continue - 이름이 「C_」 또는 「S_」로 시작하는지 판별 후 분류
- 컨벤션에 어긋나는 이름이면 continue - 문제가 없다면 total_pkt에 저장 후 id를 1 높이고 3번부터 반복.
이제 jinja2를 사용해 실제로 코드를 생성할 파이썬 프로그램을 만들어야 한다.
import argparse
import jinja2
import ProtoParser
def main():
arg_parser = argparse.ArgumentParser(description = 'PacketGenerator')
arg_parser.add_argument('--path', type=str, default='H:/C++/Learning/Server/25_PacketAutomation-/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()
파서를 이용해 프로그램 실행 시 인자를 넘겨받을 수 있게 했다.
인자들은 아래와 같은 의미이다.
- --path
- Protocol.proto 파일이 있는 경로 - --output
- 만들어질 헤더파일의 이름 - --recv/--send
- 송수신 주체를 구분하기 위해 컨벤션을 지정
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)
jinja2를 사용하기 위한 환경을 설정한다.
Templates라는 폴더에서 PacketHandler.h라는 템플릿을 참고하여 jinja2가 일을 해준다는 의미이다.
4-1. 템플릿 작성
템플릿으로는 기존에 사용하던 Sever/ClientPacketHandler.h를 그대로 넣을 것이다.
이제 여기서 자동 생성이 필요한 부분만 찝어서 "여길 이렇게 자동완성 해줘"라고 요구하게 되는 것이다.
먼저 문법에 대해 알아보자.
enum : uint16
{
{%- for pkt in parser.total_pkt %}
PKT_{{pkt.name}} = {{pkt.id}},
{%- endfor %}
};
{% %}로 감싸진 부분이 실제 자동생성되는 부분이다.
저 감싸진 부분 안에서는 파이썬 문법을 사용할 수 있다.
for문을 활용하여 필요한 부분에 맞게 변수를 넣어주는 형태를 취하게 된다.
% 옆에 -가 있는 것을 볼 수 있는데, 이는 Spacing에 관한 옵션을 제공한다.
-가 있으면 Vertical Spacing을 사용하지 않도록 한다.
만약 없다면 아래와 같이 간격이 생길 것이다.
Reference
What does this "-" in jinja2 template engine do?
enum : uint16
{
PKT_C_TEST = 1000,
PKT_C_MOVE = 1001,
PKT_S_TEST = 1002,
PKT_S_LOGIN = 1003,
};
이런 간격이 싫다면 -를 붙여 Suppress 하자.
이외의 자동화가 필요한 부분들은 아래와 같다.
# ...
// 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)
#...
원래 헤더 파의 인덴트에 맞게 간격을 줘야 깔끔하게 나온다.
f = open(args.output+'.h', 'w+')
f.write(output)
f.close()
print(output)
return
if __name__ == '__main__':
main()
마지막으로 결과를 파일에 쓰고, 이를 콘솔에 출력하고 프로그램을 종료한다.
이제 실행을 위한 배치 파일을 작성한다.
4-2. 빌드를 위한 준비
이렇게 만들어진 파이썬 파일들을 .exe 파일로 만들어 사용할 것이다.
아래와 같이 배치파일을 작성한다.
pushd %~dp0
pyinstaller --onefile PacketGenerator.py
MOVE .\dist\PacketGenerator.exe .\GenPackets.exe
@RD /S /Q .\build
@RD /S /Q .\dist
DEL /S /F /Q .\PacketGenerator.spec
PAUSE
dist라는 폴더에 생기는 PacketGenerator라는 파일을 그 밖으로 빼내고 GenPackets라는 이름으로 고친다.
그리고 더 이상 필요 없는 임시 파일들을 다 삭제하게 했다.
이제 GenPackets.bat으로 돌아가 추가된 것들에 맞게 내용을 수정한다.
GenPackets.exe --path=./Protocol.proto --output=ClientPacketHandler --recv=C_ --send=S_
GenPackets.exe --path=./Protocol.proto --output=ServerPacketHandler --recv=S_ --send=C_
# ...
XCOPY /Y Protocol.pb.cc "../../../GameServer/*"
XCOPY /Y ClientPacketHandler.h "../../../GameServer/*"
# ...
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
핸들러들을 자동으로 생성하고 이를 프로젝트 폴더에 복사한다.
복사가 다 끝나면 exe 파일이 있는 폴더의 임시 파일들을 다 삭제한다.
빌드하면서 생긴 에러들을 다 잡아주고 실행하자.
로그 출력에 관한 부분은 건드리지 않았기에 이전처럼 데이터가 잘 출력되면 성공이다.
5. 결과
실행에 문제가 없다.
로그를 다르게 찍지 않아서 왠지 이미지를 재탕하는 것 같다...
이렇게 강의의 패킷 직렬화 단원이 끝났다.
다음 강의에선 채팅 및 JobQueue에 대한 내용을 다루는 것 같다.
그전에 일단 학교 다닐 때 만들었던 유니티로 개발한 게임에 채팅서버를 붙여보려고 한다.
먼저 내가 만들어 본 후 강의 내용과 비교하면 더 좋은 공부가 될 것 같다.
'Study > C++ & C#' 카테고리의 다른 글
[C++/C#] C# 채팅 클라이언트 간보기 (0) | 2023.08.01 |
---|---|
[C++] IOCP를 활용한 채팅 서버 구현 (0) | 2023.07.21 |
[C++] Protobuf (0) | 2023.07.13 |
[C++] Packet Serialization (0) | 2023.07.12 |
[C++] Unicode / Encoding (0) | 2023.07.10 |