소스 생성기를 이용한 패킷 정의 및 직렬화 / 역직렬화 - slog

@이광석 님의 패킷 라이브러리에서

소스 생성기를 이용한 패킷 직렬화 / 역직렬화 관련 기여를 고민하던 차에

제가 제안한 내용이 지지가 없는 것이 아쉬워

내용 증명의 목적으로 코딩을 해보고 있습니다.

중요 컨셉은 Schema를 다음과 같이 정의하면

[GenPackable]
public partial class TestPacket
{
    public readonly static PacketSchema Schema = PacketSchemaBuilder.Create(UnitEndian.Little, StringEncoding.ASCII)
        .@byte(name: "Command", description: "명령어")
        .BeginChecksum()
        .@byte("Value1")
        .@short("Value2")
        .@string("Value3", "a", 5)
        .EndChecksum()
        .@checkum(ChecksumType.Sum8)
        .Build();
}

다음의 코드가 생성됩니다.

    public partial class TestPacket : GenPack.IGenPackable
    {
        /// <summary>
        /// 명령어
        /// </summary>
        public byte Command { get; set; }
        public byte Value1 { get; set; }
        public short Value2 { get; set; }
        /// <summary>
        /// a
        /// </summary>
        public string Value3 { get; set; }
    }

현재 소스 생성기를 이용해 위의 코드까지 생성하는 것을 해봤고 나아가 다음의 직렬화/역직렬화 메서드를 자동 생성하는 것입니다.

    public partial class TestPacket : GenPack.IGenPackable
    {
    ....
        public static TestPacket FromPacket(Stream s)
        {
            ....
        }

        public void ToPacket(Stream s)
        {
            ....
        }
    }

소스 생성기의 장점은 단순한 규칙의 변경이 컴파일 타임 때 소스코드에 적용되어 컴파일 되는 점 같습니다. (그런데… 소스 생성기의 Context를 다루는 것은 생각보다 많이 힘드네요)

본 슬로그는 PacketByte Support에 소스 생성기를 이용한 직렬/역직렬화 기능을 PR 하는 것을 목표로 진행하려고 합니다.

13개의 좋아요

기다리고있었습니다
언제 pr을 주실지 ….

5개의 좋아요

@object, @list, @dict를 추가했습니다. 이제 다음의 스키마는 다음의 패킷(클래스)을 생성합니다.

[GenPackable]
public partial class TestPacket
{
    public readonly static PacketSchema Schema = PacketSchemaBuilder.Create(UnitEndian.Little, StringEncoding.ASCII)
        .@object<Test2Packet>("Test2Packet", "개체 포함")
        .@list<Test2Packet>("Test2PacketList", "개체 리스트")
        .@dict<Test2Packet>("Test2PacketDict", "개체 딕셔너리")
        .@byte(name: "Command", description: "명령어")
        .BeginPointChecksum()
        .@byte("Value1")
        .@short("Value2")
        .@string("Value3", "a", 5)
        .EndPointChecksum()
        .@checkum(ChecksumType.Sum8)
        .Build();
}

| 생성

    public partial class TestPacket : GenPack.IGenPackable
    {
        /// <summary>
        /// 개체 포함
        /// </summary>
        public GenPack.Test.Console.Test2Packet Test2Packet { get; set; }
        /// <summary>
        /// 개체 리스트
        /// </summary>
        public System.Collections.Generic.IList<GenPack.Test.Console.Test2Packet> Test2PacketList { get; set; } = new List<GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 개체 딕셔너리
        /// </summary>
        public System.Collections.Generic.IDictionary<string, GenPack.Test.Console.Test2Packet> Test2PacketDict { get; set; } = new Dictionary<string, GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 명령어
        /// </summary>
        public byte Command { get; set; }
        public byte Value1 { get; set; }
        public short Value2 { get; set; }
        /// <summary>
        /// a
        /// </summary>
        public string Value3 { get; set; } = string.Empty;
    }
1개의 좋아요

ToPacket()을 자동 생성하는 코드를 구현하였습니다. (아직 검증은 하지 못했습니다.)
워낙 소스 생성기의 경험이 부족해서 동작성을 확보 후 올바른 방향으로 코드를 조금씩 수정해야 할 것 같습니다.

[GenPackable]
public partial class TestPacket
{
    public readonly static PacketSchema Schema = PacketSchemaBuilder.Create(UnitEndian.Little, StringEncoding.ASCII)
        .@array<byte>("ByteArray", 50, "byte 배열")
        .@array<int>("IntegerArray", 50, "int 배열")
        .@array<Test2Packet>("Test2PacketArray", 5, "개체 배열")
        .@object<Test2Packet>("Test2Packet", "개체 포함")
        .@list<Test2Packet>("Test2PacketList", "개체 리스트")
        .@dict<Test2Packet>("Test2PacketDict", "개체 딕셔너리")
        .@byte(name: "Command", description: "명령어")
        .BeginPointChecksum()
        .@byte("Value1")
        .@short("Value2")
        .@string("Value3", "a", 5)
        .EndPointChecksum()
        .@checkum(ChecksumType.Sum8)
        .Build();
}

| 생성 코드

public partial class TestPacket : GenPack.IGenPackable
    {
        /// <summary>
        /// byte 배열
        /// </summary>
        public byte[] ByteArray { get; } = new byte[50];
        /// <summary>
        /// int 배열
        /// </summary>
        public int[] IntegerArray { get; } = new int[50];
        /// <summary>
        /// 개체 배열
        /// </summary>
        public GenPack.Test.Console.Test2Packet[] Test2PacketArray { get; } = new GenPack.Test.Console.Test2Packet[5];
        /// <summary>
        /// 개체 포함
        /// </summary>
        public GenPack.Test.Console.Test2Packet Test2Packet { get; set; }
        /// <summary>
        /// 개체 리스트
        /// </summary>
        public System.Collections.Generic.IList<GenPack.Test.Console.Test2Packet> Test2PacketList { get; } = new List<GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 개체 딕셔너리
        /// </summary>
        public System.Collections.Generic.IDictionary<string, GenPack.Test.Console.Test2Packet> Test2PacketDict { get; } = new Dictionary<string, GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 명령어
        /// </summary>
        public byte Command { get; set; }
        public byte Value1 { get; set; }
        public short Value2 { get; set; }
        /// <summary>
        /// a
        /// </summary>
        public string Value3 { get; set; } = string.Empty;
        public void ToPacket(System.IO.Stream stream)
        {
            System.IO.BinaryWriter writer = new System.IO.BinaryWriter(stream);
            writer.Write(ByteArray);
            foreach (var item in IntegerArray)
            {
                writer.Write(item);
            }
            foreach (var item in Test2PacketArray)
            {
                item.ToPacket(stream);
            }
            Test2Packet.ToPacket(stream);
            writer.Write(Test2PacketList.Count);
            foreach (var item in Test2PacketList)
            {
                item.ToPacket(stream);
            }
            writer.Write(Test2PacketDict.Count);
            foreach (var item in Test2PacketDict)
            {
                writer.Write(item.Key);
                item.Value.ToPacket(stream);
            }
            writer.Write(Command);
            writer.Write(Value1);
            writer.Write(Value2);
            writer.Write(Value3);
        }
    }

엔디안 및 문자열 인코딩 부분(및 체크섬)은 아직 구현하지 못했는데 BinaryWriterlittle endian만 지원하는군요. 일단 엔디안은 무시하고 모두 구현한 후 마지막으로 구현해야겠습니다.

2개의 좋아요

FromPacket() 메서드를 구현하였습니다. 검증을 위해 xUnit 프로젝트를 추가했고 하나씩 테스트를 하려고 합니다.


[GenPackable]
public partial class TestPacket
{
    public readonly static PacketSchema Schema = PacketSchemaBuilder.Create(UnitEndian.Little, StringEncoding.ASCII)
        .@array<byte>("ByteArray", 50, "byte 배열")
        .@array<int>("IntegerArray", 50, "int 배열")
        .@array<Test2Packet>("Test2PacketArray", 5, "개체 배열")
        .@object<Test2Packet>("Test2Packet", "개체 포함")
        .@list<Test2Packet>("Test2PacketList", "개체 리스트")
        .@dict<Test2Packet>("Test2PacketDict", "개체 딕셔너리")
        .@byte(name: "Command", description: "명령어")
        .BeginPointChecksum()
        .@byte("Value1")
        .@short("Value2")
        .@string("Value3", "a", 5)
        .EndPointChecksum()
        .@checkum(ChecksumType.Sum8)
        .Build();
}

| 생성

public partial class TestPacket : GenPack.IGenPackable
    {
        /// <summary>
        /// byte 배열
        /// </summary>
        public byte[] ByteArray { get; } = new byte[50];
        /// <summary>
        /// int 배열
        /// </summary>
        public int[] IntegerArray { get; } = new int[50];
        /// <summary>
        /// 개체 배열
        /// </summary>
        public GenPack.Test.Console.Test2Packet[] Test2PacketArray { get; } = new GenPack.Test.Console.Test2Packet[5];
        /// <summary>
        /// 개체 포함
        /// </summary>
        public GenPack.Test.Console.Test2Packet Test2Packet { get; set; }
        /// <summary>
        /// 개체 리스트
        /// </summary>
        public System.Collections.Generic.IList<GenPack.Test.Console.Test2Packet> Test2PacketList { get; } = new List<GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 개체 딕셔너리
        /// </summary>
        public System.Collections.Generic.IDictionary<string, GenPack.Test.Console.Test2Packet> Test2PacketDict { get; } = new Dictionary<string, GenPack.Test.Console.Test2Packet>();
        /// <summary>
        /// 명령어
        /// </summary>
        public byte Command { get; set; }
        public byte Value1 { get; set; }
        public short Value2 { get; set; }
        /// <summary>
        /// a
        /// </summary>
        public string Value3 { get; set; } = string.Empty;
        public byte[] ToPacket()
        {
            using var ms = new System.IO.MemoryStream();
            ToPacket(ms);
            return ms.ToArray();
        }
        public void ToPacket(System.IO.Stream stream)
        {
            System.IO.BinaryWriter writer = new System.IO.BinaryWriter(stream);
            writer.Write(ByteArray);
            foreach (var item in IntegerArray)
            {
                writer.Write(item);
            }
            foreach (var item in Test2PacketArray)
            {
                item.ToPacket(stream);
            }
            Test2Packet.ToPacket(stream);
            writer.Write(Test2PacketList.Count);
            foreach (var item in Test2PacketList)
            {
                item.ToPacket(stream);
            }
            writer.Write(Test2PacketDict.Count);
            foreach (var item in Test2PacketDict)
            {
                writer.Write(item.Key);
                item.Value.ToPacket(stream);
            }
            writer.Write(Command);
            writer.Write(Value1);
            writer.Write(Value2);
            writer.Write(Value3);
        }
        public static TestPacket FromPacket(byte[] data)
        {
            using var ms = new System.IO.MemoryStream(data);
            return FromPacket(ms);
        }
        public static TestPacket FromPacket(System.IO.Stream stream)
        {
            TestPacket result = new TestPacket();
            System.IO.BinaryReader reader = new System.IO.BinaryReader(stream);
            int size = 0;
            byte[] buffer = null;
            buffer = reader.ReadBytes(50);
            Array.Copy(buffer, result.ByteArray, 50);
            size = reader.ReadInt32();
            for (var i = 0; i < size; i++)
            {
                result.IntegerArray[i] = reader.ReadInt32();
            }
            size = reader.ReadInt32();
            for (var i = 0; i < size; i++)
            {
                result.Test2PacketArray[i] = GenPack.Test.Console.Test2Packet.FromPacket(stream);
            }
            result.Test2Packet = GenPack.Test.Console.Test2Packet.FromPacket(stream);
            size = reader.ReadInt32();
            for (var i = 0; i < size; i++)
            {
                result.Test2PacketList.Add(GenPack.Test.Console.Test2Packet.FromPacket(stream));
            }
            size = reader.ReadInt32();
            for (var i = 0; i < size; i++)
            {
                result.Test2PacketDict[reader.ReadString()] = GenPack.Test.Console.Test2Packet.FromPacket(stream);
            }
            result.Command = reader.ReadByte();
            result.Value1 = reader.ReadByte();
            result.Value2 = reader.ReadInt16();
            result.Value3 = reader.ReadString();
            return result;
        }
    }

BinaryReader.Read(Span<byte>)의 경우 .NET Stnadard 2.0에서 지원하지 않아 타켓 프레임워크를 식별하는 방법을 찾아 분기해야 겠습니다.

2개의 좋아요

속성이 하나만 있는 패킷으로 단위 테스트를 진행했고 BinaryWriterWrite(string)에서 7bit 가변 길이를 사용함을 알 수 있었습니다. 현재 list 및 dict는 4바이트 고정 길이를 사용하므로 1차 목표는 7bit 가변 길이를 동일하게 list, dict에 적용하고 차후에 길이 저장 시 8비트, 16비트 32비트, 가변을 선택할 수 있도록 수정해야 겠습니다.

image

3개의 좋아요

BinaryWriter.Write(string)에서 사용하는 가변 7bit 사이즈 저장 방식은 BinaryWriter. Write7BitEncodedInt(size)를 이용해서 저장됩니다. @list, @dict 저장 시 이것을 이용할 수 있었습니다.

구현 방식이 궁금해서 살펴봤는데,

        public void Write7BitEncodedInt(int value)
        {
            uint uValue = (uint)value;
 
            while (uValue > 0x7Fu)
            {
                Write((byte)(uValue | ~0x7Fu));
                uValue >>= 7;
            }
 
            Write((byte)uValue);
        }

꽤 심플한 코드네요. 원리는 길이를 바이트 단위로 비교해서 0x7F보다 클 경우 최상위 비트를 1로 만들고 계속해서 7bit 씩 쉬프트 하는 방식입니다.

3개의 좋아요

0.9 미리보기 1 버전을 릴리스 하였습니다.

이제 @이광석 님의 라이브러리에 소스 생성기로 기여할 수 있을 정도가 된 것 같습니다. :slight_smile:

PacketByte Support의 경우 일반 class를 대상으로 해야 하므로 System.Text.Json과 유사한 전략을 사용해야 합니다.

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(WeatherForecast))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}

JsonSerializable(typeof(WeatherForecast)) 특성을 주어서 SourceGenerationContext WeatherForecast를 직렬화/역직렬화 하는 코드를 자동 생성하는 식입니다.

6개의 좋아요

갑자기 든 생각인데 패킷은 이전 패킷과의 호환성의 이유로 리비전 관리가 필요합니다.
이를 고려한 것이 MemoryPack의 경우 MemoryPackOrder 특성입니다.

게임을 만든다고 할 때 버전1의 플레이어는 배고픔이 없었다가 버전2에서 배고픔이 생겼다고 칩시다.

플레이어에 배고픔 속성이 추가되었고 이때부터 과거 플레이어 패킷과의 호환성이 깨지게 됩니다. (읽을 수 없게 됩니다)

이를 고려하여 리비전이라는 개념을 둬서,

.@ver(2)
...
.@int(revision: 2,  "배고픔", "배고픔을 나타내며 0 ~ 100의 값")

@ver로 기록된 버전을 식별하여 r2에서 r1의 데이터를 읽을 수 있게 만들 수 있을 것 같습니다.

4개의 좋아요