Server programming

03_04_패킷 직렬화_Serialization #3_string

devRiripong 2023. 4. 12.
반응형

확인 Test

Q1. string의 경우 long이나 ushort와 달리 파싱할 때 문제가 있는 이유가 무엇인가?

->길이를 알지 못하기 떄문에 ToInt64나 ToInt16같은 걸 써서 파싱할 수 없다.

Q2. 코드를 깔끔하게 해주기 위해 어떤 개선을 해줬는가?

-> 상수를 sizeof를 이용해 바꿨고, Span의 범위를 다시 집을 때 Slice를 사용했다.

Q3. string을 buffer에 밀어 넣을 때 UTF-16을 선택하는게 나은 이유는?

-> C#에서 string이 기본적으로 UTF-16으로 되어 있기 때문이다.

Q4. string이 몇 바이트인지 모르기 때문에 몇 바이트 짜리가 와야 성공적으로 조립을 할 수 있는건지 알기 힘들다. 이 때 해결책은?

-> string의 length를 먼저 2바이트로 보낸 다음, 해당 크기의 데이터를 이어서보낸다.

Q5. string name = "ABCD"를 byte배열로 바꾸면 왜 4바이트가 아닌 8바이트가 되는가?

-> C#에서 기본적으로 문자가 UTF-16이기 때문에 길이는 4이지만 문자 한개당 2바이트라서, 2 * 4 = 8 바이트가 나오는 것

Q6. 문자열의 UTF-16 기준 byte 배열 크기를 나타낼 수 있는 방법은 무엇인가?

-> ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);

Q7. Read에서 byte배열을 UTF-16으로 어떻게 바꿔줬나?

-> this.name = Encoding.Unicode.GetString(s.Slice(count, nameLen));

Q8. count는 어떤 의미가 있는가?

-> count가 최종적으로 패킷의 크기로 인식된다.

Q9. GetBytes의 다른 버전을 써서 개선된 점은 무엇인가?

-> nameLen을 구하지 않아도 name을 넣을 때 자동으로 구해진다.

Q10. byte배열을 보내야 한다면 어떻게 하면 될까?

-> ushort를 먼저 보낸 다음에 실제 데이터를 보내는 방식으로 하면 된다.

Q11. list를 packet으로 보내고 개선하기 위해 한 작업을 나열해 보세요.

->

Write의 2,2,8을 sizeof로 바꿔준다. 테스트를 위해 (ushort)4로 size를 했던 걸 다시 count로 돌려 놓는다. ArraySegment<byte> s의 이름을 segment로 변경합니다. Span으로 segment를 이용해 한번 만들어 주고, Slice로 집어서 TryWriteBytes에 넣어주게 수정합니다.

 

-> Read도 똑같이 수정합니다. 매개변수 s를 segment로 수정한다. ReadOnlySpan<byte> s = new Span<byte>(segment.Array, segment.Offset, segment.Count); 이렇게 s에 Span을 이용해서 기본 전체 범위로 만들어 준다. this.playerId = BitConverter.ToInt64(s.Slice(count, s.Length-count)); 이렇게 Slice로 playerId가 있는 부분을 잘라서 추출해서 꺼낸다.

 

-> class PlayerInfoReq에 public string name;을 추가한다. Write에서 ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); 이렇게 name의 byte배열 길이를 뽑아내고 success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen); 이렇게 segment에 밀어 넣어준다. count += sizeof(ushort); count도 업데이트 한다. Encoding.Unicode.GetBytes(this.name); 로 name이라는 string을 byte 배열로 반환 Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen); 이렇게 하면 원하는 배열 segment에다가 복사를 해주게 될거야. count += nameLen; count를 nameLen 만큼 증가해 준다. 즉 길이와 내용을 넣어 줬다. success &= BitConverter.TryWriteBytes(s, count);최종 카운트를 세는 코드를 맨 아래로 옮겨준다.

 

-> Read쪽도 맞춰줘야 한다. count+=2같이 상수로 이루어진 곳을 sizeof로 수정해준다. 먼저 BitConverter.ToUInt16(s.Slice를 이용해 nameLen을 뽑아오고 count를 sizeof(ushort)만큼 늘려준다. Encoding.Unicode.GetString(s.Slice를 이용해 string의 내용을 뽑아온다. 수정한 Packet 부분을 ClientSession에도 복붙해준다.

 

-> ClientSession의 OnRecvPacket에서 추출해 출력하는 부분에 {p.name}을 추출해준다. 실행해 보면 잘 된다.

 

-> 해킹 됐을 때 어떻게 될지 보기 위해 ServerSession의 Write에서 nameLen을 success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), (ushort)999); 이렇게 엉뚱한 값을 넣어준다. 실행해 보면 에러 메시지가 뜬다. 다시 nameLen으로 복원 시켜준다.

 

-> 개선 Write에서 name의 길이nameLen를 받아서 버퍼에 넣어주고, name의 string을 byte배열로 변환해서 버퍼에 넣어주는 것을 GetBytes의 다른 버전을 이용해서 바로 버퍼에 넣어준다. size를 넣어줄 ushort만큼의 크기를 위해 버퍼의 offset을 설정하는 인자에 +sizeof(ushort)를 추가한다. 그리고 nameLen을 BitConverter.TryWriteBytes를 이용해 버퍼에 넣어준다. count도 ushort와 nameLen만큼 더해준다. 이렇게 nameLen을 구하고, nameLen을 넣고, name을 넣고 하던걸 name을 넣음과 동시에 nameLen을 구하고, namelLend을 넣는 거로 개선했다.

 

 

 

 

오늘의 주제는 고정된 사이즈의 데이터가 아니라 string 같은 가변적인 크기를 갖는 데이터어떻게 넘기고, 어떻게 추출할 것인지에 관한 내용이다.

지난 시간에 UTF8 이나 UTF16 중에서 하나를 선택한다고 했는데 그거와는 별개로 string name; 같은 경우 크기가 얼마인지 예상을 할 수가 없다.

long은 8바이트, ushort 같은 경우는 2바이트 였으니까, 애당초 넣을 때 어떤 버전을 사용할지 Converter에 넣을 때 ToUInt16이라든가 ToInt64 라든가 어떤 버전을 사용할지 정해줄 수 있었는데 string은 그게 안되니까 문제가 된다.

어떻게 할지 고민을 해보는데

그 이전에 정리를 해볼거야.

나중에 자동화 할 때 어떤 식으로 코드를 만들어 줄지 고민을 해보다가

이런 식으로 2,2,8이라는 숫자를 하드코딩 했는데

public override ArraySegment<byte> Write()
{
    ArraySegment<byte> s = SendBufferHelper.Open(4096);

    ushort count = 0; // 지금까지 몇 바이트를 버퍼에 밀어 넣었는지 추적할거야.
    bool success = true;

    // 혹시라도 중간에 한번이라도 실패하면 false가 뜬다.
    //success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
    count += 2;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), (ushort)PacketID.PlayerInfoReq); 
    count += 2;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), this.playerId);
    count += 8;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), (ushort)4);

    if (success == false)
        return null; 

    return SendBufferHelper.Close(count);
}

조금 더 의미 알기 쉽게 sizeof로 바꿔주자.

public override ArraySegment<byte> Write()
{
    ArraySegment<byte> s = SendBufferHelper.Open(4096);

    ushort count = 0; // 지금까지 몇 바이트를 버퍼에 밀어 넣었는지 추적할거야.
    bool success = true;

    // 혹시라도 중간에 한번이라도 실패하면 false가 뜬다.
    //success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
    count += **sizeof(ushort)**;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), (ushort)PacketID.PlayerInfoReq); 
    count += **sizeof(ushort)**;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), this.playerId);
    count += **sizeof(long)**;
    success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), **count**);

    if (success == false)
        return null; 

    return SendBufferHelper.Close(count);
}

가독성이 좋아진 거 같아.

테스트를 위해 size를 (ushort)4로 했던 것도 다시 count로 돌려 놓는다.

 

그리고 new Span으로 데이터를 넣는 것도 크게 잘못 된 건 아니지만 버전이 하나 더 있었어.

Span을 이용할 때 ArraySegment도 마찬가지지만 Slice 계열의 함수가 항상 있어서 현재 있는 범위의 일부분을 다시 선택할 때는 Slice를 선택하는 것도 나쁜 선택이 아니다.

 

이름이 겹치니까 ArraySegment<byte> s는 segment로 이름을 수정해 준다.

Span은 한번만 만들어 줄거고, 그 다음부터 TryWriteBytes에 집어서 넘겨준다고 보면 된다.

 

원래 코드

public override ArraySegment<byte> Write()
        {
            ArraySegment<byte> s = SendBufferHelper.Open(4096);

            ushort count = 0; // 지금까지 몇 바이트를 버퍼에 밀어 넣었는지 추적할거야.
            bool success = true;

            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), this.packetId);
            count += sizeof(ushort);
            success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset + count, s.Count - count), this.playerId);
            count += sizeof(long);
            success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), count);

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }
    }

 

public override ArraySegment<byte> Write()
{
    ArraySegment<byte> segment = SendBufferHelper.Open(4096);

    ushort count = 0;
    bool success = true;

// Span이라는 걸 이렇게 한번만 만들어 줄거고, 
    Span<byte> s = new Span<byte>(segment.Array, segment.Offset + count, 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.packetId);
    count += sizeof(long);
    success &= BitConverter.TryWriteBytes(s, count); // 마지막은 원본을 넣는 거니까

    if (success == false)
        return null; 

    return SendBufferHelper.Close(count);
}

이렇게 하니까 조금 더깔끔하게 됐어.

Slice를 보면 s자체가 변하는게 아니라 결과값이 Span에 뽑혀서 나오는 것을 알 수 있어. Slice를 했다 하더라도 실질적으로 s에 변화가 있는 건 아니다. 결과물을 Span이라는 타입으로 만들어서 뱉어주니까 TryWriteBytesSpan을 넣어준 셈이 된다.

 

마찬가지로 Read하는 부분도 똑같이 수정해주자.

public override void Read(ArraySegment<byte> s)
        {
            ushort count = 0;

            //ushort size = BitConverter.ToUInt16(s.Array, s.Offset);
            count += 2;
            //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count);
            count += 2;

            //this.playerId = BitConverter.ToInt64(s.Array, s.Offset + count);
            this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count));
            count += 8; // 나중에 이어서 파싱을 해야 할 경우를 대비해서 맞춰준다.
        }

여기에서

public override void Read(ArraySegment<byte> segment)
{
    ushort count = 0;

// 전체 범위로 만들어 준다.
    ReadOnlySpan<byte> s = new ReadOnlySpan<byte>(segment.Array, segment.Offset + count, segment.Count - count); 

    //ushort size = BitConverter.ToUInt16(s.Array, s.Offset); 
    count += 2;
    //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); 
    count += 2;
    this.playerId = BitConverter.ToInt64(s.Slice(count, s.Length-count)); 
    count += 8; 
}

이렇게 수정을 해봤고

여기까지 잘 되는지 테스트하기 위해 수정한 PlayerInfoReq : Packet 클래스를 복사를 한 다음에 ClientSession에도 복붙을 해주자

매번 이렇게 복붙을 해주고 있는데 나중에는 ServerSession, ClientSession 둘 쪽에서 다 참조할 수 있는 폴더로 넘겨주면 된다.

 

실행해 보면 잘 실행된다.

class PlayerInfoReq : Packet    // 클라에서 서버로 플레이어 정보를 알고 싶어
    {
        public long playerId;
        public **string name;**

 

그럼 이어가지고 string name을 어떻게 serialization 해서 패킷에 넣을 수 있을까?

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 + count, 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.packetId);
            count += sizeof(long);
            success &= BitConverter.TryWriteBytes(s, count);

            **// string
// 여기에서 string 밀어 넣는 작업을 해야 해.** 

            if (success == false)
                return null; 

            return SendBufferHelper.Close(count);
        }

//string 이 부분에서 string을 밀어 넣는 작업을 해야 하는데

UTF-8로 할지 UTF-16으로 맞춰줄지를 정해야 한다.

일반적으로 선생님은 UTF-16이 좋다고 생각해. 지금 서버, 클라 둘 다 C#으로 가고 있고, C#에서는 기본적으로 string이나 char가 설명에 나온 대로

UTF-16으로 되어 있기 때문에

굳이 UTF-8로 변환해서 보내주기 보다는 이미 UTF-16으로 되어 있으니까 그냥 고대로 보내준 다음에 파싱을 하는게 조금 더 수월할거야.

그리고 프로젝트마다 UTF-8을 사용하는 데도 있었고 케이스 바이 케이스로 달라지긴 했다.

UTF-16을 사용한다 해도 문제가 끝이 아닌게 몇 바이트 짜리인지 알 수가 없다. C++ 문자열이라면 항상 끝에 null로 끝나니까 0x00 00 으로 끝나니까 이걸로 판별을 해가지고 문자열이 끝났는지 아직 남았는지를 알 수 있었는데 C# string에서는 딱히 이렇게 0x00 00으로 끝나지 않는다. 그걸 강압적으로 끝에 00을 붙여서 보내는 걸 고려할 수 있겠지만 그렇게 한다고 하더라도 받는 쪽 입장에서 이게 00이라는 null 문자가 나올 때 까지 계속 파싱을 하는게 말이 안되는게 애당초 이 바이트 짜리가 와야지 성공적으로 조립을 하는지를 예상하기 힘들다. 결국에는 이런 식으로 string 자체를 보낸다고 해서, 쌩으로 바이트 배열로 만든 다음에 보내기 보다는 일단은 2바이트 짜리로 이 string의 크기가 얼마인지 stirng의 length를 먼저 2바이트 짜리로 보낸 다음에 그 다음에 해당하는 크기의 데이터를 이어서 보내면 좋을 거 같다.

2단계를 거쳐서 보내는 거.

// string len [2]   ushort 짜리로 string의 크기는 얼마얼마 짜리다. length 
// byte []          실제로 그 크기 만큼을 byte 배열로 보내면 윤곽이 나올 거 같단 생각이 든다.
// 이 방법으로 해보자.

this.name;

this.name보내고 싶은 데이터를 가지고 있는데 얘를 일단은 byte 배열로 바꾸기는 해야 한다.

여기서 헷갈리는 건

class ServerSession : Session
{
      public override void OnConnected(EndPoint endPoint)
      {
          Console.WriteLine($"OnConnected : {endPoint}");

          PlayerInfoReq packet = new PlayerInfoReq() { playerId = 1001, **name** = "**ABCD**" };

				// 보낸다
          //for (int i = 0; i < 5; i++)
          {
              ArraySegment<byte> s = packet.Write(); 

              if (s != null)
                  Send(s);
          }
      }

name = "ABCD" 이렇게 가정을 해보자.

문자열의 길이4짜리가 될거야. 그렇다고 this.name.Length를 하면 4가 나오기는 하는데 실제로 byte 배열로 변환을 하면 4바이트가 아니라 더 큰 8 바이트가 나오게 될거야. C#에서 기본적으로 문자가 UTF-16이기 때문에 길이는 4이지만 문자 한개당 2바이트라서, 2 * 4 = 8 바이트가 나오는 것이다.

그래서 Length로 직접적으로 사용할 수는 없고, 문자열의 UTF-16 기준 byte 배열 크기를 나타낼 수 있는 방법이 없을까 곰곰히 생각해 보게 되는데 그런게 마련이 되어있다.

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 + count, 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.packetId);
    count += sizeof(long);
    success &= BitConverter.TryWriteBytes(s, count);

    // string len [2]
    // byte []
    **ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // 바이트 배열로 변환 됐을 때의 길이를 나타낸다. 
    success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen); // 2바이트로 바이트배열의 크기를 밀어 넣어준 것
    count += sizeof(ushort); // count도 더해준다.**

		if (success == false)
        return null;

    return SendBufferHelper.Close(count);

이 다음에 진짜로 데이터를 넣어주는 거

Encoding.Unicode.GetBytes(this.name);// 원하는 name이라는 string을 byte 배열로 반환을 해준다.

이걸 사용해서

Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen); // sendBuffer의 할당한 공간에 복사
count += nameLen; // count를 증가 시킨다.

이렇게 복사를 하면 할당한 배열에다가 복사를 해주게 된다.

즉 길이와 내용을 넣어준 게 된다.

 

// string len [2]
// byte []
ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // 바이트 배열로 변환 됐을 때의 길이를 나타낸다. 
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen); // 2바이트로 바이트배열의 크기를 밀어 넣어준 것
count += sizeof(ushort);
Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
count += nameLen;

결국 string을 보내기 위해서 이렇게 두 단계(길이 byte 넣기, string 내용 넣기)를 거쳐가지고 한 걸 알 수 있는데

애당초 처음에 데이터를 보낼 때 packetId랑 size를 같이 보냈었어.

그 다음에 세부적으로 string이나 list같은 데이터를 보낼 떄도 다시 길이가 얼마였나, 바이트배열 기준으로 몇개 짜리 였는지를 넣어주는 걸 볼 수 있다. 이게 단순 하면서도 효과적인 방법이라고 볼 수 있다.

 

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

    // 혹시라도 중간에 한번이라도 실패하면 false가 뜬다.
    //success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
    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
    ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
    success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
    count += sizeof(ushort);
    Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
    count += nameLen;

		**success &= BitConverter.TryWriteBytes(s, count); // 최종 count**

    if (success == false)
    return null;

return SendBufferHelper.Close(count);
}

 

이렇게 완성을 했으면 Read쪽도 똑같이 맞춰줘야 하는데,

public override void Read(ArraySegment<byte> segment)
{
    ushort count = 0;

    ReadOnlySpan<byte> s = new Span<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 name을 뽑아 와야 하는데

**먼저 ushort(길이)**를 뽑아 와야 한다.

// string
ushort nameLen = BitConverter.ToUInt16(s.Slice(count, s.Length - count));
count += sizeof(ushort);

 

이제 byte 배열을 UTF -16으로 바꿔주는 작업을 해야 한다.

이전에 string을 byte배열로 바꿀 때는 Encoding.Unicode를 사용했었어. 그렇다는 건 반대 작업도 있을 거 같다.

this.name = Encoding.Unicode.GetString(s.Slice(count, nameLen));

만약에 문제가 생겨서 처리가 안된다면 에러가 나니까 나중에 try, catch문에서 받아주면 된다.

결국 nameLen을 받아 주는 것도 이전 시간의 packet 크기와 마찬가지로 당연히 해킹 위험은 어느정도 있다고 가정을 하고 진행하는 것이다. 정상적인 상황이라면 얘가 바이트 배열의 크기가 되겠지만, 누군가가 일부러 이상한 값을 넣어서 packet을 조작해서 보낼 수 있다는 얘기가 되는 거.

항상 이렇게 만약의 사태에 대비를 하면서 작업을 해야지 공격을 받더라도 안전하게 버틸 수 있다는 얘기가 된다.

 

이렇게 하면 추출한게 name에 들어갈거고, 정상적으로 추가한 string name을 받아오게 될 거다.

여기까지 했으면 잘 되는지 테스트하기 위해서 수정한 class Packet부분을 ClientSession에서 복붙을 해주자.

잘 가고, 잘 뽑혔는지 테스트하기 위해서 ClientSession에서 쭉 내리다 보면 OnRecvPacket에서 파싱하는 작업을 하고 있었어.

public override void OnRecvPacket(ArraySegment<byte> buffer)
{
    ushort count = 0; 

    ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
    count += 2; 
    ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
    count += 2;

    switch ((PacketID)id)
    {
        case PacketID.PlayerInfoReq:
            {
                //long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count);
                //count += 8; // 나중에 이어서 파싱을 해야 할 경우를 대비해서 맞춰준다.
                PlayerInfoReq p = new PlayerInfoReq();
                p.Read(buffer);
                Console.WriteLine($"PlayerInfoReq: {p.playerId} **{p.name}**");
            }
            break;
    }

    Console.WriteLine($"RecvPacketId: {id}, Size: {size}");
}

이렇게 추출하면 아까 보낸 ABCD 값이 정상적으로 출력이 될텐데

실행을 해보도록 하자.

이렇게 playerId였던 1001번과 name인 ABCD가 정상적으로 출력되는 것을 볼 수 있다.

 

그리고 궁금하니까 해킹을 하면 어떤 일이 일어날지 보기 위해서

nameLen을 ServerSession의 Write에서 넣어주고 있었는데

// string
ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), **(ushort)999**);
count += sizeof(ushort);
Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
count += nameLen;

이렇게 nameLen 대신 (ushort)999를 넣어주면 어떻게 될지 테스트를 해보자.

이렇게 Client가 해킹된 상태라고 가정을 하고 실행해 보면

 

try catch 문에 들어 갔기 때문에 error 메시지가 뜬다.

 

이렇게 해서 안정성도 어느정도 보장이 되면서 정상적으로 우리가 보낸 패킷까지 잘 받고 있는 걸 볼 수 있었다.

nameLen으로 다시 복원 시켜준다.

 

마찬가지로 string 뿐만 아니라 byte배열을 보내야 할 상황이 가끔 오기도 하는데 예를 들어 리니지의 혈맹 아이콘 같은 경우에는 byte배열로 보내주게 된다. string을 이해하고 있다면 byte 배열까지 처리하는 걸 유사하게 만들 수 있을 거야. 가장 중요한 건 어느정도 완성이 된거야.

ushort나 long, string 같은 primitive 타입의 경우는 굉장히 단순하게 우리가 했던 방식으로 보내고 받으면 될거고,

string 같은 경우에는 두단계로 나눠 가지고, 일단 크기를 받은 다음에 원본 데이터를 받는 방식으로 하면 어느정도 처리가 된다.

여기서 보내는 값들의 크기는 count에 **+=**로 계속 더해지고 있으니까 이 count최종적으로 패킷의 크기로 인식이 될텐데 걔도 다 영향을 주면서 같이 갱신이 되고 있다는 것도 알 수 있다.

 

여기서 우리가 작업한게 정상적으로 잘 동작은 하고 있지만 조금 더 개선해볼 여지를 찾자면

Write에서

// string
ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
count += sizeof(ushort);
Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
count += nameLen;

이런 식으로 GetByteCount를 해서 nameLen을 뽑아온 다음에

Encoding.Unicode.GetBytes를 해서 byte 배열을 뱉어주고있었는데

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

다른 애들 보면 TryWriteBytes같이 이미 만들어진 sendBuffer인 segment에다가 직접적으로 밀어 넣고 있었어. 근데

Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);

여기서는 내부적으로 new를 해서 byte를 뱉은 다음다시 Copy를 하고 있는데

이것도 개선할 여지가 있다.

Encoding.Unicode.GetBytes를 보면 버전이 여러가지 있다.

이 버전이 흥미롭다. string에 대한 정보가 3개 있고, 복사할 byte배열이랑 byteOffset을 받아가지 돌려주는 int는 인터페이스만 봐도 몇 바이트를 복사 했는지를 뱉어줄 거라는 생각이 드니까 얘를 사용해 보도록 하자.

Encoding.Unicode.GetBytes(this.name, 0, this.name.Length, segment.Array, segment.Offset + count);

이렇게 하면 뱉어주는게 nameLen을 뱉어준다는 얘기가 된다.

// string
ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
count += sizeof(ushort);
Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
count += nameLen;

이 두가지 동작으로 하던 거를

어떻게 어떻게 잘 테트리스를 하면 한방에 구현할 수 있다는 얘기가 될텐데 위에서 사용하던 부분을 주석처리하고 보면서 맞춰줄거야.

ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, this.name.Length, segment.Array, segment.Offset + count);
count += nameLen;

얘는 먼저 처리를 한 다음에 후진을 해서 기존의 것도 다시 맞춰줘야 한다는 얘기가 되는데

offset을 count += sizeof(ushort) 이 부분이 빠져있어. 나중에 넣거나 약간 차이가 있긴 한데

ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, this.name.Length, segment.Array, segment.Offset + count + **sizeof(ushort)**);

만약에 나중에 넣을 거면, 공간을 마련해 줘야 한다. GetBytes의 byteIndex 인자를 2바이트를 늘려놔가지고, 다시 뒤로돌아가서 nameLen를 넣어줄 공간을 확보를 해줘야 한다. 2바이트 띄어 놓은 상태에서 복사를 하게 한 거.

얘를 끝내자마자 얘전에 했던 nameLen를 복사해 주는 부분을

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;

이렇게 넣어주면 된다.

+sizeof(ushort)를 해준 이유는 애당초 걔의 공간이 아니라

segment.Offset + count 만 하면은 nameLen가 들어가야 할 공간이니까, 밀어 준 거.

어쨌든 이런 식으로 처리를 하면 위에서 했던 게(nameLen을 구하고, 넣고, name을 넣고) 효율적으로 한방(name을 넣음과 동시에 nameLen을 구하고, nameLen넣고)에 처리가 된다. count를 두번 늘려주는게 헷갈리기는 한데 효율성 측면에서 보면 좋을테니까 이렇게 해준다.

 

잘 작동하는지 확인을 해보면

아까와 마찬가지로 잘 보내지는 것을 알 수 있다.

 

이렇게 string을 serialize해서 패킷으로 간단하게 보내는 작업을 해봤고, 그리고 나중에 string이 아니라 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;

이런 방식이랑 비슷하게

ushort(길이)를 먼저 보낸 다음에 실제 데이터를 보내는 방식을 채택할 수 있을 거야.

 

다음 시간에 알아볼 것은 List를 어떻게 보낼지

예를 들어 스킬 목록을 보내주는 상황이 있을 수 있을거야.

public List<int> skills = new List<int>();

이렇게 보내주는 상황은 어떻게 처리해야 할까가 궁금한데 다음 시간에 알아볼거야.

 

작업한 코드

ServerSession

using System;
using System.Collections.Generic;
using System.Text;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using ServerCore;

namespace DummyClient
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        public abstract ArraySegment<byte> Write(); // 밀어 넣는 부분 Serialize
        public abstract void Read(ArraySegment<byte> s); // 빼내 주는 부분 DeSerialize
    }

    class PlayerInfoReq : Packet    // 클라에서 서버로: 나는 플레이어 정보를 알고 싶어. 그 정보는 playerId라는 플레이어야.
    {
        public long playerId;
        public string name; 

        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        public override void Read(ArraySegment<byte> segment)
        {
            ushort count = 0;

            ReadOnlySpan<byte> s = new Span<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)); 

        }

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

            // 혹시라도 중간에 한번이라도 실패하면 false가 뜬다.
            //success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
            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
            //ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
            //success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
            //count += sizeof(ushort);
            //Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
            //count += nameLen;

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

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }
    }

    public enum PacketID
    {
        PlayerInfoReq = 1,
        PlayerInfoOk = 2,
    }

    class ServerSession : Session
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            PlayerInfoReq packet = new PlayerInfoReq() {playerId = 1001, name = "ABCD"};

            // 보낸다
            //for (int i = 0; i < 5; i++)
            {
                ArraySegment<byte> s = packet.Write(); 

                if (s != null)
                    Send(s);
            }
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override int OnRecv(ArraySegment<byte> buffer)
        {
            string recvData = Encoding.UTF8.GetString(buffer.Array, buffer.Offset, buffer.Count);
            Console.WriteLine($"[From Server] {recvData}");
            return buffer.Count;
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes: {numOfBytes}");
        }
    }
}

ClientSession

using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using ServerCore;
using System.Net;

namespace Server
{
    public abstract class Packet
    {
        public ushort size;
        public ushort packetId;

        public abstract ArraySegment<byte> Write(); // 밀어 넣는 부분 Serialize
        public abstract void Read(ArraySegment<byte> s); // 빼내 주는 부분 DeSerialize
    }

    class PlayerInfoReq : Packet    // 클라에서 서버로: 나는 플레이어 정보를 알고 싶어. 그 정보는 playerId라는 플레이어야.
    {
        public long playerId;
        public string name;

        public PlayerInfoReq()
        {
            this.packetId = (ushort)PacketID.PlayerInfoReq;
        }

        public override void Read(ArraySegment<byte> segment)
        {
            ushort count = 0;

            ReadOnlySpan<byte> s = new Span<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));

        }

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

            // 혹시라도 중간에 한번이라도 실패하면 false가 뜬다.
            //success &= BitConverter.TryWriteBytes(new Span<byte>(s.Array, s.Offset, s.Count), packet.size);
            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
            ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name);
            success &= BitConverter.TryWriteBytes(s.Slice(count, s.Length - count), nameLen);
            count += sizeof(ushort);
            Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, segment.Array, count, nameLen);
            count += nameLen;

            success &= BitConverter.TryWriteBytes(s, count);

            if (success == false)
                return null;

            return SendBufferHelper.Close(count);
        }
    }

    public enum PacketID
    {
        PlayerInfoReq = 1,
        PlayerInfoOk = 2,
    }

    //class Packet
    //{
    //    public ushort size;
    //    public ushort packetId;
    //}

    //class PlayerInfoReq : Packet    // 클라에서 서버로: 나는 플레이어 정보를 알고 싶어. 그 정보는 playerId라는 플레이어야.
    //{
    //    public long playerId;
    //}

    //class PlayerInfoOk : Packet     // 서버에서 클라로: 답변을 주는 거. 
    //{
    //    public int hp;
    //    public int attack;    // hp와 attack을 반환한다고 가정을 해보자. 간단하게 상황을 만들어 준 거. 
    //}

    class ClientSession : PacketSession
    {
        public override void OnConnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnConnected : {endPoint}");

            //Packet packet = new Packet() { size = 100, packetId = 10 };

            //ArraySegment<byte> openSegment = SendBufferHelper.Open(4096);
            //byte[] buffer = BitConverter.GetBytes(packet.size);
            //byte[] buffer2 = BitConverter.GetBytes(packet.packetId);
            //Array.Copy(buffer, 0, openSegment.Array, openSegment.Offset, buffer.Length);
            //Array.Copy(buffer2, 0, openSegment.Array, openSegment.Offset + buffer.Length, buffer2.Length);
            //ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length);

            //Send(sendBuff);
            Thread.Sleep(5000);
            Disconnect();
        }
        public override void OnRecvPacket(ArraySegment<byte> buffer)
        {
            ushort count = 0; 

            ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset);
            count += 2; 
            ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count);
            count += 2;

            switch ((PacketID)id)
            {
                case PacketID.PlayerInfoReq:
                    {
                        //long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count);
                        //count += 8; // 나중에 이어서 파싱을 해야 할 경우를 대비해서 맞춰준다.
                        PlayerInfoReq p = new PlayerInfoReq();
                        p.Read(buffer);
                        Console.WriteLine($"PlayerInfoReq: {p.playerId} {p.name}");
                    }
                    break;
            }

            Console.WriteLine($"RecvPacketId: {id}, Size: {size}");
        }

        public override void OnDisconnected(EndPoint endPoint)
        {
            Console.WriteLine($"OnDisconnected : {endPoint}");
        }

        public override void OnSend(int numOfBytes)
        {
            Console.WriteLine($"Transferred bytes: {numOfBytes}");
        }
    }

}

 

출처: https://inf.run/obU9

반응형

댓글