누군가와 실시간으로 채팅을 하거나, 게임을 하기 위해서는 데이터를 서로 주고 받아야 한다.
주로 tcp나 udp 프로토콜을 이용해 네트워크 라이브러리로 데이터를 주고 받는다.
여기서는 tcp 통신으로 서버와 클라이언트간 메시지를 주고 받는 간단한 서버를 만들어 보고자 한다.
1. 패킷 구성
패킷은 서버와 클라이언트간 약속된 메시지 구성이다. 크게 [헤더 + 패킷 타입 + 데이터]로 구성 돼 있다. 여기서 헤더는 데이트 byte배열의 크기이고, 데이터는 어떤 객체를 c#의 마샬링을 이용해 직렬화 해 만들 것이다.
public class jdPacket
{
public Int16 m_type { get; set; }
public byte[] m_data
{
get; set;
}
public jdPacket()
{
}
public void SetData(byte[] data, int len)
{
m_data = new byte[len];
Array.Copy(data, m_data, len);
}
public byte[] GetSendBytes()
{
byte[] type_bytes = BitConverter.GetBytes(m_type);
int header_size = (int)(m_data.Length);
byte[] header_bytes = BitConverter.GetBytes(header_size);
byte[] send_bytes = new byte[header_bytes.Length + type_bytes.Length + m_data.Length];
//헤더 복사. 헤더 == 데이터의 크기
Array.Copy(header_bytes, 0, send_bytes, 0 , header_bytes.Length);
//타입 복사
Array.Copy(type_bytes, 0, send_bytes, header_bytes.Length , type_bytes.Length);
//데이터 복사
Array.Copy(m_data, 0, send_bytes, header_bytes.Length + type_bytes.Length , m_data.Length);
return send_bytes;
}
}
헤더 즉 데이터의 크기가 필요한 이유는, 소켓으로 데이터를 전송하면, 내부적으로 보통 4K크기로 잘라 데이터를 전송한다. 이때 헤더는 데이터가 전부 전송 됐는지 확인하기 위해 필요하다.
2. 데이터 직렬화
데이터를 byte배열로 만드는 방법을 여러 가지가 있다. 보통 BinaryFormatter, BinaryReader/BinaryWriter, Marsharing(마샤링)을 이용하는 방법이 있는데, 여기서는 마샤링을 이용했다. 그 이유는 데이터를 byte배열로 변환하는 성능과 간편화 때문인데, 자세한 내용을 아래 링크를 참고 하면 좋다. 결론부터 말하면, 마샤링이 제일 편하면서도, 성능은 최상급이다.
https://www.genericgamedev.com/general/converting-between-structs-and-byte-arrays/
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack=1)]
public class jdData<T> where T : class
{
public jdData()
{
}
public byte[] Serialize()
{
var size = Marshal.SizeOf(typeof(T));
var array = new byte[size];
var ptr = Marshal.AllocHGlobal(size);
Marshal.StructureToPtr(this, ptr, true);
Marshal.Copy(ptr, array, 0, size);
Marshal.FreeHGlobal(ptr);
return array;
}
public static T Deserialize(byte[] array)
{
var size = Marshal.SizeOf(typeof(T));
var ptr = Marshal.AllocHGlobal(size);
Marshal.Copy(array, 0, ptr, size);
var s = (T)Marshal.PtrToStructure(ptr, typeof(T));
Marshal.FreeHGlobal(ptr);
return s;
}
}
[Serializable], [StructLayout(LayoutKind.Sequential, Pack=1)]는 마샤링을 위해 붙여야 하는 주석이다. Pack=1은 1byte단위로 데이터의 크기를 맞춘다는 뜻이다. Serialize() 메소드는 객체를 Byte배열로 만드는 것이고, Deserialize()메소드는 Byte배열을 객체로 복구 할 때 쓰인다.
jdData<T> where T를 보면 알겠지만, c# 템플렛을 이용해 아래같이 범용으로 쓰이도록 만들었다.
//클라이언트 요청 패킷 예시
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public class TestPacketReq : jdData<TestPacketReq>
{
public long m_test_long_data;
public TestStructData m_test_data;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)]
public TestStructData[] m_test_data_array = new TestStructData[10];
public TestPacketReq()
{
}
}
//서버 응답 패킷 예시
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public class TestPacketRes : jdData<TestPacketRes>
{
public bool m_is_sucess;
public int m_test_int_value;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = jdCommonDefine.MAX_PACKET_STRING_LENGTH)]
public string m_message;
public TestPacketRes()
{
}
}
TestPacketReq와 TestPacketRes 모두 jdData<T>상속 받는다 . 즉 Serialize()와 Deserialize()함수로 직렬화/역직렬화 할 수 있는 클래스 들이다.
여기서 주의해야 할 점은, 이 클래스 내부에 있는 멤버들은 직렬화 가능한 타입만 있을 수 있다. 즉 원시타입(int, long, float 등)과 직렬화 가능한 구조체 ( [Serializable] 가 붙은)로 구성된 객체들 뿐이다.
또한 문자열이나 구조체 배열을 전송하고 싶은 경우,
string(문자열)은 [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 문자열길이)]
구조체 배열을 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 배열크기)]
로 크기를 명시해 줘야 한다. 이렇게 해야 컴파일러가 객체를 직렬화/역직렬화 할 때, 그 크기를 계산 할 수 있어서이다. CharSet=CharSet.Unicode 는 문자열을 전송하는 경우, 그 인코딩 타입을 명시 해주는 것이다. 이것을 안해주면, 클라와 서버의 기본 인코딩이 다른 경우, 문자열이 깨질 수 있다. 왠만하면 유니코드로 맞춰주자.
위의 TestStructData예시는 아래와 같다.
[Serializable]
public enum TestType
{
TEST_TYPE_NONE = -1,
TEST_TYPE_1,
TEST_TYPE_2,
TEST_TYPE_3,
TEST_TYPE_COUNT
}
[Serializable]
[StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)]
public struct TestStructData
{
public TestType m_test_enum;
public long m_long;
public float m_float;
public bool m_bool;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 20)]
public string m_name;
public TestStructData(long v_long,
string v_name,
float v_float,
bool is_bool,
TestType v_test_enum)
{
m_long = instance_id;
m_name = name;
m_float = v_float;
m_bool = is_bool;
m_test_enum = v_test_enum;
}
}
위의 구조체를 보면 알겠지만, 패킷과 마찬가지로 [Serializable]가 붙어있고, 내부도 모두 직렬화 가능한 멤버들 뿐이다. 즉 컴파일러가 크기를 계산 할 수 있어야 한다는 점을 명시하자. enum타입 역시 [Serializable]를 붙여주도록 하자.
지금까지 서버로 전송할 패킷의 구조를 살펴보았다. 다음 포스팅에는 클라이언트에서 서버로 전송하는 부분을 알아볼 예정이다.
2019/12/19 - [게임을 만들자/C# 서버] - c# 실시간 게임 서버 만들기 2 - 클라이언트
'게임을 만들자 > 게임 서버(C#)' 카테고리의 다른 글
파이썬으로 구글 스프레드시트 다운받고, Json으로 변경하기 (0) | 2019.12.28 |
---|---|
c# 실시간 게임 서버 만들기 3 - 서버 (1) | 2019.12.22 |
c# 실시간 게임 서버 만들기 2 - 클라이언트 (2) | 2019.12.19 |
c# 서버 로그 log4net 사용방법 (1) | 2019.12.13 |
c# iBatis 세팅 및 예시 (0) | 2019.12.11 |