[C# 서버] 소켓 프로그래밍 - Serialization

이정석·2023년 8월 20일
0

CSharpServer

목록 보기
10/13

Packet

서버와 클라이언트가 패킷통신을 할 때 패킷은 결국 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형의 비트차이만큼 패킷을 보낼때 차이만큼 덜 전송하게 될 것이다. 이러한 전송횟수가 많아지면 많아질수록 자료형으로 인한 이득이 더더욱 크다.

직렬화(역직렬화)할 데이터로는 다음과 같은 데이터가 있다.

  1. int, short와 같은 고정길이변수
  2. string, List와 같은 가변길이변수, 자료구조
  3. struct, class와 같은 사용자정의 변수

예제 코드는 클라이언트가 서버에 사용자의 정보를 요청하는 패킷을 전송하는 상황을 가정하였다. 아래의 코드는 구현전의 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의 역할을 한다.


Serialization

데이터를 패킷으로 바꾸는 직렬화과정을 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;
    }

1. 고정길이변수

PlayerInfoReq의 고정길이변수는 size, packetId, playerId가 있다. packetIdplayerId는 해당하는 값을 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으로 변환한다.

  1. 처음 count가 가리키는 영역은 size에 대한 영역이기 때문에 size의 자료형인 ushort만큼 커서를 이동한다.
  2. count가 가리키는 영역은 packetId에 대한 영역이므로 BitConverter를 이용해 반환값에 packetId에 대한 정보를 입력한다.
  3. count를 packetId의 자료형인 ushort만큼 증가시킨다.
  4. 위 과정을 playerId에 대해서 반복한다.

BitConverter.TryWriteBytes는 ~에 ~값을 Byte형식으로 입력하는 함수로 s.Slice()를 통해 반환값(배열)의 부분배열을 매개변수로 넘겨준다.

Length와 count의 관계는 아래그림과 같다. count는 다음 데이터가 들어올자리를 가리키기 때문에 [0, count)에 해당하는 영역은 이미 데이터가 있다고 할 수 있다. 이러한 이유로 Length - count는 데이터가 입력되지 않은 영역의 크기를 나타낸다.

2. 가변길이변수

가변길의변수의 경우 패킷을 받는 입장에서 어디까지 가변길이변수에 해당하는 영역인지 알아야할 필요가 있다. 이를위해 가변길이변수의 길이를 데이터 앞에 넣어 받는사람에게 해당하는 영역을 나타내는 구조를 사용한다.

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에 대한 내용은 아래그림에 나타나있다.

skillsname과 비슷하다. 데이터를 넣기 전 element들의 개수를 앞에 표시하고 List의 내용을 반환값에 넣는다. List는 foreach문으로 각각의 변수에 대한 ByteStream을 대입하는 방식을 사용한다.

3. 사용자정의변수

struct, class와 같은 사용자정의변수는 ReadWrite에 대한 연산을 따로 정의해주어야 한다. 이를위해 같은이름의 내부메소드를 정의해 내부변수에 대한 직렬화(역직렬화)를 진행하도록 한다.

    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로 적용여부를 반환한다.


Deserialization

역직렬화는 패킷을 데이터로 변환하는 과정을 구현하면되는데 위의 직렬화과정을 반대로 진행하면 된다. 주의해야할 점은 직렬화 과정에서 BitConverter.TryWriteBytes를 이용해 ByteStream을 입력했다면 역직렬화 과정에서는 BitConverter.ToInt64를 이용해 ByteStream을 데이터로 변환해야 한다. 뒤의 64, 32, 16은 자료형의 비트수를 의미하며 Int외에도 Double, Single(float)에 대한 함수도 존재한다.

1. 고정길이변수

    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를 증가시킨다.

2. 가변길이변수

    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);
    }

nameskills에 대한 데이터를 넣기 전에 처음 2Byte는 가변길이변수의 길이를 의미하기 때문에 앞 2Byte로 길이를 구해 구한 길이만큼의 문자열과 SkillInfo에 대한 데이터를 입력한다.

3. 사용자정의변수

    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);

SkillInfoWrite와 마찬가지로 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);
        }
    }
profile
게임 개발자가 되고 싶은 한 소?년

0개의 댓글