서버와 클라이언트가 패킷통신을 할 때 패킷은 결국 Byte스트림으로 전송되는데 요청/응답 패킷에는 데이터가 있을 수 있다. 그렇다면 데이터를 패킷으로 변환하고 패킷을 데이터로 변환할 필요가 있는데 데이터를 패킷을 변환하는 것을 직렬화(Serialization)이라 하고 패킷을 데이터로 변환하는 것을 역직렬화(Deserialization)이라 한다.
패킷이 기본적으로 가지고 있어야 하는 정보와 인터페이스를 상위클래스로 묶어 나타내기 위해 Packet Class
를 정의할 필요가 있다. 기본적인 정보는 패킷의 전체 크기와 패킷식별을 위한 패킷ID로 이를 정의한 코드는 아래와 같다.
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
public enum PacketID
{
PlayerInfoReq = 1,
PlayerInfoOK = 2,
}
Receive받은 ByteStream을 Data로 바꾸거나 Data를 ByteStream으로 바꾸는 인터페이스는 각각 Read
, Write
로 정의하였다. Read
는 매개변수로 받은 ByteStream을 파싱해 Packet Class의 변수로 입력하는 과정이고 Write
는 현재 가지고 있는 변수를 바탕으로 ByteStream을 생성하는 과정이다.
위의 코드에서 ushort를 사용하였는데 ushort와 int형의 비트차이만큼 패킷을 보낼때 차이만큼 덜 전송하게 될 것이다. 이러한 전송횟수가 많아지면 많아질수록 자료형으로 인한 이득이 더더욱 크다.
직렬화(역직렬화)할 데이터로는 다음과 같은 데이터가 있다.
예제 코드는 클라이언트가 서버에 사용자의 정보를 요청하는 패킷을 전송하는 상황을 가정하였다. 아래의 코드는 구현전의 PlayerInfoReq class의 구조를 나타낸 코드이다.
class PlayerInfoReq : Packet
{
public long playerId;
public string name;
public struct SkillInfo
{
public int id;
public short level;
public float duration;
}
public List<SkillInfo> skills = new List<SkillInfo>();
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override void Read(ArraySegment<byte> segment)
{
ushort count = 0;
ReadOnlySpan<byte> s = new ReadOnlySpan<byte>(segment.Array, segment.Offset, segment.Count);
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> segment = SendBufferHelper.Open(4096);
ushort count = 0;
bool success = true;
Span<byte> s = new Span<byte>(segment.Array, segment.Offset, segment.Count);
}
}
직렬화(역직렬화)과정에 사용되는
count
변수는 ByteStream의 Cursor의 역할을 한다.
데이터를 패킷으로 바꾸는 직렬화과정을 Pack Class의 Write
함수를 통해 구현한다. Write
함수의 반환값의 구조는 미리 정의된 패킷의 구조를 만족하는 값이 되어야 한다.
[Packet Size(2 Byte)] [Packet ID(2 Byte)] [Data0...] ...
Packet ID
에 해당하는 영역은 부모클래스인 Packet class에 선언되어 있으며 Packet의 유형은 정해져 있기 때문에 생성자에서 packetId
를 설정한다.
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
PlayerInfoReq
의 고정길이변수는 size
, packetId
, playerId
가 있다. packetId
와 playerId
는 해당하는 값을 Byte로 변환하면 되지만 size
와 같은 경우는 전체 Byte를 탐색한 뒤 마지막에 count
값을 이용해 값을 설정, 변환하는 과정을 거친다.
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), this.packetId);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), this.playerId);
count += sizeof(long);
위 코드는 아래의 단계를 거치며 packetId
, playerId
에 대한 값을 ByteStream으로 변환한다.
size
에 대한 영역이기 때문에 size
의 자료형인 ushort
만큼 커서를 이동한다.packetId
에 대한 영역이므로 BitConverter를 이용해 반환값에 packetId에 대한 정보를 입력한다.packetId
의 자료형인 ushort
만큼 증가시킨다.playerId
에 대해서 반복한다.BitConverter.TryWriteBytes
는 ~에 ~값을 Byte형식으로 입력하는 함수로 s.Slice()
를 통해 반환값(배열)의 부분배열을 매개변수로 넘겨준다.
Length와 count의 관계는 아래그림과 같다. count는 다음 데이터가 들어올자리를 가리키기 때문에 [0, count)
에 해당하는 영역은 이미 데이터가 있다고 할 수 있다. 이러한 이유로 Length - count
는 데이터가 입력되지 않은 영역의 크기를 나타낸다.
가변길의변수의 경우 패킷을 받는 입장에서 어디까지 가변길이변수에 해당하는 영역인지 알아야할 필요가 있다. 이를위해 가변길이변수의 길이를 데이터 앞에 넣어 받는사람에게 해당하는 영역을 나타내는 구조를 사용한다.
PlayerInfoReq
의 가변길이변수는 name
, skills
가 있다. name
의 경우 변수가 하나이기 때문에 값만 변환하면 되지만 skills
의 경우 List에 속한 변수마다 데이터를 변환해주어야 한다.
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, this.name.Length, segment.Array, segment.Offset + count + sizeof(ushort));
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), (ushort)skills.Count);
count += sizeof(ushort);
foreach (SkillInfo skill in skills)
success &= skill.Write(s, ref count);
먼저 name
의 길이를 알아내고 반환값에 name
의 변환값을 같이하기위해 Encoding.Unicode.GetBytes
를 사용한다. 마지막 매개변수에 현재 Cursor를 나타내는 Offset+count에 sizeof(ushort)
를 더하는데 이것은 처음 2Byte는 name
의 크기를 넣기위한 자리를 마련해주기 위함이다. +2
에 대한 내용은 아래그림에 나타나있다.
skills
도 name
과 비슷하다. 데이터를 넣기 전 element들의 개수를 앞에 표시하고 List의 내용을 반환값에 넣는다. List는 foreach문으로 각각의 변수에 대한 ByteStream을 대입하는 방식을 사용한다.
struct, class와 같은 사용자정의변수는 Read
와 Write
에 대한 연산을 따로 정의해주어야 한다. 이를위해 같은이름의 내부메소드를 정의해 내부변수에 대한 직렬화(역직렬화)를 진행하도록 한다.
public struct SkillInfo
{
public int id;
public short level;
public float duration;
public bool Write(Span<byte> s, ref ushort count)
{
}
public void Read(ReadOnlySpan<byte> s, ref ushort count)
{
}
}
SkillInfo
의 경우 id
, level
, duration
에 대한 Write
를 구현하면 된다. 매개변수로는 데이터를 넣을 부분배열과 cursor에 해당하는 count를 참조형으로 받아 내부메소드에서의 변경이 유지되도록 한다.
bool success = true;
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), id);
count += sizeof(int);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), level);
count += sizeof(short);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), duration);
count += sizeof(float);
return success;
고정길이 변수와 마찬가지로 id
, level
, duration
에 대한 데이터입력을 진행한다. 반환값으로는 Write
가 잘되었는지 PlayerInfoReq
에서 확인하기 위해 bool로 적용여부를 반환한다.
역직렬화는 패킷을 데이터로 변환하는 과정을 구현하면되는데 위의 직렬화과정을 반대로 진행하면 된다. 주의해야할 점은 직렬화 과정에서 BitConverter.TryWriteBytes
를 이용해 ByteStream을 입력했다면 역직렬화 과정에서는 BitConverter.ToInt64
를 이용해 ByteStream을 데이터로 변환해야 한다. 뒤의 64
, 32
, 16
은 자료형의 비트수를 의미하며 Int외에도 Double, Single(float)에 대한 함수도 존재한다.
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(s.Slice(count, s.Length - count));
count += sizeof(long);
처음 4Byte는 패킷 길이와 ID를 나타내기 때문에 count를 4
증가시킨다. 이후에 오는 8Byte는 playerId
에 대한 데이터이므로 BitConverter.ToInt64
를 통해 변수를 입력한다. 이후 count를 증가시킨다.
ushort nameLen = BitConverter.ToUInt16(s.Slice(count, s.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(s.Slice(count, nameLen));
count += nameLen;
ushort skillLen = BitConverter.ToUInt16(s.Slice(count, s.Length - count));
count += sizeof(ushort);
skills.Clear();
for (int i = 0; i < skillLen; i++)
{
SkillInfo skill = new SkillInfo();
skill.Read(s, ref count);
skills.Add(skill);
}
name
과 skills
에 대한 데이터를 넣기 전에 처음 2Byte는 가변길이변수의 길이를 의미하기 때문에 앞 2Byte로 길이를 구해 구한 길이만큼의 문자열과 SkillInfo
에 대한 데이터를 입력한다.
id = BitConverter.ToInt32(s.Slice(count, s.Length - count));
count += sizeof(int);
level = BitConverter.ToInt16(s.Slice(count, s.Length - count));
count += sizeof(short);
duration = BitConverter.ToSingle(s.Slice(count, s.Length - count));
count += sizeof(float);
SkillInfo
의 Write
와 마찬가지로 Read
는 매개변수로 데이터를 넣을 부분배열과 cursor에 해당하는 count를 참조형으로 받아 내부메소드에서의 변경이 유지되도록 한다.
받은 패킷의 내용은 id
, level
, duration
순서로 입력되어있음을 알기 때문에 같은순서와 해당 자료형에 맞는 함수로 데이터를 입력한다.
public abstract class Packet
{
public ushort size;
public ushort packetId;
public abstract ArraySegment<byte> Write();
public abstract void Read(ArraySegment<byte> s);
}
class PlayerInfoReq : Packet
{
public long playerId;
public string name;
public struct SkillInfo
{
public int id;
public short level;
public float duration;
public bool Write(Span<byte> s, ref ushort count)
{
bool success = true;
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), id);
count += sizeof(int);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), level);
count += sizeof(short);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), duration);
count += sizeof(float);
return success;
}
public void Read(ReadOnlySpan<byte> s,ref ushort count)
{
id = BitConverter.ToInt32(s.Slice(count, s.Length - count));
count += sizeof(int);
level = BitConverter.ToInt16(s.Slice(count, s.Length - count));
count += sizeof(short);
duration = BitConverter.ToSingle(s.Slice(count, s.Length - count));
count += sizeof(float);
}
}
public List<SkillInfo> skills = new List<SkillInfo>();
public PlayerInfoReq()
{
this.packetId = (ushort)PacketID.PlayerInfoReq;
}
public override void Read(ArraySegment<byte> segment)
{
ushort count = 0;
ReadOnlySpan<byte> s = new ReadOnlySpan<byte>(segment.Array, segment.Offset, segment.Count);
count += sizeof(ushort);
count += sizeof(ushort);
this.playerId = BitConverter.ToInt64(s.Slice(count, s.Length - count));
count += sizeof(long);
//string
ushort nameLen = BitConverter.ToUInt16(s.Slice(count, s.Length - count));
count += sizeof(ushort);
this.name = Encoding.Unicode.GetString(s.Slice(count, nameLen));
count += nameLen;
//skill list
ushort skillLen = BitConverter.ToUInt16(s.Slice(count, s.Length - count));
count += sizeof(ushort);
skills.Clear();
for (int i = 0; i < skillLen; i++)
{
SkillInfo skill = new SkillInfo();
skill.Read(s, ref count);
skills.Add(skill);
}
}
public override ArraySegment<byte> Write()
{
ArraySegment<byte> segment = SendBufferHelper.Open(4096);
ushort count = 0;
bool success = true;
Span<byte> s = new Span<byte>(segment.Array, segment.Offset, segment.Count);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), this.packetId);
count += sizeof(ushort);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), this.playerId);
count += sizeof(long);
// string len [2]
// byte []
ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, this.name.Length, segment.Array, segment.Offset + count + sizeof(ushort));
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
count += sizeof(ushort);
count += nameLen;
// skill list
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), (ushort)skills.Count);
count += sizeof(ushort);
foreach(SkillInfo skill in skills)
success &= skill.Write(s, ref count);
success &= BitConverter.TryWriteBytes(s, count);
if (success == false)
return null;
return SendBufferHelper.Close(count);
}
}