Program/C & C++

Packet Generator 패킷 생성기

너구리V 2012. 12. 28. 15:10

출처 :http://mastercho.tistory.com/6


온라인 프로그램 패킷의 전통적 처리 방법 ( 장 단 점 )

온라인 프로그램을 짜는 프로그래머라면   누구나 프로토콜을 정의해 패킷을 만들고

클라이언트와 서버간에 통신하는 모듈을 만들어보았을것이다

그리고 이것은 일찍감치 프로그래머들에게 의해  2가지 패턴으로 정형화 되었는데 .....

첫째로는  구조체를 이용하는 방법이고 두째로는 스트림 클래스를 이용하는 방법이다

잘 이해가 안갈수 있으니 예를한번 살펴보자.

첫번째 방법: 구조체 이용하기

struct HEADER
{
int type;
int size;
}

enum {ID_CS_LOG_IN = 25 };

struct CS_LOG_IN : public HEADER
{
CS_LOG_IN() { type = ID_CS_LOGIN; size = sizeof(*this); }
char id[32];
char password[32];
};


이렇게 struct를 정의하고 해당하는 enum값을 정의 한다음
struct 의 생성자에서  필요한 값을 넣어준다

그다음  내부 내용을 복사해 채워 넣은후 소켓함수로 데이타를 보내는게 일반적인것이다

CS_LOG_IN packet;
strcpy(id,"myID");
strcpy(password,"mypassword")

SendPacket(socket,(char*)&packet,sizeof(CS_LOG_IN));

받는쪽도 그냥 통째로 받아 헤더의 type를 보고 CS_LOG_IN 으로 캐스팅 처리하면 된다


장점 :
1. 대단히 직관적이고 쉽다.
프로토콜 패킷정의를   구조체로 볼수 있고, 구조체는 누구나 쉽게 바로 파악할수 있기 때문이다

2. 직관적이기 때문에 고전적으로 많이 쓰였다. 따라서 누구나 쉽게 소스를 구할수 있고
편하게 접근 할수 있다

3. 컴파일 타임때 구조체 크기가 결정되므로 패킷의 버퍼 오버플로우를 컴파일 타임때 체크할수 있다

4. 구조체 내용이 바뀌면  컴파일타임때 패킷을 쓰는 부분에서 에러를 내뱃어주므로
   컴파일때 패킷처리의 오류를 고치기 쉽다

단점 :
1.  일단 구조체 이므로 바이트 정렬에 신경 써야 한다.
컴파일러는 기본 정렬이 4-16 사이에  정의 되어 있으므로
#pragma pack(1) 같은  컴파일러에 영향주는 옵션을 줘야 하고
저걸 제대로 안했을경우 (클라이언트/서버) 구조체 데이터가 서로 호환이 안될수도 있기에
바이트 정렬 자체를 신경써야 한다는거 자체가 부담스럽다

2. 1번과 비슷한 문제지만 서버와 클라이언트의 플레폼이 다를 경우 CPU의 (빅/리틀)엔디안
때문에 호환이 되지 않는 경우가 발생할수도 있다.  Marshal(마샬) 같은 작업이 필요할수가 있는것이다

3. 구조체는 컴파일 타임때 크기가 결정되므로  항상 최대 크기의 데이터를 보내야한다
예를 들면  유저가 채팅방에 0-100명까지 들어갈수 있다고 보자
입장할때 실제 방안에 있는 사람 숫자만큼만 데이터를 줄수 있어야 하지만
구조체는 항상 최대 크기인 100명을 소켓에 출력할수 밖에 없다

따라서 런타임시에 , 가변적인 크기에 대응하기 위해선
꽁수를 써야한다(이건 설명을 생략),  하지만  근본적으로 구조체 방법은  컴파일타임때 결정되는
구조이므로 꽁수로써 쓰다보면  프로토콜 자체가 점점 복잡해지며 처리 방법이 지저분해진다
패킷이 가변적인지 아닌지를 신경 써야 하는것이다.  


결론 :

구조체 방법은 나름 편리하기도 하지만 가변적인 문제는 치명적이다

따라서 네트웍 부하를 고려해야하는 온라인 게임에는 일반적으로 적합하지 않다

하지만 꽁수를 씀으로써  극복하려는 프로젝트를 상당히 많이 보았으며 상용 게임에서도

꽤 많이 쓰이곤 한다. 하지만 꽁수로 극복해야 하는 것들이 발목잡으며 상당히 거슬리곤 한다

익숙해지면 괜찬다고 하지만  ..........


결론적으로는  네트웍 대역폭을 별로 신경 쓸 필요 없는 프로젝트에서 대부분 많이 쓰인다

------------------------------------------------------------------------------------------------

두번째 방법: 스트림 클래스 이용하기

c++ 프로그래머라면 누구나  std::cout<<"hello world"<<std::endl;  프로그램을 짜봤으리라
의심치 않는다

하지만 누구나 스트림의 대표격인 cout를 쓰면서도 스트림 클래스을 제대로 이해하고  활용하는 사람들은
일반적으로 많진 않은 편이다 , 나 역시 스트림을 그리 즐겨 사용하진 않는다

그렇지만 패킷 처리에 있어서만큼은 필수라 할만큼 꼭 필요한 방법인데 예를 한번 보자

(스트림 클래스의 구현은 인터넷에 많이 있다 모르는분은 찾아보자)
class PacketStream
{
 // value to buffer
  PacketStream& operator << ( int  value );
 // buffer to value
  PacketStream& operator >> ( int  value );

        -------------------------------------------
 char m_buffer[1024];
}

enum { ID_LOBBY_USER_LIST = 26 };

struct User
{
 char id[32];
 int    flag;
}

--------------             보내는쪽             ------------------------

// 서버에 있는 유저 데이타 리스트
std::vector< User > m_serverUserList;

PacketStream stream;

// 먼저 ID를 입력해주고
stream << ID_LOBBY_USER_LIST;

// 가변 크기의 입력
stream << m_serverUserList.size();
for(size_t i=0; i < m_serverUserList.size(); ++i)
{
 // 갯수만큼 내용을 스트림에 입력
 stream << m_serverUserList.id;
 stream << m_serverUserList.flag;
}

SendPacket( socket, stream.GetBuffer(), stream.size() );

--------------------------------------------------------------------------

--------------             받는 쪽              ------------------------

PacketStream stream;

// 소켓으로 부터 복사된 버퍼를 받아 stream에 입력한다
stream.CopyBuffer( socketBuffer, socketSize);

// id 출력해 파싱한다
int  type;
stream >> type;

switch( type )
{
case ID_LOBBY_USER_LIST:
{
std::vector< User > m_clientUserList;
size_t userCount;
   stream >> userCount;
m_clientUserList.resize( userCount );

for(size_t i=0; i < m_serverUserList.size(); ++i)
{
stream >> m_clientUserList[i].id;
  stream >> m_clientUserList[i].flag;
   }
  }
}
--------------------------------------------------------------------------

참고 : stream에서 STL를 지원하게 만들면 stream 작업이 상당히 간편해진다


장점:
1. 구조체의 가장 큰단점인 고정크기로 부터 자유롭다

2. 고정크기에 자유롭다는것만으로 온라인게임에서 가장 적합한 크기의 패킷을
만들어 낼수가 있다

자유롭다


단점 :  
 

1. 런타임때 크기를 알수 있기때문에  런타임시에 버퍼 오버플로우와 언더플로우를 체크해야한다

2. 정확히  보내는 쪽과 받는쪽이 짝을 이루어야 한다

실수로  한쪽만 고치던가 다르게 고치면  컴파일 타임때는 잡아낼수 없고

런타임때  스트림이 꼬여 크래쉬를 내거나  눈에 띄는 버그를 발생 시킬때나 인식할수 있으므로

실수는 치명적이다.

[ 예를 들면 보내는쪽과 받는쪽의 타입 크기가 다르게 실수로 설정하던가 순서가 바뀌던가 하는... ]

이러한 실수는 적당히 스트림이 꼬여버려 애매한 버그를 만들어 버리고 
마치 잘못된 메모리 참조 마냥 ......  충격으로  다가오곤 한다.
[차라리 바로 크래쉬를 내주면 고맙다]

게다가 반복 노가다적인  패킷 설정 작업은 구조체 설정에 비해 상당히 불편한 편이며
패킷의 복잡도에 따라 나름 상당한 노가다 이기에 복사 붙여넣기를 무조건?하게 유발한다
 


또한 구조체에 비해 직관성이 떨어진다

따라서 작업을 하다보면  스트림 꼬이는 버그는 근본적으로 피하긴 어렵다

결론  
 :

가변적 크기를 다루는 내용이 많고 , 네트웍 대역폭 낭비가 적어야 하는 온라인 게임이라면
스트림 방법은 선택이 아니라  필수다

구조체 방법과 비교하자면

구조체방법에서는 꽁수로 구조체에 제한된 스트림 방식을 적용하므로 프로토콜 처리
복잡도가 올라간 반면

스트림 처리는 깔끔은 하지만 컴파일타임때 체크는 포기해야 하므로  버그가 괴롭다

그 버그와,  편리함 때문에,  온라인게임에서  프로토콜이 복잡해지는것을 감수하고도
 꽁수가 추가된 구조체 방법을 많이 이용하게 되는것이다
[하지만 꽁수를 볼때마다  스트림 방식이 근대적인 방법임을 확신하게 된다]

비율은 조사를 해보진 않았으나 회사를 옮기는곳마다  의외로 구조체 방법을 고수하는곳이 많았다


하지만  구조체와 스트림 방식의 장점을 모두 취할수 있고 , 단점은 날려 버릴 방법은 없을것인가?

그래서 생각한것이 바로 패킷 생성기다


-----------------------------------------------------------------------------

저번 글에서   전통적인 두가지 패킷 처리 방법을 보았다

두 방법다 장점과 단점이 존재 하며  ,  완벽한 방법은 없어 보인다

하지만 자연스럽게 합쳐질 수 있는 방법은 없을까?

안타깝게도  두 방법은 공존할수 없는 방법이다 , 하나는 컴파일타임 기반 코드이고
하나는 런타임에 기반코드이기에 공존해 쓸수가 없는것이다

솔직히 근본적으로 C++ 언어차원에서는 해결할 방법이없다
그래서 수많은 프로젝트에서 이 전통전인 방법들을 고수하고 있는게 아닌가?

이쯤 되고 보면 , 이렇게 된거 하나를 선택하고 단점을 줄일 방법을 연구하는게 빠를거 같다

"남들이라고 별수 있겠어?" 라고 속편히 생각하면  이게 가장 현명한 생각인것인 같다는 생각도
드는게 인지상정이다

하지만  구조체를 선택해도 가변적인 처리때문에  코드량 및 복잡도가 높아지고
스트림 처리를 선택하더라도  패킷이 복잡하고나 양이 많아지면 마찬가지로 코드량이
많아지고 복잡해지는건 매한가지다

"나와 내 동료들은  실수 따윈 하지 않아!" 라는 가정하더라도 결국 많은 양의 단순 노가다에 앞에는
장사가 없다. 노가다는 인간의 본성을 거슬리는 일이기때문이다
(공산주의가 망한 해묵은 이유를 설명하진 않아도 되겠지)

"그냥 몸으로 때워!"의 마인드를 가진 팀장이라면 , 팀원들이 생각하며 생산적인 일을
하기보단  노가다하는 시간이 많을것이다
(새벽2-3까지 매일같이 해도 답 안나오는 프로젝트가 아직도 기억이 생생하다)

게임 디자이너너 마저도  "단순 노가다는 죽기보다 싫다"며 프로그래머에게 자동화를 요구해
오는 광경은 어렵지 않게 볼수 있다
[ 심지어 맥스 스크립트로 자신의 작업을 자동화해버리는 디자이너도 있다 ]

결론적으로 말해 인간의 본성을 거슬르는 노가다로 프로젝트를 운영하게 되면
프로젝트가 제대로  돌아갈리가 없다

근데 잠시만 디자이너가 자동화? 요구한다고?

갑자기 드는 생각으로 디자이너의 편의를 위해 자동화 처리를 해주면서  정작 왜 자신의 일인
코드는 자동화 시키지 못하는지 의아스럽기만 하다

굳이 변명을 하자면   C++언어밖에 모르는 입장에선 C++로 디자이너의 일은 자동화 해줄수 있지만
정작 자신은 C++언어의 문법에 묶여 C++ 언어에서 할수 있는거 밖에 생각할수 없지 않은가?
 
그래도 미약하지만 C언어의 표준 전처리기인 메크로를 써 조금이라도 더 편하게 쓸수 있지 않을까라는
생각이 든다

"오 메크로! 그럴듯한데?" 단순 반복작업을 어떻게든 줄여줄수 있는것처럼 느껴진다
메크로로 인해 코드가 어려워 보이겠지만  이 노가다만 줄일 수 있다면 영혼이라도 팔겠다는 심정으로
#define Oh_My_God_Packet  ....
#define Damn_it  ....
#define WASTE_OF_TIME....
이런 코드를 짜기 시작하지만  끝도 없이 정의해야 될것들이 생겨난다.

게다가  이건 내가봐도 외계어가 된 느낌이다 ,
메크로를 정의해 패킷 처리 노가다를 줄여보려 외계어 정의에 더 스트레스 받는 느낌이
이건 뭔가 아니다 싶다

역시 없쩔수 없이  노가다를 인정하겠다는 마음으로  패킷 처리 코드를 짜지만
 , ctrl + V 를 한 코드에서 버그가 다시 발생하자  , 마음이 약해진다

그러는 문득! 실용주의 프로그래머에서 읽었던  자동화가 떠오른다

내용이 좋아 3-4번을 읽었으면서 막상 왜 그 자동화를 실천하지 못했을까?
[물론 그 책은 5년전에 읽었고 이 생각은 4년전부터 해왔다, 생각은 하고 있었으나 역시 실천이 T_T ]

실용주의 프로그래머 원칙
코드를 작성하는 코드를 작성하라

오오라 바로 이것인것이다
[신대륙을 발견한 콜롬버스의 달걀 깨기 처럼 C++ 문법의 한계를 넘어서는게 중요하다]

혹시 누군가 이런걸 만들어 놓지 않았을까 하는 마음에  구글에서 검색을 해보지만
C++에서 맘에 드는 방식으로 바로 쓸수 있는건 드물어 보였다

구글 코드에서 XML로 정의해 C++ 코드를 생성해 내는게 있었지만
XML로 복잡하게 정의해야 했고 , XML 정의가 직관적으로 바로 눈에 들어오진 않았다

그리고 그외 비슷한 것들을 보았지만 내맘에 쏙드는게 없었다

마치 fix_vector를 만들때 기분이랄까?

역시 직접 만들어야겠다는 생각에  프로토콜을 직관적인  구조체로 정의하고  그 코드를 파싱해서
stream 에 입출력하는 코드를 작성하는 생성기를 만들었다

그토록 원한  고전적인  패킷 처리 방법인 구조체 처리 방법과 스트림 처리의 장점을 동시에
가져갈수 있게 된것이다

분명 글 앞단에선 이것이 불가능하다면서  지금은 어떻게 가능하다고 하는 이야기일까?

다시 설명하면
전 처리 단계때  구조체를 보고  send recv  함수를 만들어 낸다면 (제대로 작성했따면)
컴파일 타임 체크 자체가 필요가 없고 ,  send recv 함수 내에는  구조체의 내부 데이터를
스트림에 담아 소켓 버퍼에 보내는 함수를 호출한다면 스트림의 장점을 다 취하는것이다

예를 들어 보겠다
// 파싱의 편의상 및  프로토콜이라는 직관성을 위해  struct 메크로로 재정의 했다

-----------------------------------------------------------------------

#define PT_INTERFACE  struct
#define PG_VECTOR     std::vector

// 이것이 패킷 정의다
PT_INTERFACE CS_LOG_IN
{
    String id;
    String password;
    PG_VECTOR<String> itemList;
};
-----------------------------------------------------------------------

"이건 구조체 방법이 아닌가?" 싶은데  가변 크기를 지정하는 vector가 들어가 있다
vector를 봤을적엔 구조체 방법도 아닌것고 그렇다고 스트림 방법도 아닌거 같다

자 이걸 보고 만약 이런 코드를 생성한다면 어떨까?

void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize);

void SendPacket(SOCKET socket , const CS_LOG_IN& packet )
{
        // 이 스트림 클래스엔 String과 Vector를 처리할수 있는 operator << 가 정의 되어 있다
PacketStream stream;
stream << packet.id;
stream << packet.password;
stream << packet.itemList;
SendPacket(socket,stream.GetBuffer(),stream.GetSize());
}

감이 있다면 바로 느꼈을것이다.  바로 이거라고....

구조체로 패킷을 정의하고 , 코드 생성기로 컴파일 타임체크의 역활 이상을 대신하며
스트림의 장점을 가지는 이런 코드 생성..... 아 내가 원한것이 바로 이런것인것이다

참고로
void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize)  이 함수는 
은 선언만 되어 있고 구현은 없다  

이유는 프로젝트마다 선택되는 소켓의 처리 방식이 다를수 있으며
암호화가 추가될수도 있기에  특정 코드의 종속성을 방지 하기 위해
어플리케이션 코드단에서 정의를 해줘야 하기때문이다

예를 들면
void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize)  
{
//간단한 XOR 암호
XOR_Encode(pBuffer,bufSize);
// 현재는 간단히 버클리 소켓을 쓴다
send(socket,(char*)pBuffer,bufSize,0);
}

 패킷 생성기가 유연하고 독립성을 가지려면  위와 같이 어플리케이션 종속적인 코드를
피해야 재활용 가능한 패킷 생성기를 이용할수 있게 된다

to be continue...


----------------------------------------------------------------

저번에는 SendPacket 함수까지 알아보았다

이제는 받기 함수를 소개할 차례인데

참 어려운 고민이 많았다

일단 서버를 생각한다면  IOCP , select, epoll , kqueue 등등  다양한 소켓이 존재했고
그리고 소켓을 캡슐화한 다른 형태의 라이브러리까지 생각한다면 ,
그런것을 고려해 코드를 생성하기는 매우 어려운 일이었다

게다가  Send쪽은 SendPacket은  void* 와 size 를 인자로  하나의 함수만 어플리케이션쪽에서
정의해 주면 끝났지만

받는쪽은 반대로 void* 와 size 를 받아
패킷을 분석해  PT_INTERFACE로 정의되어 있는 구조체를 인자에 맞게 함수를 호출해줘야 했기때문에
더더욱 쉽게 자동화 하긴 어려웠다

그래서  가장 덜 손이 갈수 있겠다 싶은 방법을 생각해 보았다

먼저 해야할것이

 소켓에서 데이타를 받은 데이터를 stream에 입력하고 enumID에 맞게 PT_INTERFACE에 정의한
구조체로 변환 하는 작업이다

예를 들면 이러한 함수 일것이다

// 이함수는 AC_LogIn 이 데이터가 어떻게 쓰일지 모르기에 , 선언만 되어 있음
void ProcessPacket(SOCKET_ID socketID, const AC_LogIn& ptData);

void RecvPacketAC_LogIn(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
{
AC_LogIn ptData;
StreamReaderSelfBuffer stream(pBuffer,bufferSize);
stream & ptData;
ProcessPacket(socketID,ptData);
}

여기서 보면  내부에서 ProcessPacket 함수가 다시 호출되는데 이것은 스트림에서 구조체로
변환한다음  이 구조체를 사용하는 함수를 호출해주는 역활이다

이 구조체로 무엇을 할지 모르기때문에  이 함수의 구현은 어플리케이션 단에서 해줘야한다
그러므로 패킷 생성기에서 생성된 코드를 보면  선언만 되어 있고 구현은 없다

마치 SendPacket를 어플리케이션 단에서 다시 정의해줬던것과 같은것이다
따라서 저러한 함수를 어플리케이션 단에서 구현해줘야 한다  

자 그럼...  어떻게 저러한 함수가 호출하도록 만들것인가를 살펴보자

일단 저함수를 호출할수 있도록 Map에 담아 가져오기로 한다
enumID 를 키로하고   void (SOCKET_ID ,int8* ,uint32 ) 의 함수포인터를 가지는 Map 가져오기로 하는것이다

void GetFuncMap_gene_recv_PT_AC_Data(RecvPacketMap& ptRecvMap);

그럼 이제 이 Map 담긴 함수를 호출해야 하는데   아주 간략하게 코드로  보자면 이렇게 될것이다


// 스트림을 구조체로 바꿔 준후   구조체를 사용하는 함수를 호출해주는 Map얻어오기
// 자동 생성된 코드에서 이 함수가 선언되어 있다
GetFuncMap_gene_recv_PT_AC_Data(g_funcRecvMap);


//------------------------------------------------------------------------------
// 소켓으로부터 버퍼 얻어오기  ( 간단히 만든거므로 딴지 금지 )
// 전체 크기 얻어 오기
uint32 totalSize;
ptEnumType enumID;
int recvSize = recv(g_sockfd,(char*)&totalSize,HEADER_SIZE,0);
if(recvSize <= 0)
 break;

// enum 얻어오기
PG_ASSERT(recvSize == HEADER_SIZE);
recvSize = recv(g_sockfd,(char*)&enumID,PT_ENUM_TYPE_SIZE,0);
if(recvSize <= 0)
 break;

PG_ASSERT(recvSize == PT_ENUM_TYPE_SIZE);

// 데이타 버퍼 얻어오기
char recvBuf[MAX_PACKETLEN];
uint32 ptSize = totalSize - PT_ENUM_TYPE_SIZE;
recvSize = recv(g_sockfd,recvBuf,ptSize,0);
if(recvSize <= 0)
 break;

PG_ASSERT(recvSize == ptSize);
//------------------------------------------------------------------------------


// 위 루틴으로부터  enumID 와 버퍼  버퍼크기를 얻어올수 있었다

try
{
// check enumID valid
RecvPacketMap::iterator it = g_funcRecvMap.find(enumID);

if( it != g_funcRecvMap.end())
it->second(g_sockfd,recvBuf,ptSize);
}
catch(StreamExceptionUnderflow* )
{
// 패킷 Stream Underflow
}

g_funcRecvMap   함수포인터 맵에 enumID를 넘겨주면
위에서 스트림을 구조체로 변환해주는  함수포인터를 넘겨 받게 된다

그리고 그 넘겨 받은 함수에  버퍼와 크기를 넘겨주면 자동으로  
g_funcRecvMap[enumID](g_sockfd,recvBuf,ptSize); )

위에서 말한
void ProcessPacket(SOCKET_ID socketID, const AC_LogIn& ptData);
이러한 함수가 호출된다

단 패킷 생성기에서는 선언만 만들기 때문에 구현은 어플리케이션단에서
정의해주어야 하는것이다

자동으로 많은것을 만들려주다보면  소켓 구조에 의존성을 가지게 되어
재활용율이 떨어지게 되므로  이정도까지가 최선이라 생각이 되었다

필요하다면  코드를 수정해 더  필요한만큼 만들어내면  될것이지만
가장 최적으로 재활용 될만한 수준에서 공개하는게 바람직할거 같아 여기까지 해주는 것으로
일단락 하였다

참 그리고 패킷 제네레이터가 해줘야 할 중요한 작업이 하나더 있는데
PT_INTERFACE의 내용 구성물이 바뀔때마다 ( 멤버 데이터의 순서 바뀜 혹은 추가 삭제 )  
버전업을 시켜 줘야 한다 그래서 버전이 다른 서버/클라이언트 간에 통신을 막아주어야 한다

예전 XX 게임에서는 빌드 할때마다 버전업을 시켜 , 클라이언트만 빌드해도 버전업이되어  
서버를 내리고 다시 버전업 시킨 서버를 올려야하는 시스템이 있었다

프로토콜이 바뀌지 않는 이상 그럴 필요는 없으므로 버전은 프로토콜이 바뀔때만 올라가야한다
따라서 패킷 제네레이터는 PT_INTERFACE의 스트림에  
 담는 구조가 바뀌었는지 파악하기때문에
자동으로 프로토콜 버전업 관리도 하게 된다  
----------------------------------------

 다음버전에는 좀더 최적화된 버전으로 코드를 생성할수도 있게 옵션을 추가 할 예정이다
 
-------

참고 사항 :

스트림 클래스를 boost의 serialization을 쓰려 했으나 너무 무겁고 ,  파일 쓰기에 중심을 맞춘거 같아
스트림 코드를 재작성하였다  ,  그리고 제가 주말에만 취미로 작업을 하는데
일요일 회사 문이 잠겨 있어 T_T    문서화 작업을 끝내지 못해 공개를 조금 미루게 될거 같다


---------------------------------------------------------------------


드디어 패킷 제네레이터 (PG 로 줄여쯤) 를 사용해보자


현재 배포될 PG_ReleaseBeta0.5a.zip 를 풀면   



이렇게 디렉토리가 나오게 된다 ( 이미지와 다르게 이름이 바뀌었다 T_T 양해를... )
 
PG_ReleaseBeta0.5a  디렉토리에는  기본적으로  패킷 생성기 파일(PG.exe)이 있으며

그외 테스트 서버와 클라이언트에서 기본적으로 참조하는 PgType.h ,PgUtil.h, PgUtil.cpp , 
PgStream.h PgStream.cpp 파일이 존재 한다

그리고 클라이언트에서 서버(에이전트 서버)로 가는 패킷 정의는 PT_CA_Data.h에 있으며
반대로 서버에서 클라이언트로 가는 패킷 정의는 PT_AC_Data.h 에 있다

다시 말해 ,  우리가 필요로 하는 프로토콜 패킷은 저기에다 정의 하면 되는것이고 
PG.exe는 저 파일을 파싱하여  자동 생성파일을 만들어 내는것이다

PG.exe를 사용 예제를 위해  autoPG.bat 파일을 보면 밑에와 같이 사용되어질수 있다

-----------------------------------------------------------------------
PG.exe -ptdb:my_game.pkdb -ptprecompiledheader:stdafx.h
-ptversionfile:PT_CA_Data.h -ptn1:PT_CA_Data.h -ptn1:PT_AC_Data.h
-----------------------------------------------------------------------
[간략한 인자 설명 ] 
ptdb ->  PG 가 ptn1 인자로 들어온 파일을 파싱해 프로토콜 패킷들의 정보를 저장하는 파일 (버전업 관리)
ptprecompiledheader-> 자동 생성되는 파일에 해당하는  프리컴파일드 해더 파일을 #include하는 코드 생성
ptversionfile -> 버전을 주고 받을 프로토콜 파일 이름 지정 (자동 생성되는 파일에 현재의 프로토콜 버전이 자동으로 선언된다)
ptn1-> 파싱될 프로토콜 파일,
단! 클리이언트 -> 서버,  서버 -> 클라이언트 용 따로 분리해야 한다 (섞이면 안됨)
----------------------------------------------------------------------

이걸 사용하면  PT_CA_Data.h PT_AC_Data.h 을 파싱해  자동으로 패킷 처리 함수 파일을 만들어주는것이다

먼저 위 파일을 열어보고 autoPG.bat를 사용해보기전에  
위 파일에 정의 될수있는 키워드 및 타입을 알아보자 

타입은  PgType.h 에 정의 되어 있으며 키워드 역시 #define으로 정의해 놓았다
먼저 핵심적인 키워드는 
PT_INTERFACE 로서  프로토콜 패킷 구조체를 정의 하게 되는것이다

PT_COMMON 키워드는  PT_INTERFACE 와 똑같이 정의되지만
SendPacket 나 RecvPacket 함수로는 만들어지지 않는다 
다만! 이걸로 정의된 구조체는 PT_INTERFACE 내에서 포함용으로 쓰인다
여러 PT_INTERFACE에서 쓰일수 있으므로 공통으로 쓰이는 데이터역활을 
하는것이다 

PT_TYPEDEF 이것은 typedef 와 사실상 같다  다만 이걸로 정의 되려면 
밑에 (int8,int16 ...) 보는것과 같은 기본 타입,컨테이너 타입,
PT_INTERFACE  PT_COMMON  아니면 PT_TYPEDEF 된 다른 타입
으로 정의 될수 있다

중요한것은 PT_TYPEDEF으로 정의 한 데이타 역시 PG 인식하는 타입으로 되며
PT_TYPEDEF 정의가 달라질 경우 프로토콜이 달라지는것으로 간주하게되
프로토콜 버전이 올라가게 된다

다시 말해 위에 3가지 키워드로 정의된 것중 무엇이든  구조체를 스트림에 담을때  스트림이
깨질수 있는 변화를 주면 프로토콜 버전업을 시키게 되는것이다
따라서 PG 사용자는  서버/클라이언트간에  버전을 확인하도록 코드를 작성하여야 한다
(스트림 변화란 ? -> 구조체 내부의 멤버 변수 순서 변화 , 추가, 삭제 , 변형 등등)
 주석이나  PT_INTERFACE 간에 순서는 무시 된다
그리고 위 정의 방법들은  반드시 C++ 로 컴파일 될수 있는 코드로 작성되어야 한다

기본타입: 
int8; uint8;int16; uint16; int32; uint32; int64; uint64; real32; real64; boolean;
WString;String; 
컨테이너타입:
PG_VECTOR;PG_MAP; PG_SET; PG_LIST; PG_DEQUE;
사용자 타입:
PT_INTERFACE : 프로토콜 패킷 struct를  정의한 구조체
PT_COMMON :  struct를 재 정의한  구조체
PT_TYPEDEF :   기본타입 , 컨테이너타입 ,사용자 타입 이름을 줄여서 
다시 정의한 정의한 타입 (typedef 의 역활)

자 이제 PT_AC_Data.h 코드를 살펴 보자

namespace PG
{
PT_INTERFACE AC_CheckVersion
{
boolean bOK;
};

PT_INTERFACE AC_LogIn
{
boolean bOK;
};

PT_INTERFACE AC_CharacterInfoList
{
AC_LogIn Test;
int32    level2;
};

PT_INTERFACE AC_GOGOGO
{
uint32  up;
boolean bOK;
uint32  add;
PG_MAP<String,AC_CharacterInfoList> charInfoMap;
};
}


보는 봐와 같이 기본타입과 컨테이너 타입을 PT_INTERFACE ( 구조체  struct )에 정의 할수 있고  이미 정의되어 있는 PT_INTERFACE , PT_COMMON 자체를 포함 할수도 있다 
또한 PT_INHERIT ( public ) 키워드를 이용해 

PT_INTERFACE AC_LogIn : PT_INHERIT AC_CheckVersion
{
boolean bOK;
};

이런식으로 정의해도 된다( 단일 상속만 지원한다 )

자 이렇게 정의 한 PT_AC_Data.h  PT_CA_Data.h 
autoPG.bat 를 실행해 코드를 생성해 내보자
 
autoPG.bat 실행 후   
PT_AC_Data.inl  PT_CA_Data.inl

gene_send_PT_CA_Data.h
gene_send_PT_CA_Data.cpp
gene_send_PT_AC_Data.h
gene_send_PT_AC_Data.cpp
gene_recv_PT_CA_Data.h
gene_recv_PT_CA_Data.cpp
gene_recv_PT_AC_Data.h
gene_recv_PT_AC_Data.cpp

이런 파일이 생성된다

여기서 inl 파일은  PT_INTERFACE 와 PT_COMMON를 스트림에 담는 코드를 직접 생성한다
예를 들면 밑에와 같은 코드는 
PT_INTERFACE AC_GOGOGO
{
uint32  up;
boolean bOK;
uint32  add;
PG_MAP<String,AC_CharacterInfoList> charInfoMap;
};


이런 함수를 생성하게 되는것이다

template<class Archive>
void serialize(Archive& ar,AC_GOGOGO& ptData)
{
ar & ptData.up;
ar & ptData.bOK;
ar & ptData.add;
ar & ptData.charInfoMap;
}

따라서 위와 같은 함수를  위처럼 한 번만 만들어두면 PT_INTERFACE 혹은PT_COMMON를 포함하는 다른 PT_INTERFACE에서는  위 구조체의 멤버들을 
다시 "ar &  XX" 코드로 만들어 낼 필요가 없다  

예를들어보면 , AC_GOGOGO의 serialize 함수에 
ar & ptData.charInfoMap;
이걸 보면 이미 정의 되어 있는  PT_INTERFACE 인 AC_CharacterInfoList 구조체다
저 구조체는 이미 아래와 같이 정의 되어 있다

template<class Archive>
void serialize(Archive& ar,AC_CharacterInfoList& ptData)
{
ar & ptData.Test;
ar & ptData.level2;
}

이렇게 스트림으로 담는게 정의 되어 있으므로 내부에 PT_INTERCAE  PT_COMMON 이 들어 있든 컴파일하는데 문제가 없게 된다 

따라서 inl 파일에 들어 있는 PT_INTERCAE 와 PT_COMMON은 기본타입처럼 
ar & 로 담으면 컴파일러가 알아서 타입을 찾아서 스트림에 담아주게 되는것이다

대략 스트림에 담는 구조를 보았으니 PT_CA_Data.h에 관련되어 생성된 
gene_send_PT_CA_Data.h
gene_send_PT_CA_Data.cpp
gene_recv_PT_AC_Data.h
gene_recv_PT_AC_Data.cpp

를 봐보자 gene_send_XX  부터 보자면  PT_INTERFACE에서 정의한 것들이 그대로
SendPacket 함수의 인자로 정의된으로 보인다
하지만 PT_COMMON 은 무시되어 있는데  앞단에도 말했듯이
구조체형으로써 여러 다른 PT_INTERFACE에서 상속하거나 포함하기 위한용이기 때문이다

void SendPacket(SOCKET_ID socketID,const CA_CheckVersion& ptData);
void SendPacket(SOCKET_ID socketID,const CA_LogIn& ptData);
void SendPacket(SOCKET_ID socketID,const CA_CharacterInfo& ptData);
void SendPacket(SOCKET_ID socketID,const CA_AGAIN_LOGIN& ptData);
void SendPacket(SOCKET_ID socketID,const CA_GoToLoby& ptData);
extern const uint32 PROTOCAL_VERSION;

별거 없다   PT_CA_Data.h 에 정의 되어 있는 PT_INTERFACE 구조체를 토대로  
SendPacket 함수가 자동 생성했을뿐이다

좀 특이한거로는  PROTOCAL_VERSION 가 있는데 이건 프로토콜의 패킷의 스트림에
변화가 생기면 버전을 올려 , 호환 되지 않게 하기 위한용이다  

전에도 말했듯 PG.exe 에  -ptversionfile:PT_CA_Data.h을 주면 생겨나게 된다
따라서 PT_CA_Data.h   PT_INTERFACE에는 버전을 확인하는 프로토콜 패킷이
있어야 한다

 PT_CA_Data.h  소스를 보면  밑에와 같은 프로토콜을 볼수 있는데  이게 버전을 체크하기 위한 프로토콜이다

PT_INTERFACE CA_CheckVersion
{
uint32 version;
};

버전을 체크해서 버전이 다르면 서로 통신하지 않게 만들어주면 된다 

이렇게 만들어진 SendPacket 함수들은  필요한 곳에서 gene_send_PT_CA_Data.h 파일을 #include 해 패킷을 보내면 된다 

한가지 주의 할 함수가 밑에 있다 
void SendPacket(SOCKET_ID socketID,ptEnumType enumID,const void* pBuffer,uint32 bufferSize);

이함수는 PT_INTERFACE 구조체가 스트림에 담겨지고 난후에 스트림의 버퍼포인터와 크기를 넘겨받아 ,  어플리케이션 단에서 그 버퍼를 처리하다록 하는 역활을 맡는다 
때라서 사용자가 구현해주지 않으면 링크 에러가 날것이다 

이렇게 만든 이유는 
마지막 부분에 설명되어 있는데 ,  어플리케이션 구조나 소켓 구조에 영향을 받는 코드를
제외하는것이  재활용성을 높일수 있기 때문이다
[스트림에 담긴 버퍼를 처리하는 역활은 소켓 구조와 어플리케이션 구조에 영향을 받는다]

------------------------------------------------------------------------
예를 들면 이렇게 구현될것이다
void SendPacket(SOCKET socket,void* pBuffer,uint32 bufSize)  
{
//간단한 XOR 암호
XOR_Encode(pBuffer,bufSize);
// 현재는 간단히 버클리 소켓을 쓴다
send(socket,(char*)pBuffer,bufSize,0);
}
---------------------------------------------------------------------------

이제 gene_send_PT_CA_Data.cpp  에서 자동생성된 코드를 더 보도록 하자  
소스내용을 좀더 살펴보면  밑에 와 같이 자동으로 enum값이 PT_INTERFACE 이름에 맞춰 생성되었으며  PT_INTERFACE 구조체들이 stream에 & 연산자에 의해 담기는것을 확인할수 있다

enum ID_CA_CheckVersion = 6,ID_CA_LogIn = 8,ID_CA_CharacterInfo = 5,ID_CA_AGAIN_LOGIN = 4,ID_CA_GoToLoby = 7,};

void SendPacket(SOCKET_ID socketID,const CA_CheckVersion& ptData)
StreamWriterSelfBuffer stream;
stream & ptData;
SendPacket(socketID, ID_CA_CheckVersion ,stream.GetBuffer() , stream.GetBufferSize() );

enum ID 와 구조체를 스트림에 담는 수고를 덜어주는 코드들이다
stream & ptData;
위코드는 결국  inl 파일에 정의된 serialize 함수가 호출되어 처리된다

그리고 아주 중요한 내용이 있는데 PT_CA_Data.h 같은 프로토콜 파일을 #include로 포함하면 #include 파일 내용까지 처리하게 된다

이제 다음으로 넘어가 보자 

@보내는게 있으면 받는것도 있는법

gene_recv_PT_AC_Data.h
gene_recv_PT_AC_Data.cpp

에서 먼저 헤더 파일을보면   
GetFuncMap_gene_recv_PT_AC_Data 함수가 보인다
[만약 PT_CA_Data.h 였으면 extern const uint32 PROTOCAL_VERSION; 코드 역시
생겼을것이다]

SendPacket쪽과 다르게 여긴 달랑 이함수만 있으니 조금 이해가 안갈거 같다
하지만 보내는쪽과 다르게 받는쪽은  컴파일 타임때 프로토콜 타입을 알수가 없다
일단 소켓의 버퍼를 받아 파싱해봐야 알수 있는것이다

따라서 버퍼를 받아 구조체로 변환해 처리할수 있는 함수들은
gene_recv_PT_AC_Data.cpp에 구현 되어 있으며 

이 함수들은 함수 포인터로 가져와 처리할수 있다

GetFuncMap_gene_recv_PT_AC_Data 을 보면 알겠지만

typedef void (*funcRecvPacket)(SOCKET_ID,PG::int8*,PG::uint32);
typedef PG_MAP<PG::uint32,funcRecvPacket> RecvPacketMap;

스트림 버퍼와 포인터를 가지고 구조체로 변환해주는   함수 포인터를 Map(RecvPacketMap)에 담아서 리턴해준다 

간단한 예로 보자면 밑에와 비슷할것이다 , 

(간단히 버클리 소켓으로 처리 했으니 딴지 금지...)
---------------------------------------------------------------
// 패킷처리를 담당하는 함수포인터 맵
RecvPacketMap g_funcRecvMap;

GetFuncMap_gene_recv_PT_CA_Data(g_funcRecvMap);


uint32 totalSize;
ptEnumType enumID;
int recvSize = recv(g_clientSocket,(char*)&totalSize,HEADER_SIZE,0);
if(recvSize <= 0 )
break;

PG_ASSERT(recvSize == HEADER_SIZE);
recvSize = recv(g_clientSocket,(char*)&enumID,PT_ENUM_TYPE_SIZE,0);
if(recvSize <= 0)
break;

PG_ASSERT(recvSize == PT_ENUM_TYPE_SIZE);

char recvBuf[MAX_PACKETLEN];
uint32 ptSize = totalSize - PT_ENUM_TYPE_SIZE;
recvSize = recv(g_clientSocket,recvBuf,ptSize,0);
if(recvSize <= 0)
break;

PG_ASSERT(recvSize == ptSize);

//////////////////////////////////////////////////////////////////////////
// 소켓에서 얻어온 버퍼를 GetFuncMap_gene_recv_PT_CA_Data에서 얻어온 Map의 함수 포인터로
// 넘겨주면 자동으로 위에서 gene_recv_PT_AC_Data.cpp 에서 구현되어진
// ProcessPacket함수들이 호출된다
try
{
// check enumID valid
RecvPacketMap::iterator it = g_funcRecvMap.find(enumID);
if( it != g_funcRecvMap.end())
it->second(g_clientSocket,recvBuf,ptSize);
}
catch(StreamExceptionUnderflow* )
{
// 패킷 Stream Underflow
}
---------------------------------------------------------------------

대략 이해가 갔을것이라 생각된다

enumID를 키로 주면 Map에 저장된, 함수포인터를 선택하게 되고 
그 함수 포인터에 버퍼 포인터와 버퍼 크기를 넘기게 되면
gene_recv_PT_AC_Data.cpp 에서 정의한 

void RecvPacketCA_CheckVersion(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
void RecvPacketCA_LogIn(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
void RecvPacketCA_CharacterInfo(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
...

함수들이 호출된다

------------------------------------------------------------------------------
예)
void RecvPacketAC_CheckVersion(SOCKET_ID socketID,int8* pBuffer,uint32 bufferSize)
AC_CheckVersion ptData;
StreamReaderSelfBuffer stream(pBuffer,bufferSize);
stream & ptData;
ProcessPacket(socketID,ptData);
------------------------------------------------------------------------------

그리고 그 함수들은 스트림을 구조체로 바꾼뒤에  밑에와 같은 함수를 다시 호출하게 된다

void ProcessPacket(SOCKET_ID socketID, const CA_CheckVersion& ptData);
void ProcessPacket(SOCKET_ID socketID, const CA_LogIn& ptData);
void ProcessPacket(SOCKET_ID socketID, const CA_CharacterInfo& ptData);
...

다만 위 함수들은 선언만 되어 있고 구현은 없다. 
이유는 SendPacket 함수를 어플리케이션 단에서 구현했을때와 같다
PT_INTERFACE로 정의 된 구조체로  어플리케이션단에서 무엇을 할지 
알수 없으므로 어플리케이션단에서 구현해줘야 하는것이다 
따라서 저 함수 구현이 없으면 링크에러가 날것이다
설명은 http://mastercho.tistory.com/에서 많이 하였다

이제 예제에서 내용을 확인해보자
example 폴더에
testWinServer 폴더와 testWinClient가 보일텐데
폴더에 들어가  솔루션 파일(sln) 열어보자

보면 여태 말한 내용이 다 들어가 있고 테스트 해볼수 있다
좀 귀찬아서 대충 처리한 프로토콜 패킷도 있으니 이해심을 가지고 소스를 봐보자

그리고 난뒤에 PT_CA_Data.h , PT_AC_Data.h 에 여러가지 
변형 PT_INTERFACE를 만들어보고   상속과 포함을 사용해보도록 하자 
그후 autoPG.bat를 실행하면  gene_XX_ 파일이 생성될텐데
원하는데로 나왔는지 확인해보고  테스트 프로그램들을  실행해 보면 된다
 
다만 컴파일 할때 boost 를 꼭 포함해야 한다
( boost의 컴파일된 라이브러리는 예제에서는 필요 없다 )
만약 그 라이브러기가 없다면  boost.org 에서 다운받아
추가 포함디렉토리로  설정하기 바란다
현재는 ../../../boost_1_42/ 익으로 걸려 있는데  유저의 라이브러 위치에 맞게 재수정하길 바란다

반응형