[ 섹션 3. 패킷 직렬화 ]
# Serialization #1
- 직렬화란 객체를 저장 가능한 상태 또는 전송 가능한 상태로 변환하는 것을 뜻한다.
~> 즉, 패킷 직렬화란 메모리 상에 존재하는 데이터를 패킷에 차곡차곡 쌓은 뒤 이를 하나의 바이트 배열로 만드는 것이다.
- 역직렬화란 특정 포맷 상태의 데이터를 다시 객체로 변환하는 것을 뜻한다.
- Session은 추후에 다양하게 존재할 수 있기 때문에 Session의 이름을 정확하게 지어주는 것이 중요하다.
(예를 들어 분산서버인 경우 각 다른 부분을 관리하는 서버의 대리자 역할을 하는 Session이 여러개 존재한다.)
- 우선 Serialization 의 흐름만 이해한 뒤 추후에 자동화 할 예정이다.
DummyClient에 ServerSession Class 생성 후 DummyClient의 Program Class 와 내용 분리using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DummyClient { class Packet { public ushort size; // ushort는 2Byte public ushort packetId; // ushort는 2Byte } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; } class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것 { public int hp; public int attack; } public enum PacketID { PlayerInfoReq = 1, PlayerInfoOk = 2, } class ServerSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); PlayerInfoReq packet = new PlayerInfoReq() { packetId = (ushort)PacketID.PlayerInfoReq, playerId = 1001 }; for (int i = 0; i < 5; i++) { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); // 아래 부분 추가 (2번의 단계를 거쳐야 하는 것을 TryWriteBytes를 통해 1번의 단계를 거치도록 수정) bool success = true; ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가? count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.packetId); count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), packet.playerId); count += 8; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화 // 아래 부분 삭제 //byte[] size = BitConverter.GetBytes(packet.size); //byte[] packetId = BitConverter.GetBytes(packet.packetId); //byte[] playerId = BitConverter.GetBytes(packet.playerId); //Array.Copy(size, 0, openSegment.Array, openSegment.Offset + count, 2); //count += 2; //Array.Copy(packetId, 0, openSegment.Array, openSegment.Offset + count, 2); //count += 2; //Array.Copy(playerId, 0, openSegment.Array, openSegment.Offset + count, 8); //count += 8; ArraySegment<byte> sendBuff = SendBufferHelper.Close(count); if (success) // success시 Send Send(sendBuff); } } 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}"); } } }
수정된 DummyClient의 Program Classusing System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using ServerCore; // Servercore 라이브러리 참조 namespace DummyClient { class Program { static void Main(string[] args) { // DNS (Domain Name System) : Domain을 IP 네트워크에서 찾아갈 수 있는 IP로 변환해 준다. string host = Dns.GetHostName(); // Local Computer의 host 이름을 반환 IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; // ip 주소를 배열로 반환 (예를 들어 Google과 같이 Traffic이 어마무시한 사이트는 여러개의 ip 주소를 가질 수 있기 때문) IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // ip 주소와 port 번호를 매개변수로 입력 Connector connector = new Connector(); connector.Connect(endPoint, () => { return new ServerSession(); }); while (true) { try { } catch (Exception e) { Console.WriteLine(e.ToString()); } Thread.Sleep(100); } } } }
Server에 ClientSession Class 생성 후 Server의 Program Class 와 내용 분리using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Server { class Packet // 패킷 설계시 최대한 Size를 압축하여 보내는 것이 중요하다. { // Packet이 완전체로 왔는지? 잘려서 왔는지? 구분할 수 있어야 한다. public ushort size; // ushort는 2Byte public ushort packetId; // ushort는 2Byte } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; } class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것 { public int hp; public int attack; } public enum PacketID { PlayerInfoReq = 1, PlayerInfoOk = 2, } 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, buffer.Length, buffer2.Length); ArraySegment<byte> sendBuff = SendBufferHelper.Close(buffer.Length + buffer2.Length); Send(sendBuff); Thread.Sleep(5000); Disconnect(); } // OnRecvPacket 코드 수정 public override void OnRecvPacket(ArraySegment<byte> buffer) { ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가? ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것 count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 ushort로 뽑아달라는 것 count += 2; switch ((PacketID)id) { case PacketID.PlayerInfoReq: { long playerId = BitConverter.ToInt64(buffer.Array, buffer.Offset + count); // ToUInt16은 Byte 배열을 long으로 뽑아달라는 것 count += 8; Console.WriteLine($"PlayerInfoReq: {playerId}"); } 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}"); } } }
수정된 Server의 Program Classusing System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { static Listener _listener = new Listener(); static void Main(string[] args) { string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 손님을 입장시킨다. _listener.Init(endPoint, () => { return new ClientSession(); }); Console.WriteLine("Listening..."); while (true) { } } } }
# Serialization #2
- 자동화 하기에 앞서 인터페이스를 통한 코드 수정을 할 예정이다.
ServerSession Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DummyClient { public abstract class Packet { public ushort size; public ushort packetId; // 최상위 Class인 Packet에 인터페이스 생성 public abstract ArraySegment<byte> Write(); public abstract void Read(ArraySegment<byte> s); } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; // 생성자 public PlayerInfoReq() { this.packetId = (ushort)PacketID.PlayerInfoReq; } // 인터페이스 구현 public override ArraySegment<byte> Write() { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가? count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId); count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId); count += 8; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화 if (success == false) return null; return SendBufferHelper.Close(count); } // 인터페이스 구현 public override void Read(ArraySegment<byte> s) { ushort count = 0; //ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X count += 2; //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X count += 2; // 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함) this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count)); count += 8; } } // PlayerInfoOk는 추후에 구현 예정 //class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것 //{ // public int hp; // public int attack; //} // ... class ServerSession : Session { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); PlayerInfoReq packet = new PlayerInfoReq() { playerId = 1001 }; for (int i = 0; i < 5; i++) { ArraySegment<byte> s = packet.Write(); // 직렬화 if (s != null) // success시 Send Send(s); } } // ... } }
ClientSession Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Server { public abstract class Packet { public ushort size; public ushort packetId; // 최상위 Class인 Packet에 인터페이스 생성 public abstract ArraySegment<byte> Write(); public abstract void Read(ArraySegment<byte> s); } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; // 생성자 public PlayerInfoReq() { this.packetId = (ushort)PacketID.PlayerInfoReq; } // 인터페이스 구현 public override ArraySegment<byte> Write() { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; // 지금까지 몇 Byte를 Buffer에 밀어 넣었는가? count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.packetId); count += 2; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset + count, openSegment.Count - count), this.playerId); count += 8; success &= BitConverter.TryWriteBytes(new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count), count); // size는 모든 작업이 끝난 뒤 초기화 if (success == false) return null; return SendBufferHelper.Close(count); } // 인터페이스 구현 public override void Read(ArraySegment<byte> s) { ushort count = 0; //ushort size = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> 사용할 일이 없어 필요 X count += 2; //ushort id = BitConverter.ToUInt16(s.Array, s.Offset + count); ~> Read를 실행했다는 것은 이미 패킷 분해 후 id에 대한 정보를 얻은 뒤이므로 필요 X count += 2; // 수정 부분 (Client가 악의적으로 잘못된 Packet Size를 보낸 경우를 방지하기 위함) this.playerId = BitConverter.ToInt64(new ReadOnlySpan<byte>(s.Array, s.Offset + count, s.Count - count)); count += 8; } } // PlayerInfoOk는 추후에 구현 예정 //class PlayerInfoOk : Packet // Server에서 Client로 요청에 대한 답변을 전달하는 것 //{ // public int hp; // public int attack; //} // ... class ClientSession : PacketSession { // ... public override void OnRecvPacket(ArraySegment<byte> buffer) { // 패킷을 분해하여 id 에 대한 정보를 얻은 뒤 ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; // 해당 id 에 맞는 코드를 실행 switch ((PacketID)id) { case PacketID.PlayerInfoReq: { PlayerInfoReq p = new PlayerInfoReq(); p.Read(buffer); // 역직렬화 Console.WriteLine($"PlayerInfoReq: {p.playerId}"); } break; } Console.WriteLine($"RecvPacketID: {id}, Size: {size}"); } // ... } }
# UTF-8 vs UTF-16
- 컴퓨터가 세상에 처음 등장할 당시에는 영어와 몇가지 특수문자만을 사용하였고, 이를 저장하기 위해 1Byte로 충분했다.
~> ASCII 코드 (1Byte) 등장
- 그러나 인터넷 시대 도입 후 언어의 다양성으로 인하여 1Byte 만으로는 모든 나라의 언어를 표현할 수 없다.
~> UNICODE (2Byte) 등장
- Encoding은 컴퓨터에서 문자와 기호를 표현하기 위해 문자를 이진 데이터로 변환하는 과정이며 문자와 이진 데이터 간의
Mapping 규칙을 정의하는 방법이다.
~> Variable-Width Encoding (가변 너비 인코딩) : UTF-8, UTF-16
~> Fixed-Length Encoding (고정 길이 인코딩) : UTF-32
- UTF-8
~> 영문 : 1Byte
~> 한글 : 3Byte
- UTF-16
~> BMP X : 2Byte
~> BMP O : 4Byte
~> 영문 : 2Byte
~> 한글 : 2Byte
# Serialization #3
- 데이터의 길이가 가변적인 String은 어떻게 처리해야 할까?
String 처리를 위한 ServerSession Class 와 ClientSession Class 수정
(아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DummyClient { public abstract class Packet { public ushort size; public ushort packetId; public abstract ArraySegment<byte> Write(); public abstract void Read(ArraySegment<byte> openSegment); } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; public string name; // 가변 길이의 멤버 변수는 어떻게 처리? // 생성자 public PlayerInfoReq() { this.packetId = (ushort)PacketID.PlayerInfoReq; } public override ArraySegment<byte> Write() { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); // Slice는 실질적으로 Span에 변화를 주지 X count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); // Slice는 실질적으로 Span에 변화를 주지 X count += sizeof(long); // string 처리 #1 (Buffer에 2Byte인 string len을 먼저 삽입 후 string data 삽입) //ushort nameLen = (ushort)Encoding.Unicode.GetByteCount(this.name); // GetByteCount()는 UTF-16 기준의 byte 배열 크기를 반환 //success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); // Slice는 실질적으로 Span에 변화를 주지 X //count += sizeof(ushort); //Array.Copy(Encoding.Unicode.GetBytes(this.name), 0, openSegment.Array, count, nameLen); // GetBytes()는 string을 받아 Byte 배열로 변환 //count += nameLen; // string 처리 #2 (Buffer에 2Byte인 string len을 위한 공간을 남겨둔 채로 string data를 먼저 삽입 후 string len 삽입) ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환 success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); count += sizeof(ushort); count += nameLen; success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화 if (success == false) return null; return SendBufferHelper.Close(count); } public override void Read(ArraySegment<byte> openSegment) { ushort count = 0; ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); count += sizeof(ushort); this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); // Slice는 실질적으로 Span에 변화를 주지 X count += sizeof(long); // string 처리 ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count)); count += sizeof(ushort); this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen)); // GetString()는 Byte 배열을 받아 string으로 변환 count += nameLen; } } // ... }
# Serialization #4
- 데이터의 길이가 가변적인 List는 어떻게 처리해야 할까?
List 처리를 위한 ServerSession Class 와 ClientSession Class 수정
(아래 코드는 ServerSession Class지만 ClientSession Class도 동일한 코드로 수정)using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DummyClient { public abstract class Packet { public ushort size; public ushort packetId; public abstract ArraySegment<byte> Write(); public abstract void Read(ArraySegment<byte> openSegment); } class PlayerInfoReq : Packet // Client에서 Server로 Player의 정보를 알고 싶다고 요청하는 것 { public long playerId; public string name; public struct SkillInfo { public int id; public short level; public float duration; public bool Write(Span<byte> span, ref ushort count) // span은 전체 Byte 배열을, count는 실시간으로 현재 어느 곳을 작업하는지 { bool success = true; success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), id); count += sizeof(int); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), level); count += sizeof(short); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), duration); count += sizeof(float); return success; } public void Read(ReadOnlySpan<byte> span, ref ushort count) { id = BitConverter.ToInt32(span.Slice(count, span.Length - count)); count += sizeof(int); level = BitConverter.ToInt16(span.Slice(count, span.Length - count)); count += sizeof(short); duration = BitConverter.ToSingle(span.Slice(count, span.Length - count)); count += sizeof(float); } } public List<SkillInfo> skills = new List<SkillInfo>(); // 가변 길이의 멤버 변수는 어떻게 처리? // 생성자 public PlayerInfoReq() { this.packetId = (ushort)PacketID.PlayerInfoReq; } public override ArraySegment<byte> Write() { ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.packetId); count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.playerId); count += sizeof(long); // string 처리 ushort nameLen = (ushort)Encoding.Unicode.GetBytes(this.name, 0, name.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환 success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), nameLen); count += sizeof(ushort); count += nameLen; // list 처리 success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)skills.Count); count += sizeof(ushort); foreach (SkillInfo skill in skills) success &= skill.Write(span, ref count); success &= BitConverter.TryWriteBytes(span, count); // size는 모든 작업이 끝난 뒤 초기화 if (success == false) return null; return SendBufferHelper.Close(count); } public override void Read(ArraySegment<byte> openSegment) { ushort count = 0; ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); count += sizeof(ushort); this.playerId = BitConverter.ToInt64(span.Slice(count, span.Length - count)); count += sizeof(long); // string 처리 ushort nameLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count)); count += sizeof(ushort); this.name = Encoding.Unicode.GetString(span.Slice(count, nameLen)); count += nameLen; // list 처리 skills.Clear(); ushort skillLen = BitConverter.ToUInt16(span.Slice(count, span.Length - count)); count += sizeof(ushort); for (int i = 0; i < skillLen; i++) { SkillInfo skill = new SkillInfo(); skill.Read(span, ref count); skills.Add(skill); } } } // ... }
# Packet Generator #1
- [ 솔루션 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 솔루션 폴더 ] 를 통해 폴더를 추가할 수 있다.
- [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 추가 ] - [ 새 항목 ] 의 [ C# 항목 ] - [ 데이터 ] 에서 XML 파일을 추가할 수 있다.
~> 생성된 XML 파일은 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ] 안에
위치하도록 한다. (즉, 해당 프로젝트의 실행파일이 있는 곳에 위치하도록)
- 패킷의 정의를 어떤 방식으로 할지 결정해야 한다. (JSON, XML, 자체 정의 IDL)
~> XML이 JSON에 비해 Hierarchy가 잘 보인다는 장점을 가지므로 XML을 사용할 것이다.
~> XML에서 정보는 시작 Tag와 끝 Tag 사이에 담겨지며, Tag는 쌍을 이룬다.
(Tag 사이에 삽입할 정보가 없는 경우 시작 Tag 끝에 /를 추가하여 끝 Tag 생략 가능)
- List를 제외한 나머지 부분들을 자동화하기 위한 Template을 만들고자 한다.
PacketGenerator 프로젝트 생성 후 PDL XML 파일 생성<?xml version="1.0" encoding="utf-8" ?> <PDL> <packet name="PlayerInfoReq"> <long name="playerId"/> <string name="name"/> <list name="skill"> <int name="id"/> <short name="level"/> <float name="duration"/> </list> </packet> </PDL>
PacketGenerator 코드 수정using System.Xml; namespace PacketGenerator { internal class Program { static void Main(string[] args) { XmlReaderSettings settings = new XmlReaderSettings() { IgnoreComments= true, // 주석을 무시 IgnoreWhitespace = true // 공백을 무시 }; // XML 파싱 using (XmlReader reader = XmlReader.Create("PDL.xml", settings)) { reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동 while (reader.Read()) // 한줄씩 읽어나간다. { if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 Tag, EndElement는 끝 Tag ParsePacket(reader); // Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환 } } // reader.Dispose(); ~> using 사용시 using 범위를 벗어날 경우 자동으로 Dispose()를 호출 } public static void ParsePacket(XmlReader reader) { if (reader.NodeType == XmlNodeType.EndElement) return; if (reader.Name.ToLower() != "packet") { Console.WriteLine("Invalid packet node"); return; } string packetName = reader["name"]; if (string.IsNullOrEmpty(packetName) ) { Console.WriteLine("Packet without name"); return; } ParseMembers(reader); } public static void ParseMembers(XmlReader reader) { string packetName = reader["name"]; int depth = reader.Depth + 1; while(reader.Read()) { if (reader.Depth != depth) break; string memberName = reader["name"]; if (string.IsNullOrEmpty(memberName) ) { Console.WriteLine("Member without name"); return; } string memberType = reader.Name.ToLower(); switch (memberType) { case "bool": case "byte": case "short": case "ushort": case "int": case "long": case "float": case "double": case "string": case "list": break; default: break; } } } } }
PacketFormat Class 생성using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // 여러줄에 거쳐 문자열을 정의하고 싶은 경우 @"" // 고정적인 부분을 제외한 변경되는 부분을 {}로 표시(일반적인 소괄호는 { { } }로 표시) // {0} 패킷 이름 // {1} 멤버 변수들 // {2} 멤버 변수 Read // {3} 멤버 변수 Write public static string packetFormat = @" class {0} {{ {1} public void Read(ArraySegment<byte> openSegment) {{ ushort count = 0; ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); count += sizeof(ushort); {2} }} public ArraySegment<byte> Write() {{ ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0}); count += sizeof(ushort); {3} success &= BitConverter.TryWriteBytes(span, count); if (success == false) return null; return SendBufferHelper.Close(count); }} }} "; // {0} 변수 형식 // {1} 변수 이름 public static string memberFormat = @"public {0} {1}"; // {0} 변수 이름 // {1} To변수형식 (ex : ToInt16, ToInt32, ToSingle ...) // {2} 변수 형식 public static string readFormat = @" this.{0} = BitConverter.{1}(span.Slice(count, span.Length - count)); count += sizeof({2}); "; // {0} 변수 이름 public static string readStringFormat = @" ushort {0}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count)); count += sizeof(ushort); this.{0} = Encoding.Unicode.GetString(span.Slice(count, {0}Len)); count += {0}Len; "; // {0} 변수 이름 // {1} 변수 형식 public static string writeFormat = @" success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), this.{0}); count += sizeof({1}); "; // {0} 변수 이름 public static string writeStringFormat = @" ushort {0}Len = (ushort)Encoding.Unicode.GetBytes(this.{0}, 0, this.{0}.Length, openSegment.Array, openSegment.Offset + count + sizeof(ushort)); // Buffer에 string data를 삽입함과 동시에 string len을 반환 success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), {0}Len); count += sizeof(ushort); count += {0}Len; "; } }
+ 추가 검색 (https://velog.io/@mercurios0603/%ED%8C%8C%EC%8B%B1Parsing%EC%9D%B4%EB%9E%80)
- Parsing은 컴퓨터 과학 및 프로그래밍에서 특정 형식으로 구성된 데이터를 분석하고 그 의미를 이해하는 과정을 의미
- Parsing은 주로 텍스트 기반 데이터를 해석하거나, 프로그래밍 언어의 소스 코드를 이해하거나, 문서를 구조화하고
내용을 추출하는 데 사용
# Packet Generator #2
- 지난시간 제외한 List 부분을 자동화하기 위한 Template을 만들고, Packet Generator가 잘 실행되는지 확인하고자 한다.
~> Packet Generator의 결과를 "GenPackets.cs" 파일에 저장하였다.
~> "GenPackets.cs" 파일은 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] - [ Debug ] - [ net ]
에서 확인할 수 있다.
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // ... // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] // {2} 멤버 변수들 // {3} 멤버 변수 Read // {4} 멤버 변수 Write public static string memberListFormat = @" public struct {0} {{ {2} public void Read(ReadOnlySpan<byte> span, ref ushort count) {{ {3} }} public bool Write(Span<byte> span, ref ushort count) {{ bool success = true; {4} return success; }} }} public List<{0}> {1}s = new List<{0}>(); "; // ... // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] public static string readListFormat = @" this.{1}s.Clear(); ushort {1}Len = BitConverter.ToUInt16(span.Slice(count, span.Length - count)); count += sizeof(ushort); for (int i = 0; i < {1}Len; i++) {{ {0} {1} = new {0}(); {1}.Read(span, ref count); {1}s.Add({1}); }} "; // ... // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] public static string writeListFormat = @" success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)this.{1}s.Count); count += sizeof(ushort); foreach ({0} {1} in this.{1}s) success &= {1}.Write(span, ref count); "; } }
PacketGenerator 코드 수정using System.Xml; namespace PacketGenerator { internal class Program { static string genPackets; // 실시간으로 만들어지는 패킷 static void Main(string[] args) { XmlReaderSettings settings = new XmlReaderSettings() { IgnoreComments= true, // 주석을 무시 IgnoreWhitespace = true // 공백을 무시 }; // XML 파싱 using (XmlReader reader = XmlReader.Create("PDL.xml", settings)) { reader.MoveToContent(); // header를 건너뛰고 내용 부분으로 이동 while (reader.Read()) // 한줄씩 읽어나간다. { if (reader.Depth == 1 && reader.NodeType == XmlNodeType.Element) // Element는 시작 부분, EndElement는 끝 부분 ParsePacket(reader); // Console.WriteLine(reader.Name + " " + reader["name"]); // Name은 Type을 반환, []는 Attribute를 반환 } } File.WriteAllText("GenPackets.cs", genPackets); // genPackets의 내용을 통해 GenPackets.cs 파일 생성 } public static void ParsePacket(XmlReader reader) { if (reader.NodeType == XmlNodeType.EndElement) return; if (reader.Name.ToLower() != "packet") { Console.WriteLine("Invalid packet node"); return; } string packetName = reader["name"]; if (string.IsNullOrEmpty(packetName) ) { Console.WriteLine("Packet without name"); return; } Tuple<string, string, string> tuple = ParseMembers(reader); genPackets += string.Format(PacketFormat.packetFormat, packetName, tuple.Item1, tuple.Item2, tuple.Item3); } // 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환 public static Tuple<string, string, string> ParseMembers(XmlReader reader) { string packetName = reader["name"]; string memberCode = ""; string readCode = ""; string writeCode = ""; int depth = reader.Depth + 1; while(reader.Read()) { if (reader.Depth != depth) break; string memberName = reader["name"]; if (string.IsNullOrEmpty(memberName) ) { Console.WriteLine("Member without name"); return null; } if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우 memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우 readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우 writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 string memberType = reader.Name.ToLower(); switch (memberType) { case "bool": case "short": case "ushort": case "int": case "long": case "float": case "double": memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName); readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType); writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType); break; case "string": memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName); readCode += string.Format(PacketFormat.readStringFormat, memberName); writeCode += string.Format(PacketFormat.writeStringFormat, memberName); break; case "list": Tuple<string, string, string> tuple = ParseList(reader); memberCode += tuple.Item1; readCode += tuple.Item2; writeCode += tuple.Item3; break; default: break; } } // 가독성을 위해 Text를 정렬 memberCode = memberCode.Replace("\n", "\n\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab 까지 입력되도록 수정 readCode = readCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정 writeCode = writeCode.Replace("\n", "\n\t\t"); // Enter가 입력된 곳은 Enter 입력 후 Tab Tab 까지 입력되도록 수정 return new Tuple<string, string, string>(memberCode, readCode, writeCode); } public static Tuple<string, string, string> ParseList(XmlReader reader) { string listName = reader["name"]; if (string.IsNullOrEmpty(listName)) { Console.WriteLine("List without name"); return null; } // memberListFormat의 {2}, {3}, {4}는 순서대로 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 이므로 ParseMembers() 함수 사용 Tuple<string, string, string> tuple = ParseMembers(reader); string memberCode = string.Format(PacketFormat.memberListFormat, FirstCharToUpper(listName), FirstCharToLower(listName), tuple.Item1, tuple.Item2, tuple.Item3); string readCode = string.Format(PacketFormat.readListFormat, FirstCharToUpper(listName), FirstCharToLower(listName)); string writeCode = string.Format(PacketFormat.writeListFormat, FirstCharToUpper(listName), FirstCharToLower(listName)); return new Tuple <string, string, string> (memberCode, readCode, writeCode); } public static string FirstCharToUpper(string input) { if (string.IsNullOrEmpty(input)) return ""; return input[0].ToString().ToUpper() + input.Substring(1); } public static string FirstCharToLower(string input) { if (string.IsNullOrEmpty(input)) return ""; return input[0].ToString().ToLower() + input.Substring(1); } public static string ToMemberType(string memberType) { switch (memberType) { case "bool": return "ToBoolean"; case "short": return "ToInt16"; case "ushort": return "ToUInt16"; case "int": return "ToInt32"; case "long": return "ToInt64"; case "float": return "ToSingle"; case "double": return "ToDouble"; default: return ""; } } } }
# Packet Generator #3
- using 과 enum 및 byte 부분을 자동화하기 위한 Template을 만들고, 패킷이 여러개 존재해도 자동화가 잘 되는지
확인하고자 한다.
PDL XML 파일 수정 (패킷 및 sbyte 멤버 변수 추가)<?xml version="1.0" encoding="utf-8" ?> <PDL> <packet name="PlayerInfoReq"> <sbyte name="testByte"/> <long name="playerId"/> <string name="name"/> <list name="skill"> <int name="id"/> <short name="level"/> <float name="duration"/> </list> </packet> <packet name="Test"> <int name="testInt"/> </packet> </PDL>
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // {0} 패킷 이름/번호 목록 // {1} 패킷 목록 public static string fileFormat = @" using System; using System.Collections.Generic; using System.Text; using System.Net; using ServerCore; public enum PacketID {{ {0} }} {1} "; // {0} 패킷 이름 // {1} 패킷 번호 public static string packetEnumFormat = @"{0} = {1},"; // ... // {0} 변수 이름 // {1} 변수 형식 public static string readByteFormat = @" this.{0} = ({1})openSegment.Array[openSegment.Offset + count]; count += sizeof({1}); "; // ... // {0} 변수 이름 // {1} 변수 형식 public static string writeByteFormat = @" openSegment.Array[openSegment.Offset + count] = (byte)this.{0}; count += sizeof({1}); "; // ... } }
PacketGenerator 코드 수정using System.Xml; namespace PacketGenerator { internal class Program { // ... static void Main(string[] args) { // ... static ushort packetId; // 몇개의 패킷을 처리하였는지 static string packetEnums; // Parsing 처리된 패킷의 이름과 번호 // fileFormat을 통해 using과 enum 추가 string fileText = string.Format(PacketFormat.fileFormat, packetEnums, genPackets); File.WriteAllText("GenPackets.cs", fileText); } public static void ParsePacket(XmlReader reader) { // ... Tuple<string, string, string> tuple = ParseMembers(reader); genPackets += string.Format(PacketFormat.packetFormat, packetName, tuple.Item1, tuple.Item2, tuple.Item3); // 패킷 1개를 Parsing 할때마다 packetEnums에 Parsing 처리된 패킷의 이름과 번호를 저장 packetEnums += string.Format(PacketFormat.packetEnumFormat, packetName, ++packetId) + Environment.NewLine + "\t"; } // 멤버 변수들, 멤버 변수 Read, 멤버 변수 Write 에 관한 코드를 알맞게 제작한 뒤 이를 string으로 반환 public static Tuple<string, string, string> ParseMembers(XmlReader reader) { string packetName = reader["name"]; string memberCode = ""; string readCode = ""; string writeCode = ""; int depth = reader.Depth + 1; while(reader.Read()) { // ... if (string.IsNullOrEmpty(memberCode) == false) // 이미 내용이 존재하는 경우 memberCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 if(string.IsNullOrEmpty(readCode) == false) // 이미 내용이 존재하는 경우 readCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 if(string.IsNullOrEmpty(writeCode) == false) // 이미 내용이 존재하는 경우 writeCode += Environment.NewLine; // Enter를 치는 것과 같은 동작 string memberType = reader.Name.ToLower(); switch (memberType) { case "byte": case "sbyte": memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName); readCode += string.Format(PacketFormat.readByteFormat, memberName, memberType); writeCode += string.Format(PacketFormat.writeByteFormat, memberName, memberType); break; case "bool": case "short": case "ushort": case "int": case "long": case "float": case "double": memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName); readCode += string.Format(PacketFormat.readFormat, memberName, ToMemberType(memberType), memberType); writeCode += string.Format(PacketFormat.writeFormat, memberName, memberType); break; case "string": memberCode += string.Format(PacketFormat.memberFormat, memberType, memberName); readCode += string.Format(PacketFormat.readStringFormat, memberName); writeCode += string.Format(PacketFormat.writeStringFormat, memberName); break; case "list": Tuple<string, string, string> tuple = ParseList(reader); memberCode += tuple.Item1; readCode += tuple.Item2; writeCode += tuple.Item3; break; default: break; } } // ... } // ... } }
# Packet Generator #4
- Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용을 수동으로 ServerSession 과 ClientSession class에
추가하였는데 해당 부분을 자동화하기 위한 Template을 만들고자 한다.
- 출력 경로는 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 속성 ] - [ 빌드 ] - [ 일반 ] 의 [ 출력 ] 에서 설정 가능하다.
~> 그 후 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] 에서
"프로젝트이름.csproj" 파일을 메모장으로 연 뒤 <PropertyGroup></PropertyGroup> 사이에
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> 를 추가하고,
<BaseOutputPath>출력 경로</BaseOutputPath> 를 <OutputPath>출력 경로</OutputPath> 로 수정한다.
~> 모든 설정이 끝난 뒤 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 빌드 ] 시 설정한 출력 경로에 실행파일이 생성된다.
- 배치 파일은 명령 인터프리터에 의해 실행되게끔 공안된 명령어들이 나열된 텍스트 파일이다.
~> .bat 또는 .cmd 형식의 확장자 파일을 직접 실행하거나 명령 프롬프트에서 배치 파일의 이름으로 실행할 수 있다.
~> 배치파일을 통해 [ 프로젝트 ] - [ 오른쪽 마우스 ] - [ 파일 탐색기에서 폴더 열기 ] - [ bin ] 내의 프로젝트 실행파일을
대신 실행한 뒤 실행 파일의 결과에 저장된 내용을 자동으로 복사할 예정이다. (출력 경로를 /bin 으로 설정)
~> START는 응용 프로그램 실행 명령어로 실행하고자 하는 파일의 경로와 인자를 넘겨준다.
~> XCOPY는 파일 복사 명령어로 복사 대상과 복사 위치를 넘겨준다.
(/Y 옵션은 같은 파일이 있는 경우 무조건 덮어쓴다는 것이다.)
GenPackets.bat 배치 파일 생성 (배치 파일은 [ 솔루션 ] - [ Common ] - [ Packet ] 내에 존재)
(배치 파일 실행시 PDL.xml을 인자로 넘긴 Packet Generator가 실행되어 DummyClient/Packet 과 Server/Packet 산하의 GenPackets에 Packet Generator의 결과가 저장된 "GenPackets.cs" 파일의 내용이 자동으로 복사된다.)
START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml XCOPY /Y GenPackets.cs "../../DummyClient/Packet" XCOPY /Y GenPackets.cs "../../Server/Packet"
PacketGenerator 코드 수정using System.Xml; namespace PacketGenerator { internal class Program { // ... static void Main(string[] args) { string pdlPath = "../PDL.xml"; // bin 폴더가 아닌 PacketGenerator 폴더에 있는 PDL.xml 찾기 위한 것 (실행 파일이 위치한 곳 기준으로 이동) // ... if (args.Length >= 1) // 프로그램 실행시 인자로 무언가를 넘겨준 경우 pdlPath = args[0]; // pdlPath를 전달받은 인자로 초기화 using (XmlReader reader = XmlReader.Create(pdlPath, settings)) { // ... } // ... } // ... } }
# Packet Generator #5
- 게임 규모가 커질수록 패킷의 종류는 상당히 많아진다.
~> 즉, ClientSession Class의 OnRecvPacket() 함수 안의 switch-case문 길이가 상당히 길어질 수 있다.
- switch-case문 및 OnRecvPacket() 함수 자동화를 위해 모든 패킷들이 base interface를 상속받도록 한다.
~> 모든 패킷에 대해 공통 인수로 넘길 수 있기 때문에 편리하다는 장점을 가진다.
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // {0} 패킷 이름/번호 목록 // {1} 패킷 목록 public static string fileFormat = @" using System; using System.Collections.Generic; using System.Text; using System.Net; using ServerCore; public enum PacketID {{ {0} }} interface IPacket {{ ushort Protocol {{ get; }} void Read(ArraySegment<byte> openSegment); ArraySegment<byte> Write(); }} {1} "; // ... // {0} 패킷 이름 // {1} 멤버 변수들 // {2} 멤버 변수 Read // {3} 멤버 변수 Write public static string packetFormat = @" public class {0} : IPacket {{ {1} public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }} public void Read(ArraySegment<byte> openSegment) {{ ushort count = 0; ReadOnlySpan<byte> span = new ReadOnlySpan<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); count += sizeof(ushort); {2} }} public ArraySegment<byte> Write() {{ ArraySegment<byte> openSegment = SendBufferHelper.Open(4096); bool success = true; ushort count = 0; Span<byte> span = new Span<byte>(openSegment.Array, openSegment.Offset, openSegment.Count); count += sizeof(ushort); success &= BitConverter.TryWriteBytes(span.Slice(count, span.Length - count), (ushort)PacketID.{0}); count += sizeof(ushort); {3} success &= BitConverter.TryWriteBytes(span, count); if (success == false) return null; return SendBufferHelper.Close(count); }} }} "; // ... } }
DummyClient/Packet 과 Server/Packet 산하에 PacketHandler Class 생성using ServerCore; using System; using System.Collections.Generic; using System.Text; namespace Server { class PacketHandler { // 해당 패킷이 전부 조립된 경우 무엇을 할까? // PacketHandler는 자동화 없이 수동으로 추가 public static void PlayerInfoReqHandler(PacketSession session, IPacket packet) { PlayerInfoReq p = packet as PlayerInfoReq; Console.WriteLine($"PlayerInfoReq: {p.playerId} {p.name}"); foreach (PlayerInfoReq.Skill skill in p.skills) { Console.WriteLine($"Skill({skill.id})({skill.level})({skill.duration})"); } } } }
DummyClient/Packet 과 Server/Packet 산하에 PacketManager Class 생성using ServerCore; using System; using System.Collections.Generic; using System.Text; namespace Server { class PacketManager { // PacketManager는 Singleton 패턴 사용 #region Singleton static PacketManager _instance; public static PacketManager Instance { get { if (_instance == null) _instance = new PacketManager(); return _instance; } } #endregion // 구분하기 위한 Protocol ID, 어떤 작업을 수행할지 Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>(); // 구분하기 위한 Protocol ID, 어떤 Handler를 호출할지 Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>(); public void Register() // 추후 자동화할 예정 { _onRecv.Add((ushort)PacketID.PlayerInfoReq, MakePacket<PlayerInfoReq>); _handler.Add((ushort)PacketID.PlayerInfoReq, PacketHandler.PlayerInfoReqHandler); } public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer) { // 패킷을 분해하여 id 에 대한 정보를 얻은 뒤 ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; // 이젠 switch-case문이 아닌 Dictionary에서 찾아 Invoke() Action<PacketSession, ArraySegment<byte>> action = null; if (_onRecv.TryGetValue(id, out action)) action.Invoke(session, buffer); } void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new() { T packet = new T(); packet.Read(buffer); // 역직렬화 // Dictionary에서 찾아 Invoke() Action<PacketSession, IPacket> action = null; if (_handler.TryGetValue(packet.Protocol, out action)) action.Invoke(session, packet); } } }
ClientSession Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Server { class ClientSession : PacketSession { // ... public override void OnRecvPacket(ArraySegment<byte> buffer) { PacketManager.Instance.OnRecvPacket(this, buffer); } // ... } }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { // ... static void Main(string[] args) { // MultiThread가 개입하지 않는 부분에서 실행 PacketManager.Instance.Register(); // ... } } }
# Packet Generator #6
- PacketManager 를 자동화하기 위한 Template을 만들고자 한다.
~> 양방향 패킷은 거의 존재하지 않는다. (대부분 Client에서 Server 또는 Server에서 Client)
~> 그러나 분산 Server인 경우 Client와 Server만 소통하는 것이 아닌 Server 끼리도 소통한다.
~> 패킷 이름에 규칙을 설정하고 이를 통해 파일을 분리한다.
(패킷 이름 앞에 C_ 가 붙은 것은 Client에서 Server로, S_ 가 붙은 것은 Server에서 Client로)
(PacketManager 를 자동화할때 Server 쪽에 추가되는 PacketManager의 Register에는 C_가 붙은 것들만,
DummyClient 쪽에 추가되는 PacketManager의 Register에는 S_가 붙은 것들만 등록한다.)
(즉, Register에 온갖 패킷을 등록하는 것이 아닌 필요한 패킷들만 등록한다.)
PDL XML 파일 수정<?xml version="1.0" encoding="utf-8" ?> <PDL> <packet name="C_PlayerInfoReq"> <sbyte name="testByte"/> <long name="playerId"/> <string name="name"/> <list name="skill"> <int name="id"/> <short name="level"/> <float name="duration"/> </list> </packet> <packet name="Test"> <int name="testInt"/> </packet> </PDL>
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // {0} 패킷 등록 public static string managerFormat = @" using ServerCore; using System; using System.Collections.Generic; class PacketManager {{ #region Singleton static PacketManager _instance; public static PacketManager Instance {{ get {{ if (_instance == null) _instance = new PacketManager(); return _instance; }} }} #endregion Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>(); Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>(); public void Register() {{ {0} }} public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer) {{ ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; Action<PacketSession, ArraySegment<byte>> action = null; if (_onRecv.TryGetValue(id, out action)) action.Invoke(session, buffer); }} void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new() {{ T packet = new T(); packet.Read(buffer); Action<PacketSession, IPacket> action = null; if (_handler.TryGetValue(packet.Protocol, out action)) action.Invoke(session, packet); }} }} "; // {0} 패킷 이름 public static string managerRegisterFormat = @" _onRecv.Add((ushort)PacketID.{0}, MakePacket<{0}>); _handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler); "; // ... } }
PacketGenerator 코드 수정using System.Xml; namespace PacketGenerator { internal class Program { // ... static string clientRegister; static string serverRegister; static void Main(string[] args) { // ... string clientManagerText = string.Format(PacketFormat.managerFormat, clientRegister); File.WriteAllText("ClientPacketManager.cs", clientManagerText); string serverManagerText = string.Format(PacketFormat.managerFormat, serverRegister); File.WriteAllText("ServerPacketManager.cs", serverManagerText); } public static void ParsePacket(XmlReader reader) { // ... if (packetName.StartsWith("S_") || packetName.StartsWith("s_")); clientRegister += string.Format(PacketFormat.managerRegisterFormat, packetName) + Environment.NewLine; else serverRegister += string.Format(PacketFormat.managerRegisterFormat, packetName) + Environment.NewLine; } // ... } }
GenPackets.bat 배치 파일 수정START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml XCOPY /Y GenPackets.cs "../../DummyClient/Packet" XCOPY /Y GenPackets.cs "../../Server/Packet" XCOPY /Y ClientPacketManager.cs "../../DummyClient/Packet" XCOPY /Y ServerPacketManager.cs "../../Server/Packet"
[ 섹션 4. Job Queue ]
# 채팅 테스트 #1
- Server 구현시 대부분 채팅 프로그램을 통해 테스트가 이루어진다.
- 우선 ServerCore에 예외 처리를 하지 않은 부분이 존재하기 때문에 이를 수정할 예정이다.
~> Disconnect() 중복 호출은 예방하였으나, Disconnect() 호출시 Send와 Receive는 끊김에 대한 예방이 없다.
(동시다발적으로 누군가는 Disconnect를 통해 socket을 shutdown하고, 누군가는 Send나 Receive를 호출시 문제 발생)
- 또한 채팅 테스트를 위해 Server 입장에서 코드 추가 및 수정을 할 예정이다.
PDL XML 파일 수정<?xml version="1.0" encoding="utf-8" ?> <PDL> <packet name="C_Chat"> <string name="chat"/> </packet> <packet name="S_Chat"> <int name="playerId"/> <string name ="chat"/> </packet> </PDL>
예외 처리를 위해 Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { // ... public abstract class Session { // ... void Clear() // _sendQueue 와 _pendingList 를 초기화하기 위한 함수 추가 { lock (_lock) { _sendQueue.Clear(); _pendingList.Clear(); } } // ... public void Disconnect() { // ... Clear(); // _sendQueue 와 _pendingList 를 초기화 } void RegisterSend() { if (_disconnected == 1) // 최소한의 예방책 return; while (_sendQueue.Count > 0) { ArraySegment<byte> buff = _sendQueue.Dequeue(); _pendingList.Add(buff); } _sendArgs.BufferList = _pendingList; // socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책) // ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문 try { bool pending = _socket.SendAsync(_sendArgs); if (pending == false) OnSendCompleted(null, _sendArgs); } catch (Exception e) { Console.WriteLine($"RegisterSend Failed {e}"); } } // ... void RegisterRecv() { if (_disconnected == 1) // 최소한의 예방책 return; _recvBuffer.Clean(); ArraySegment<byte> segment = _recvBuffer.WriteSegment; _recvArgs.SetBuffer(segment.Array, segment.Offset, segment.Count); // socket을 다루는 부분을 try-catch문으로 감싸준다. (MultiThread 환경을 위한 예방책) // ~> 누군가는 위의 if문을 통과하여 아래 부분을 마저 실행하려고 하는 도중에 다른 Thread에서 socket을 disconnect시 문제가 발생하기 때문 try { bool pending = _socket.ReceiveAsync(_recvArgs); if (pending == false) OnRecvCompleted(null, _recvArgs); } catch(Exception e) { Console.WriteLine($"RegisterRecv Failed {e}"); } } // ... } }
Server에 GameRoom Class 생성using System; using System.Collections.Generic; using System.Text; namespace Server { class GameRoom { List<ClientSession> _sessions = new List<ClientSession>(); // GameRoom에 존재하는 session들 object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성 public void Broadcast(ClientSession session, string chat) // 현재 session이 접속중인 방에 존재하는 모두에게 chat을 뿌린다. { S_Chat packet = new S_Chat(); packet.playerId = session.SessionId; packet.chat = chat; ArraySegment<byte> segment = packet.Write(); lock(_lock) { foreach (ClientSession s in _sessions) s.Send(segment); } } public void Enter(ClientSession session) // 방 입장 { lock (_lock) { _sessions.Add(session); session.Room = this; } } public void Leave(ClientSession session) // 방 퇴장 { lock (_lock) { _sessions.Remove(session); } } } }
Server에 SessionManager Class 생성using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Server { class SessionManager // SessionManager는 Engine 쪽에서 관리해도 되고, Content 쪽에서 관리해도 된다. (선택의 차이) { // SessionManager는 Singleton 패턴 사용 static SessionManager _session = new SessionManager(); public static SessionManager Instance { get { return _session; } } int _sessionId = 0; // session을 구분하기 위한 Id Dictionary<int, ClientSession> _sessions = new Dictionary<int, ClientSession>(); // 현재 존재하는 session들 object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성 public ClientSession Generate() // session 생성 { lock (_lock) { int sessionId = ++_sessionId; ClientSession session = new ClientSession(); session.SessionId = sessionId; _sessions.Add(sessionId, session); Console.WriteLine($"Connected : {sessionId}"); return session; } } public ClientSession Find(int id) // sessionId를 통해 session을 찾는 함수 { lock (_lock) { ClientSession session = null; _sessions.TryGetValue(id, out session); return session; } } public void Remove(ClientSession session) // session 삭제 { lock (_lock) { _sessions.Remove(session.SessionId); } } } }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { static Listener _listener = new Listener(); public static GameRoom Room = new GameRoom(); // GameRoom 생성 (딴 곳에서도 접근이 가능하도록 public으로 생성) static void Main(string[] args) { PacketManager.Instance.Register(); string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); // 손님을 입장시킨다. _listener.Init(endPoint, () => { return SessionManager.Instance.Generate(); }); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정 Console.WriteLine("Listening..."); while (true) { } } } }
ClientSession 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Server { class ClientSession : PacketSession { public int SessionId { get; set; } // Session 구분을 위해 public GameRoom Room { get; set; } // 현재 어떤 방에 위치하는지 알기 위해 public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출) Thread.Sleep(5000); Disconnect(); } // ... public override void OnDisconnected(EndPoint endPoint) { SessionManager.Instance.Remove(this); // 내 자신을 (즉, session을) sessionManager를 통해 삭제 요청 if (Room != null) // { Room.Leave(this); Room = null; } Console.WriteLine($"OnDisconnected : {endPoint}"); } // ... } }
Server의 PacketHandler 수정using Server; using ServerCore; using System; using System.Collections.Generic; using System.Text; class PacketHandler { // 해당 패킷이 전부 조립된 경우 무엇을 할까? // PacketHandler는 자동화 없이 수동으로 추가 public static void C_ChatHandler(PacketSession session, IPacket packet) { C_Chat chatPacket = packet as C_Chat; ClientSession clientSession = session as ClientSession; if (clientSession.Room == null) return; clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다. } }
# 채팅 테스트 #2
- 채팅 테스트를 위해 Client 입장에서 코드 추가 및 수정을 할 예정이다.
~> 현재 1명의 유저만 접속하고 있는 상황이므로, 이를 다수의 유저들이 접속하는 상황으로 가정하고 변경할 예정이다.
- 기존 Server의 Register 부분을 생성자를 통해 자동으로 생성하도록 변경할 예정이다.
ServerSession Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace DummyClient { class ServerSession : PacketSession // Session이 아닌 PacketSession 을 상속 받도록 수정 { public override void OnConnected(EndPoint endPoint) { Console.WriteLine($"OnConnected : {endPoint}"); } public override void OnDisconnected(EndPoint endPoint) { Console.WriteLine($"OnDisconnected : {endPoint}"); } public override void OnRecvPacket(ArraySegment<byte> buffer) // PacketSession 을 상속 받으므로 OnRecv가 아닌 OnRecvPacket으로 수정 (반환값도 int가 아닌 void로) { PacketManager.Instance.OnRecvPacket(this, buffer); } public override void OnSend(int numOfBytes) { // Console.WriteLine($"Transferred bytes : {numOfBytes}"); ~> session이 많아지면 OnSend() 가 자주 호출되므로 일단은 출력되지 않도록 주석 처리 } } }
ServerCore의 Connector 수정using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; namespace ServerCore { public class Connector { Func<Session> _sessionFactory; public void Connect(IPEndPoint endPoint, Func<Session> sessionFactory, int count = 1) // 다수의 Client 환경에서 Test 하고 싶을 수도 있기 때문에 { for (int i = 0; i < count; i++) // 입력받은 매개변수 count 만큼 아래의 과정을 반복하도록 수정 { // 휴대폰 설정 Socket socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _sessionFactory = sessionFactory; SocketAsyncEventArgs args = new SocketAsyncEventArgs(); args.Completed += OnConnectCompleted; args.RemoteEndPoint = endPoint; args.UserToken = socket; RegisterConnect(args); } } // ... } }
DummyClient에 SessionManager Class 생성using System; using System.Collections.Generic; using System.Text; namespace DummyClient { class SessionManager { // SessionManager는 Singleton 패턴 사용 static SessionManager _session = new SessionManager(); public static SessionManager Instance { get { return _session; } } List<ServerSession> _sessions = new List<ServerSession>(); // 현재 존재하는 session들 object _lock = new object(); // List나 Dictionary 등 대부분의 자료 구조들은 MultiThread 환경에서 잘 돌아간다는 보장이 없기 때문에 lock 생성 public void SendForEach() // Server쪽으로 채팅 패킷을 전송 { foreach (ServerSession session in _sessions) { C_Chat chatPacket = new C_Chat(); chatPacket.chat = $"Hello Server!"; ArraySegment<byte> segment = chatPacket.Write(); session.Send(segment); } } public ServerSession Generate() // session 생성 { lock (_lock) { ServerSession session = new ServerSession(); _sessions.Add(session); return session; } } } }
DummyClient 코드 수정using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using ServerCore; namespace DummyClient { class Program { static void Main(string[] args) { string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); Connector connector = new Connector(); connector.Connect(endPoint, () => { return SessionManager.Instance.Generate(); }, 10); // new를 통해 Session을 생성하는 것이 아닌 SessionManager를 통해 생성하도록 수정, 원하는 Client 수를 인자로 넘김 while (true) { try { SessionManager.Instance.SendForEach(); // 모든 Session들이 Server쪽으로 계속해서 채팅 패킷을 쏘도록 } catch (Exception e) { Console.WriteLine(e.ToString()); } Thread.Sleep(250); // 0.25초 휴식 } } } }
DummyClient의 PacketHandler 수정using DummyClient; using ServerCore; using System; using System.Collections.Generic; using System.Text; class PacketHandler { // C_Chat을 Server에 보낸뒤, Server는 방에 있는 모든 애들에게 S_Chat으로 답장을 주는 부분을 다룬다. public static void S_ChatHandler(PacketSession session, IPacket packet) { S_Chat chatPacket = packet as S_Chat; ServerSession serverSession = session as ServerSession; Console.WriteLine(chatPacket.chat); } }
PacketFormat Class 수정
(Main에서 PacketManager.Instance.Register(); 를 직접 입력하는 것이 아닌 생성자를 통해 자동으로 생성되도록 수정)using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // {0} 패킷 등록 public static string managerFormat = @" using ServerCore; using System; using System.Collections.Generic; class PacketManager {{ #region Singleton static PacketManager _instance = new PacketManager(); public static PacketManager Instance {{ get {{ return _instance; }} }} #endregion PacketManager() {{ Register(); }} Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>> _onRecv = new Dictionary<ushort, Action<PacketSession, ArraySegment<byte>>>(); Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>(); public void Register() {{ {0} }} public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer) {{ ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; Action<PacketSession, ArraySegment<byte>> action = null; if (_onRecv.TryGetValue(id, out action)) action.Invoke(session, buffer); }} void MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new() {{ T packet = new T(); packet.Read(buffer); Action<PacketSession, IPacket> action = null; if (_handler.TryGetValue(packet.Protocol, out action)) action.Invoke(session, packet); }} }} "; // ... } }
- 위의 결과로 GameRoom 안에 존재하는 10명의 유저들에게 채팅 패킷을 뿌려주고 있다는 것을 알 수 있다.
~> 그러나 위와 같은 방식을 MMORPG에 도입할 경우 속도가 상당히 느려질 수 있다.
(유저의 수가 증가할수록, 패킷을 뿌리는 양이 많아지므로)
- 위의 Thread들은 모두 Broadcast의 lock 부분에서 대기중이다.
~> 이는 당연한 결과이다. 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 만약 100명의
유저가 있다고 가정하면 100 * 100 = 10,000번이므로 1초에 40,000번 동작하고 있기 때문이다. (250 * 4 = 1000 = 1초)
이에 lock 부분에 동시다발적으로 수많은 Thread들이 들어오지만 lock 때문에 1번에 1개의 Thread만 처리할 수 있다.
~> 따라서 위의 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지
않았기 때문에 다시 Thread를 보내고 있는 상황이 발생한다. 이에 Thread가 계속해서 쌓이게 되는 것이다.
- 이러한 문제가 발생하는 이유는 Recv를 하자마자, lock을 통해 패킷을 전송하였기 때문이다.
~> 해결 방법으로는 하나의 Thread만 Queue에 쌓여있는 일감을 처리하고, 다른 Thread들의 일감은 Queue에 담아두는
것이다. (이를 Job 또는 Task라고 하며, 중요한 것은 패킷을 저장하고 하나의 Thread에서 처리하는 것)
# Command 패턴
- 다음 시간에 만들어볼 Job 또는 Task를 관리하는 Queue가 전형적인 Command 패턴의 예제이다.
- 손님을 대리하는 ClientSession이 직원인 Thread에게 주문을 한다.
- 만약 직원이 서빙, 요리, 계산을 전부 도맡아 할 경우 주문을 받자마자 주방에 달려가 요리를 바로 시작할 것이다.
~> 지금까지 구현된 코드가 위와 비슷하다.
- 만약 주방의 크기가 너무 작아 동시에 1명만 요리가 가능한 경우 주문을 받은 직원들은 주방 앞에서 자신의 요리 차례가
올때까지 계속해서 기다리게 된다.
- 모든 직원들이 자신의 요리 차례를 기다리고 있기 때문에 주문을 받을 직원이 부족한 경우 식당은 직원을 더 고용한다.
~> 지금까지 구현된 코드의 결과가 위와 비슷하다.
- 위와 같이 1명의 직원이 서빙, 요리, 계산을 모두 담당하는 것이 아닌 직원들 각각이 업무를 분담받도록 한다.
~> 서빙을 담당하는 직원이 주문을 받아 주문서를 주방장에게 전달한다.
~> 이러한 방법이 command 패턴과 유사하다.
- command 패턴이란?
~> command 패턴은 객체 지향 디자인 패턴 중 하나로, 객체 간의 결합도를 낮추고 유연성을 높이는 패턴이다.
~> command 패턴의 주요 목적은 사용자가 보낸 요청을 객체의 형태로 캡슐화하여 이를 나중에 이용할 수 있도록
이름, 매개변수 등 요청에 필요한 정보를 저장 또는 로깅, 취소할 수 있도록 하는 패턴으로, 이를 통해 메소드를
호출하는 Class와 메소드를 구현하는 Class 사이의 결합을 느슨하게 만든다.
~> 따라서 Client가 요청의 수신자를 알 필요 없이 다양한 요청을 보낼 수 있게 된다.
+ 추가 검색 (https://bamtory29.tistory.com/entry/%EC%BB%A4%EB%A7%A8%EB%93%9C-%ED%8C%A8%ED%84%B4-Command-Pattern)
Command 인터페이스public interface Command { public void execute(); }
ComputerOnCommand Classpublic class ComputerOnCommand implements Command { private Computer computer; public ComputerOnCommand(Computer computer) { this.computer = computer; } @Override public void execute() { computer.turnOn(); } }
ComputerOffCommand Classpublic class ComputerOffCommand implements Command { private Computer computer; public ComputerOffCommand(Computer computer) { this.computer = computer; } @Override public void execute() { computer.turnOff(); } }
Computer Classpublic class Computer { public void Computer() {} public void turnOn() { System.out.println("컴퓨터 전원 켜짐"); } public void turnOff() { System.out.println("컴퓨터 전원 꺼짐"); } }
Button Classpublic class Button { private Command command; public Button(Command command) { this.command = command; } public void setCommand(Command command) { this.command = command; } public void pressButton() { this.command.execute(); } }
Main 메소드public static void main(String[] args) { Computer computer = new Computer(); //컴퓨터는 Receiver //컴퓨터 객체 생성 ComputerOnCommand computerOnCmd = new ComputerOnCommand(computer); ComputerOffCommand computerOffCmd = new ComputerOffCommand(computer); Button btn = new Button(computerOnCmd); //버튼이 Invoker 역할 btn.pressButton(); btn.setCommand(computerOffCmd); btn.pressButton(); }
# JobQueue #1
- 지난 시간 학습한 Comman 패턴을 활용하여 JobQueue를 구현할 예정이다.
~> Queue에 쌓인 일감을 처리하는 Thread는 Push시 _jobQueue에 처음으로 일감을 밀어 넣는 Thread가 담당한다.
ServerCore에 JobQueue Class 생성using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ServerCore { public interface IJobQueue { void Push(Action job); } public class JobQueue : IJobQueue // IJobQueue를 상속 받는다. { Queue<Action> _jobQueue = new Queue<Action>(); // 일감 목록을 담는 곳 object _lock = new object(); // MultiThread 환경을 위한 lock 선언 bool _flush = false; // Queue에 쌓인 일감들을 본인이 처리할 것인지 아닌지 public void Push(Action job) // _jobQueue에 일감을 밀어 넣기 위한 함수 { bool flush = false; // MultiThread 환경에서 단 1개의 Thread만 일감 처리를 담당하도록 lock (_lock) { _jobQueue.Enqueue(job); if (_flush == false) // _flush가 false인 경우 Queue에 쌓인 일감들을 본인이 처리 flush = _flush = true; } if (flush) Flush(); } void Flush() { while (true) { Action action = Pop(); if (action == null) return; action.Invoke(); } } Action Pop() // 일감 처리를 위해 _jobQueue에서 일감을 꺼내기 위한 함수 { lock (_lock) { if (_jobQueue.Count == 0) { _flush = false; return null; } return _jobQueue.Dequeue(); } } } }
GameRoom Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Text; namespace Server { class GameRoom : IJobQueue // IJobQueue를 상속 받는다. { // ... JobQueue _jobQueue = new JobQueue(); public void Push(Action job) { _jobQueue.Push(job); } // ... } }
ClientSession Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; namespace Server { class ClientSession : PacketSession { // ... public override void OnConnected(EndPoint endPoint) { // ... // 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것 Program.Room.Push(() => Program.Room.Enter(this)); // Program.Room.Enter(this); // Client가 접속시 방에 입장시킨다. (Program 산하에 static으로 Room을 생성하였기 때문에 다음과 같이 호출) // ... } // ... public override void OnDisconnected(EndPoint endPoint) { // ... if (Room != null) { // 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것 GameRoom room = Room; room.Push(() => room.Leave(this)); //Room.Leave(this); Room = null; } // ... } // ... } }
PacketHandler Class 수정using Server; using ServerCore; using System; using System.Collections.Generic; using System.Text; class PacketHandler { public static void C_ChatHandler(PacketSession session, IPacket packet) { // ... // 실행 도중 Client 종료시 Null Crash 방지를 위한 것 & 바로 실행하는 것이 아닌 일감을 _jobQueue에 담는 것 GameRoom room = clientSession.Room; room.Push(() => room.Broadcast(clientSession, chatPacket.chat)); // clientSession.Room.Broadcast(clientSession, chatPacket.chat); // 현재 clientSession이 접속중인 방에 존재하는 모두에게 채팅 메시지를 뿌린다. } }
# JobQueue #2
- 지난 시간에 구현한 JobQueue와 똑같이 동작하지만 수동적으로 Task를 만들어주는 방법에 대해 알아볼 예정이다.
~> 람다식 개념이 등장한지 얼마 되지 않아 현업과 같은 실무에서는 수동적으로 일 처리가 필요한 함수들을 구현해서
처리하는 형식이 자주 사용된다.
~> 그러나 위와 같이 수동적인 방법은 일 처리가 필요한 함수를 모두 구현해야 한다는 단점이 존재한다.
Server에 TaskQueue Class 생성
(_queue에 쌓인 일감 처리는 지난 시간의 Flush() 와 같은 메소드에서 처리하도록)using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace Server { interface ITask { void Execute(); } // 일 처리가 필요한 함수에 맞춰 클래스를 생성 class BroadcastTask : ITask { // 함수 실행시 필요한 변수들을 선언 GameRoom _room; ClientSession _session; string _chat; // 생성자를 통한 변수 초기화 BroadcastTask(GameRoom room, ClientSession session, string chat) { _room = room; _session = session; _chat = chat; } public void Execute() { _room.Broadcast(_session, _chat); } } class TaskQueue { Queue<ITask> _queue = new Queue<ITask>(); } }
부하 Test를 위한 Listener Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { public class Listener { Socket _listenSocket; Func<Session> _sessionFactory; // 문지기 수를 10명으로 증가, 최대 대기수를 100명으로 증원 public void Init(IPEndPoint endPoint, Func<Session> sessionFactory, int register = 10, int backlog = 100) { // 문지기 고용 _listenSocket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); _sessionFactory += sessionFactory; // 문지기 교육 _listenSocket.Bind(endPoint); // 영업 시작 // backlog : 최대 대기 수 _listenSocket.Listen(backlog); for (int i = 0; i < register; i++) { SocketAsyncEventArgs args = new SocketAsyncEventArgs(); args.Completed += new EventHandler<SocketAsyncEventArgs>(OnAcceptCompleted); RegisterAccept(args); } } // ... } }
- 만약 Client 접속 인원을 500명으로 늘린 뒤에 실행할 경우 메모리가 계속해서 상승하는 것을 볼 수 있다.
~> 이는 수많은 작업들이 밀리게 되면서 Thread를 관리하는 입장에서는 Thread를 보냈으나 일 처리가 완료되지 않았기
때문에 ThreadPool에서 새로운 Thread를 뽑아 보내고 있기 때문에 메모리가 계속해서 상승하는 것이다.
- 가장 심한 부하를 유발하는 곳은 Broadcast의 foreach문 내의 Send이다.
~> 왜냐하면 Thread.Sleep(250) 을 통해 0.25초에 1번 동작하도록 설정하였으므로 500명의 유저가 있다고 가정하면
500 * 500 = 250,000번이므로 1초에 1,000,000번 동작하고 있기 때문이다.
~> 이는 N^2의 시간 복잡도를 가진다.
~> 해결 방법으로는 패킷 요청이 올때마다 바로 Send 하는 것이 아닌 패킷을 모은 뒤 추후에 한번에 보내는 것이다.
+ 추가 검색 (https://shung2.tistory.com/1445)
- Zone 형식의 게임은 맵을 넘어갈 때 로딩 시간이 있기때문에 하나의 Zone 단위에 JobQueue를 넣어 처리하도록 만들면
되므로 비교적 JobQueue 활용 처리가 용이하다.
- 그러나 Seamless 게임은 넓은 맵에서 임의로 영역을 구분하여 JobQueue를 두고 처리할 수는 있겠지만, 이렇게 된다면
구분된 영역 사이에서는 어떻게 처리할 지에 대한 의문점이 생길 수 밖에 없다.
~> JobQueue를 모든 객체들에게 넣는 방법이 있다. (즉, User, Monster, Skill ...)
+ 추가 검색 (https://drehzr.tistory.com/1683)
# 패킷 모아 보내기
- 패킷을 모아 보내는 것은 Engine 쪽에서도 처리할 수 있고 Content 쪽에서도 처리할 수 있다.
~> 만약 Engine 쪽에서 처리하고자 할 경우 Session의 Send() 메소드에서 처리가 가능하다.
(어느정도 쌓아둔 뒤 일정 조건에 따라 보내는 구조로 변경)
~> 그러나 이번 시간에는 Content 쪽에서 패킷 모아 보내기를 구현할 예정이다.
~> 패킷이 불규칙하게 전송될 경우 RecvBuffer의 크기를 늘려주면 된다.
Session Class 수정using System; using System.Text; using System.Net; using System.Net.Sockets; namespace ServerCore { // ... public abstract class Session { // ... // List<ArraySegment<byte>> 를 매개변수로 받아 List에 담긴 패킷들을 전부 처리하는 Send 메소드 추가 public void Send(List<ArraySegment<byte>> sendBuffList) { if (sendBuffList.Count == 0) return; lock (_lock) { foreach (ArraySegment<byte> sendBuff in sendBuffList) _sendQueue.Enqueue(sendBuff); if (_pendingList.Count == 0) RegisterSend(); } } // 기존의 Send 메소드 public void Send(ArraySegment<byte> sendBuff) { lock (_lock) { _sendQueue.Enqueue(sendBuff); if (_pendingList.Count == 0) RegisterSend(); } } // ... } }
GameRoom Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Text; namespace Server { class GameRoom : IJobQueue { // ... List<ArraySegment<byte>> _pendingList = new List<ArraySegment<byte>>(); // 패킷을 모으기 위한 List // ... public void Flush() // 모아둔 패킷을 처리 { foreach (ClientSession s in _sessions) s.Send(_pendingList); Console.WriteLine($"Flushed {_pendingList.Count} items"); _pendingList.Clear(); } // .. } }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { // ... static void Main(string[] args) { // ... while (true) { Room.Push(() => Room.Flush()); Thread.Sleep(250); } } } }
# JobTimer
- GameRoom 뿐만이 아닌 다양한 Room들이 추가될 경우 각각의 룸들은 서로 다른 대기시간을 가지며 실행된다.
~> 첫번째로는 Tick을 이용하는 방법이 있다.
~> 두번째로는 PriorityQueue를 이용하는 방법이 있다.
Tick을 통해 처리하기 위한 Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { // ... static void Main(string[] args) { // ... int roomTick = 0; // room이 추가될때마다 Tick 변수도 추가된다. while (true) { int now = System.Environment.TickCount; if (roomTick < now) // 그러나 이와 같은 방법은 불필요한 if문 체크가 반복된다. { Room.Push(() => Room.Flush()); roomTick = now + 250; } // room이 추가될때마다 if문도 추가된다. } } } }
ServerCore에 PriorityQueue Class 생성using System; using System.Collections.Generic; using System.Text; namespace ServerCore { public class PriorityQueue<T> where T : IComparable<T> { List<T> _heap = new List<T>(); public int Count { get { return _heap.Count; } } // 0 (logN) public void Push(T data) { // 힙의 맨 끝에 새로운 데이터를 삽입한다. _heap.Add(data); int now = _heap.Count - 1; // 도장깨기를 시작 while (now > 0) { // 도장깨기를 시도 int next = (now - 1) / 2; if (_heap[now].CompareTo(_heap[next]) < 0) // 대상값이 비교값과 같은 경우 0, 작은 경우 -1, 큰 경우 1 ( 대상값.CompareTo(비교값) ) break; // 실패 // 두 값을 교체한다. T temp = _heap[now]; _heap[now] = _heap[next]; _heap[next] = temp; // 검사 위치를 이동한다. now = next; } } // 0 (logN) public T Pop() { // 반환할 데이터를 따로 저장 T ret = _heap[0]; // 마지막 데이터를 루트로 이동한다. int lastIndex = _heap.Count - 1; _heap[0] = _heap[lastIndex]; _heap.RemoveAt(lastIndex); lastIndex--; // 역으로 내려가는 도장깨기 시작 int now = 0; while (true) { int left = 2 * now + 1; int right = 2 * now + 2; int next = now; // 왼쪽값이 현재값보다 크면, 왼쪽으로 이동 if (left <= lastIndex && _heap[next].CompareTo(_heap[left]) < 0) next = left; // 오른쪽값이 현재값(왼쪽 이동 포함)보다 크면, 오른쪽으로 이동 if (right <= lastIndex && _heap[next].CompareTo(_heap[right]) < 0) next = right; // 왼쪽/오른쪽 모두 현재값보다 작으면 종료 if (next == now) break; // 두 값을 교체한다. T temp = _heap[now]; _heap[now] = _heap[next]; _heap[next] = temp; // 검사 위치를 이동한다. now = next; } return ret; } public T Peek() // 일감 내용 확인을 위한 함수 { if (_heap.Count == 0) return default(T); return _heap[0]; } } }
Server에 JobTimer Class 생성using System; using System.Collections.Generic; using System.Text; using ServerCore; namespace Server { struct JobTimerElem : IComparable<JobTimerElem> // 하나의 일감 단위 { public int execTick; // 실행 시간 public Action action; // 일감 (즉, Job) // IComparable 인터페이스의 필수 구성 요소인 CompareTo() public int CompareTo(JobTimerElem other) { // 실행 시간이 적을수록 먼저 튀어나오도록 (즉, 우선순위가 높은 항목) return other.execTick - execTick; } } class JobTimer // Job을 예약할 수 있는 시스템 { // 우선순위 큐는 대소관계 연산 처리 속도가 상당히 빠르다. PriorityQueue<JobTimerElem> _pq = new PriorityQueue<JobTimerElem>(); object _lock = new object(); // MultiThread 환경을 위한 lock 선언 public static JobTimer Instance { get; } = new JobTimer(); public void Push(Action action, int tickAfter = 0) // 예약하고자 하는 일감, 몇 Tick 후에 실행해야하는지 (입력 받지 못한 경우 바로 실행하도록 0으로 설정) { JobTimerElem job; job.execTick = System.Environment.TickCount + tickAfter; job.action = action; lock (_lock) { _pq.Push(job); } } public void Flush() // 일감 처리 { while (true) { int now = System.Environment.TickCount; JobTimerElem job; lock (_lock) { if (_pq.Count == 0) // 일감이 없는 경우 break; // while문을 나간다는 의미 job = _pq.Peek(); // 꺼내지 않고 일감 내용만 확인하는 것 if (job.execTick > now) // 현재 시간보다 실행 시간이 많이 남은 경우 break; _pq.Pop(); } // 일감을 실행시킨다. job.action.Invoke(); } } } }
Server 코드 수정using System; using System.Text; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using ServerCore; namespace Server { class Program { // ... static void FlushRoom() { Room.Push(() => Room.Flush()); JobTimer.Instance.Push(FlushRoom, 250); } static void Main(string[] args) { // ... JobTimer.Instance.Push(FlushRoom); while (true) { JobTimer.Instance.Flush(); } } } }
- Priority Queue란? (즉, 우선순위 큐란?)
~> 선입선출(FIFO)의 원칙에 의하여 먼저 들어온 데이터가 먼저 나가는 Queue와 달리 Priority Queue는 데이터들이
우선순위를 가지고 있어 우선순위가 높은 데이터가 먼저 출력되는 자료구조이다.
[ 섹션 5. 유니티 연동 ]
# 유니티 연동 #1
- 유니티 연동 전 좋은 소식과 나쁜 소식이 존재한다.
~> 좋은 소식은 네트워크 통신을 위해 구현한 코드들을 어느정도 재사용할 수 있다는 것이다.
~> 나쁜 소식은 Unity의 정책상 C#에서 허용되는 문법과 허용되지 않는 문법들이 존재하기 때문에 GenPacket의
Span, ReadOnlySpan, TryWriteBytes 와 같은 것들을 사용할 수 없다는 것이다.
(Unity 2021 버전 이후부터는 Engine에서 지원하지만 오류가 난다는 가정하에 강의를 들을 예정)
- 또한 Unity에서 MultiThread 환경은 제약사항이 존재한다.
~> Main Thread가 아닌 Background Thread에서 Unity가 관리하는 객체들을 접근하거나 코드를 실행하려고 하는 경우
Crash가 발생한다.
~> 따라서 Game Logic은 Main Thread 에서만 접근 및 실행하도록 조정해야 한다.
- 우선 Unity Hub를 통해 새로운 Client 프로젝트를 생성한 뒤 Scripts 폴더 산하에 재사용하고자 하는 코드들을 복붙한다.
~> 프로젝트의 경로는 여태까지 실습하던 프로젝트의 경로로 설정
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // ... // {0} 패킷 이름 // {1} 멤버 변수들 // {2} 멤버 변수 Read // {3} 멤버 변수 Write public static string packetFormat = @" public class {0} : IPacket {{ {1} public ushort Protocol {{ get {{ return (ushort)PacketID.{0}; }} }} public void Read(ArraySegment<byte> segment) {{ ushort count = 0; count += sizeof(ushort); count += sizeof(ushort); {2} }} public ArraySegment<byte> Write() {{ ArraySegment<byte> segment = SendBufferHelper.Open(4096); ushort count = 0; count += sizeof(ushort); Array.Copy(BitConverter.GetBytes((ushort)PacketID.{0}), 0, segment.Array, segment.Offset + count, sizeof(ushort)); count += sizeof(ushort); {3} Array.Copy(BitConverter.GetBytes(count), 0, segment.Array, segment.Offset, sizeof(ushort)); return SendBufferHelper.Close(count); }} }} "; // ... // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] // {2} 멤버 변수들 // {3} 멤버 변수 Read // {4} 멤버 변수 Write public static string memberListFormat = @"public class {0} {{ {2} public void Read(ArraySegment<byte> segment, ref ushort count) {{ {3} }} public bool Write(ArraySegment<byte> segment, ref ushort count) {{ bool success = true; {4} return success; }} }} public List<{0}> {1}s = new List<{0}>();"; // {0} 변수 이름 // {1} To~ 변수 형식 // {2} 변수 형식 public static string readFormat = @"this.{0} = BitConverter.{1}(segment.Array, segment.Offset + count); count += sizeof({2});"; // ... // {0} 변수 이름 public static string readStringFormat = @"ushort {0}Len = BitConverter.ToUInt16(segment.Array, segment.Offset + count); count += sizeof(ushort); this.{0} = Encoding.Unicode.GetString(segment.Array, segment.Offset + count, {0}Len); count += {0}Len;"; // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] public static string readListFormat = @"this.{1}s.Clear(); ushort {1}Len = BitConverter.ToUInt16(segment.Array, segment.Offset + count); count += sizeof(ushort); for (int i = 0; i < {1}Len; i++) {{ {0} {1} = new {0}(); {1}.Read(segment, ref count); {1}s.Add({1}); }}"; // {0} 변수 이름 // {1} 변수 형식 public static string writeFormat = @"Array.Copy(BitConverter.GetBytes(this.{0}), 0, segment.Array, segment.Offset + count, sizeof({1})); count += sizeof({1});"; // ... // {0} 변수 이름 public static string writeStringFormat = @"ushort {0}Len = (ushort)Encoding.Unicode.GetBytes(this.{0}, 0, this.{0}.Length, segment.Array, segment.Offset + count + sizeof(ushort)); Array.Copy(BitConverter.GetBytes({0}Len), 0, segment.Array, segment.Offset + count, sizeof(ushort)); count += sizeof(ushort); count += {0}Len;"; // {0} 리스트 이름 [대문자] // {1} 리스트 이름 [소문자] public static string writeListFormat = @"Array.Copy(BitConverter.GetBytes((ushort)this.{1}s.Count), 0, segment.Array, segment.Offset + count, sizeof(ushort)); count += sizeof(ushort); foreach ({0} {1} in this.{1}s) {1}.Write(segment, ref count);"; } }
GenPackets.bat 배치 파일 수정 (배치 파일 실행시 Unity Script 또한 같이 변경되도록)START ../../PacketGenerator/bin/PacketGenerator.exe ../../PacketGenerator/PDL.xml XCOPY /Y GenPackets.cs "../../DummyClient/Packet" XCOPY /Y GenPackets.cs "../../Client/Assets/Scripts/Packet" XCOPY /Y GenPackets.cs "../../Server/Packet" XCOPY /Y ClientPacketManager.cs "../../DummyClient/Packet" XCOPY /Y ClientPacketManager.cs "../../Client/Assets/Scripts/Packet" XCOPY /Y ServerPacketManager.cs "../../Server/Packet"
Unity Project에 NetworkManager Script 생성후 빈 객체에 Component로 추가using DummyClient; using ServerCore; using System.Collections; using System.Collections.Generic; using System.Net; using UnityEngine; public class NetworkManager : MonoBehaviour { ServerSession _session = new ServerSession(); void Start() { // DNS (Domain Name System) string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); Connector connector = new Connector(); connector.Connect(endPoint, () => { return _session; }, 1); } void Update() { } }
통신이 원활하게 이루어지는지 확인하기 위해 Unity Project의 PacketHandler Script 수정using DummyClient; using ServerCore; using System; using System.Collections.Generic; using System.Text; using UnityEngine; class PacketHandler { public static void S_ChatHandler(PacketSession session, IPacket packet) { S_Chat chatPacket = packet as S_Chat; ServerSession serverSession = session as ServerSession; if (chatPacket.playerId == 1) Debug.Log(chatPacket.chat); //if (chatPacket.playerId == 1) //Console.WriteLine(chatPacket.chat); } }
- Test를 위해 Server 솔루션 프로젝트를 실행시켜 Server와 Client를 구동시킨 뒤, Unity의 Play 버튼을 누르면
Console 창에 Log가 뜨는 것을 확인할 수 있다.
# 유니티 연동 #2
- 이번 시간에는 단순히 Log만 띄우는 것이 아닌 실질적인 액션을 취하도록 만들 예정이다.
~> 우선 Unity에서 [ Hierarchy ] - [ 오른쪽 마우스 ] - [ 3D Object ] - [ Cylinder ] 를 통해 3D 객체 생성 후 이름을
Player로 설정해준다.
Unity Project의 PacketHandler Script 수정using DummyClient; using ServerCore; using System; using System.Collections.Generic; using System.Text; using UnityEngine; class PacketHandler { public static void S_ChatHandler(PacketSession session, IPacket packet) { S_Chat chatPacket = packet as S_Chat; ServerSession serverSession = session as ServerSession; if (chatPacket.playerId == 1) { Debug.Log(chatPacket.chat); // Unity 내의 객체를 찾는 Logic 추가 GameObject go = GameObject.Find("Player"); if (go == null) Debug.Log("Player not found"); else Debug.Log("Player found"); } } }
- 위의 추가한 Logic은 정상적으로 실행되지 않는다.
~> 기존의 우리가 작성한 Server 솔루션의 Logic은 비동기로 네트워크 통신을 하고 있다.
~> 따라서 Unity를 구동하는 Main Thread에서 네트워크 패킷을 실행하는 것이 아닌, Thread Pool에서 Thread를 꺼내와
실행하고 있는 것이 문제가 된다.
~> Unity는 다른 Thread에서 게임과 관련된 부분을 접근하여 실행하는 것을 원천적으로 차단해 두었기 때문에
정상적으로 실행되지 않는 것이다.
~> 해결 방법으로는 PacketHandler Class가 Main Thread에서 실행되도록 만들면 된다.
(S_ChatHandler에서 Logic을 처리하는 것이 아닌 Queue에 일감을 등록후, 처리하는 Logic을 구분 지어 사용)
Unity Project의 GenPacket Script 수정using System; using System.Collections.Generic; using System.Text; using System.Net; using ServerCore; // ... public interface IPacket // 접근한정자 public 추가 { ushort Protocol { get; } void Read(ArraySegment<byte> segment); ArraySegment<byte> Write(); } // ...
Unity Project에 NetworkManager Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; // Main Thread와 Background Thread (즉, 네트워크를 처리하는 애들끼리) 는 PacketQueue라는 통로를 통해 소통한다. // ~> Background Thread는 Pakcet을 Push하여 밀어 넣고, Main Thread에서는 Packet을 Pop하여 처리한다. public class PacketQueue // Component로 사용할 건 X ~> MonoBehaviour 상속 X { public static PacketQueue Instance { get; } = new PacketQueue(); Queue<IPacket> _packetQueue = new Queue<IPacket>(); object _lock = new object(); public void Push(IPacket packet) { lock (_lock) { _packetQueue.Enqueue(packet); } } public IPacket Pop() { lock ( _lock) { if (_packetQueue.Count == 0) return null; return _packetQueue.Dequeue(); } } }
일감 등록을 위한 Unity Project의 ClientPacketManager Script 수정using ServerCore; using System; using System.Collections.Generic; class PacketManager { #region Singleton static PacketManager _instance = new PacketManager(); public static PacketManager Instance { get { return _instance; } } #endregion PacketManager() { Register(); } Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>(); Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>(); public void Register() { _makeFunc.Add((ushort)PacketID.S_Chat, MakePacket<S_Chat>); _handler.Add((ushort)PacketID.S_Chat, PacketHandler.S_ChatHandler); } // Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력 받는다. public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null) { ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; Func<PacketSession, ArraySegment<byte>, IPacket> func = null; if (_makeFunc.TryGetValue(id, out func)) { IPacket packet = func.Invoke(session, buffer); if (onRecvCallback != null) onRecvCallback.Invoke(session, packet); else HandlePacket(session, packet); } } T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new() { T pkt = new T(); pkt.Read(buffer); return pkt; } public void HandlePacket(PacketSession session, IPacket packet) { Action<PacketSession, IPacket> action = null; if (_handler.TryGetValue(packet.Protocol, out action)) action.Invoke(session, packet); } }
일감 등록을 위한 Unity Project의 ServerSession Script 수정using System; using System.Collections.Generic; using System.Text; using System.Net; using ServerCore; namespace DummyClient { class ServerSession : PacketSession { // ... // Action<PacketSession, IPacket> Type의 매개변수인 onRecvCallback 을 추가로 입력 public override void OnRecvPacket(ArraySegment<byte> buffer) { PacketManager.Instance.OnRecvPacket(this, buffer, (s, p) => PacketQueue.Instance.Push(p)); } // ... } }
일감 처리를 위한 Unity Project의 NetworkManager Script 수정using DummyClient; using ServerCore; using System; using System.Collections; using System.Collections.Generic; using System.Net; using UnityEngine; public class NetworkManager : MonoBehaviour { ServerSession _session = new ServerSession(); void Start() { // DNS (Domain Name System) string host = Dns.GetHostName(); IPHostEntry ipHost = Dns.GetHostEntry(host); IPAddress ipAddr = ipHost.AddressList[0]; IPEndPoint endPoint = new IPEndPoint(ipAddr, 7777); Connector connector = new Connector(); connector.Connect(endPoint, () => { return _session; }, 1); StartCoroutine("CoSendPacket"); } void Update() { // 일감 처리 IPacket packet = PacketQueue.Instance.Pop(); if (packet != null) { PacketManager.Instance.HandlePacket(_session, packet); } } // 3초마다 패킷을 보내도록 (즉, DummyClient의 역할) IEnumerator CoSendPacket() { while (true) { yield return new WaitForSeconds(3.0f); C_Chat chatPacket = new C_Chat(); chatPacket.chat = "Hello Unity !"; ArraySegment<byte> segment = chatPacket.Write(); _session.Send(segment); } } }
PacketFormat Class 수정using System; using System.Collections.Generic; using System.Text; namespace PacketGenerator { class PacketFormat { // {0} 패킷 등록 public static string managerFormat = @"using ServerCore; using System; using System.Collections.Generic; public class PacketManager {{ #region Singleton static PacketManager _instance = new PacketManager(); public static PacketManager Instance {{ get {{ return _instance; }} }} #endregion PacketManager() {{ Register(); }} Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>> _makeFunc = new Dictionary<ushort, Func<PacketSession, ArraySegment<byte>, IPacket>>(); Dictionary<ushort, Action<PacketSession, IPacket>> _handler = new Dictionary<ushort, Action<PacketSession, IPacket>>(); public void Register() {{ {0} }} public void OnRecvPacket(PacketSession session, ArraySegment<byte> buffer, Action<PacketSession, IPacket> onRecvCallback = null) {{ ushort count = 0; ushort size = BitConverter.ToUInt16(buffer.Array, buffer.Offset); count += 2; ushort id = BitConverter.ToUInt16(buffer.Array, buffer.Offset + count); count += 2; Func<PacketSession, ArraySegment<byte>, IPacket> func = null; if (_makeFunc.TryGetValue(id, out func)) {{ IPacket packet = func.Invoke(session, buffer); if (onRecvCallback != null) onRecvCallback.Invoke(session, packet); else HandlePacket(session, packet); }} }} T MakePacket<T>(PacketSession session, ArraySegment<byte> buffer) where T : IPacket, new() {{ T pkt = new T(); pkt.Read(buffer); return pkt; }} public void HandlePacket(PacketSession session, IPacket packet) {{ Action<PacketSession, IPacket> action = null; if (_handler.TryGetValue(packet.Protocol, out action)) action.Invoke(session, packet); }} }}"; // {0} 패킷 이름 public static string managerRegisterFormat = @" _makeFunc.Add((ushort)PacketID.{0}, MakePacket<{0}>); _handler.Add((ushort)PacketID.{0}, PacketHandler.{0}Handler);"; // {0} 패킷 이름/번호 목록 // {1} 패킷 목록 public static string fileFormat = @"using System; using System.Collections.Generic; using System.Text; using System.Net; using ServerCore; public enum PacketID {{ {0} }} public interface IPacket {{ ushort Protocol {{ get; }} void Read(ArraySegment<byte> segment); ArraySegment<byte> Write(); }} {1} "; // ... } }
# 유니티 연동 #3
- 이번 시간과 다음 시간을 통해 Server에서의 Player 생성 및 움직임을 구현 예정이다.
~> 우선 Server Logic 수정 후, DummyClient Logic을 수정할 것이다.
PDL XML 파일 수정<?xml version="1.0" encoding="utf-8" ?> <PDL> <packet name="S_BroadcastEnterGame"> <int name="playerId"/> <float name="posX"/> <float name="posY"/> <float name="posZ"/> </packet> <packet name="C_LeaveGame"> </packet> <packet name="S_BroadcastLeaveGame"> <int name="playerId"/> </packet> <packet name="S_PlayerList"> <list name="player"> <bool name="isSelf"/> <int name="playerId"/> <float name="posX"/> <float name="posY"/> <float name="posZ"/> </list> </packet> <packet name="C_Move"> <float name="posX"/> <float name="posY"/> <float name="posZ"/> </packet> <packet name="S_BroadcastMove"> <int name="playerId"/> <float name="posX"/> <float name="posY"/> <float name="posZ"/> </packet> </PDL>
ClientSession Class 수정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 { class ClientSession : PacketSession { public int SessionId { get; set; } public GameRoom Room { get; set; } public float PosX { get; set; } // x 좌표 값 저장을 위해 추가 public float PosY { get; set; } // y 좌표 값 저장을 위해 추가 public float PosZ { get; set; } // z 좌표 값 저장을 위해 추가 // ... } }
GameRoom Class 수정using ServerCore; using System; using System.Collections.Generic; using System.Text; namespace Server { class GameRoom : IJobQueue { // ... public void Broadcast(ArraySegment<byte> segment) { _pendingList.Add(segment); } public void Enter(ClientSession session) { // 플레이어 추가 _sessions.Add(session); session.Room = this; // 신입생한테 모든 플레이어 목록 전송 S_PlayerList players = new S_PlayerList(); foreach (ClientSession s in _sessions) { players.players.Add(new S_PlayerList.Player() { isSelf = (s == session), playerId = s.SessionId, posX = s.PosX, posY = s.PosY, posZ = s.PosZ }); } session.Send(players.Write()); // 신입생 입장을 모두에게 알린다 S_BroadcastEnterGame enter = new S_BroadcastEnterGame(); enter.playerId = session.SessionId; enter.posX = 0; enter.posY = 0; enter.posZ = 0; Broadcast(enter.Write()); } public void Leave(ClientSession session) { // 플레이어 제거 _sessions.Remove(session); // 플레이어 퇴장을 모두에게 알린다 S_BroadcastLeaveGame leave = new S_BroadcastLeaveGame(); leave.playerId = session.SessionId; Broadcast(leave.Write()); } public void Move(ClientSession session, C_Move packet) { // 좌표를 바꿔주고 session.PosX = packet.posX; session.PosY = packet.posY; session.PosZ = packet.posZ; // 모두에게 알린다 S_BroadcastMove move = new S_BroadcastMove(); move.playerId = session.SessionId; move.posX = session.PosX; move.posY = session.PosY; move.posZ = session.PosZ; Broadcast(move.Write()); } } }
Server의 PacketHandler 수정using Server; using ServerCore; using System; using System.Collections.Generic; using System.Text; class PacketHandler { public static void C_LeaveGameHandler(PacketSession session, IPacket packet) { C_LeaveGame chatPacket = packet as C_LeaveGame; ClientSession clientSession = session as ClientSession; if (clientSession.Room == null) return; GameRoom room = clientSession.Room; room.Push(() => room.Leave(clientSession)); } public static void C_MoveHandler(PacketSession session, IPacket packet) { C_Move movePacket = packet as C_Move; ClientSession clientSession = session as ClientSession; if (clientSession.Room == null) return; GameRoom room = clientSession.Room; room.Push(() => room.Move(clientSession, movePacket)); } }
DummyClient의 PacketHandler 수정
(빌드가 통과할 수 있도록 함수만 만들어줄 뿐, 실질적인 작업은 유니티 내부에서 처리)using DummyClient; using ServerCore; using System; using System.Collections.Generic; using System.Text; class PacketHandler { public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet) { S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame; ServerSession serverSession = session as ServerSession; } public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet) { S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame; ServerSession serverSession = session as ServerSession; } public static void S_PlayerListHandler(PacketSession session, IPacket packet) { S_PlayerList pkt = packet as S_PlayerList; ServerSession serverSession = session as ServerSession; } public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet) { S_BroadcastMove pkt = packet as S_BroadcastMove; ServerSession serverSession = session as ServerSession; } }
DummyClient의 SessionManager 수정using System; using System.Collections.Generic; using System.Text; namespace DummyClient { class SessionManager { // ... Random _rand = new Random(); public void SendForEach() { lock (_lock) { // 채팅 패킷이 아닌 이동 패킷을 보내도록 수정 foreach (ServerSession session in _sessions) { C_Move movePacket = new C_Move(); movePacket.posX = _rand.Next(-50, 50); movePacket.posY = 0; movePacket.posZ = _rand.Next(-50, 50); session.Send(movePacket.Write()); } } } // ... } }
# 유니티 연동 #4
- 지난 시간에 이어 Server에서의 Player 생성 및 움직임을 마저 구현할 예정이다.
Unity Project의 PacketQueue Script 수정using System.Collections; using System.Collections.Generic; using UnityEngine; public class PacketQueue { // ... public List<IPacket> PopAll() { List<IPacket> list = new List<IPacket>(); lock (_lock) { while (_packetQueue.Count > 0) list.Add(_packetQueue.Dequeue()); } return list; } }
Unity Project의 NetworkManager Script 수정using DummyClient; using ServerCore; using System; using System.Collections; using System.Collections.Generic; using System.Net; using UnityEngine; public class NetworkManager : MonoBehaviour { // ... public void Send(ArraySegment<byte> sendBuff) { _session.Send(sendBuff); } // ... void Update() { // 일감 처리 // 프레임마다 1개의 일감만을 처리하는 것이 아닌, 프레임마다 모든 일감하도록 수정 List<IPacket> list = PacketQueue.Instance.PopAll(); // 즉, Pop()이 아닌 PopAll()을 실행하도록 수정 foreach (IPacket packet in list) PacketManager.Instance.HandlePacket(_session, packet); } }
Unity Project에 Player Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class Player : MonoBehaviour { public int PlayerId { get; set; } }
Unity Project에 MyPlayer Script 생성using ServerCore; using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class MyPlayer : Player { NetworkManager _network; void Start() { StartCoroutine("CoSendPacket"); _network = GameObject.Find("NetworkManager").GetComponent<NetworkManager>(); } void Update() { } IEnumerator CoSendPacket() { while (true) { yield return new WaitForSeconds(0.25f); C_Move movePacket = new C_Move(); movePacket.posX = UnityEngine.Random.Range(-50, 50); movePacket.posY = 0; movePacket.posZ = UnityEngine.Random.Range(-50, 50); _network.Send(movePacket.Write()); } } }
Unity Project에 PlayerManager Script 생성using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerManager // 기생하는 것이 아닌 데이터만 들고 있도록 MonoBehaviour 상속 X { MyPlayer _myPlayer; Dictionary<int, Player> _players = new Dictionary<int, Player>(); public static PlayerManager Instance { get; } = new PlayerManager(); public void Add(S_PlayerList packet) { Object obj = Resources.Load("Player"); foreach (S_PlayerList.Player p in packet.players) { GameObject go = Object.Instantiate(obj) as GameObject; if (p.isSelf) { MyPlayer myPlayer = go.AddComponent<MyPlayer>(); myPlayer.PlayerId = p.playerId; myPlayer.transform.position = new Vector3(p.posX, p.posY, p.posZ); _myPlayer = myPlayer; } else { Player player = go.AddComponent<Player>(); player.PlayerId = p.playerId; player.transform.position = new Vector3(p.posX, p.posY, p.posZ); _players.Add(p.playerId, player); } } } public void Move(S_BroadcastMove packet) { if (_myPlayer.PlayerId == packet.playerId) { _myPlayer.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ); } else { Player player = null; if (_players.TryGetValue(packet.playerId, out player)) { player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ); } } } public void EnterGame(S_BroadcastEnterGame packet) { if (packet.playerId == _myPlayer.PlayerId) return; Object obj = Resources.Load("Player"); GameObject go = Object.Instantiate(obj) as GameObject; Player player = go.AddComponent<Player>(); player.transform.position = new Vector3(packet.posX, packet.posY, packet.posZ); _players.Add(packet.playerId, player); } public void LeaveGame(S_BroadcastLeaveGame packet) { if (_myPlayer.PlayerId == packet.playerId) { GameObject.Destroy(_myPlayer.gameObject); _myPlayer = null; } else { Player player = null; if (_players.TryGetValue(packet.playerId, out player)) { GameObject.Destroy(player.gameObject); _players.Remove(packet.playerId); } } } }
Unity Project의 PacketHandler Script 수정using DummyClient; using ServerCore; using System; using System.Collections.Generic; using System.Text; using UnityEngine; class PacketHandler { public static void S_BroadcastEnterGameHandler(PacketSession session, IPacket packet) { S_BroadcastEnterGame pkt = packet as S_BroadcastEnterGame; ServerSession serverSession = session as ServerSession; PlayerManager.Instance.EnterGame(pkt); } public static void S_BroadcastLeaveGameHandler(PacketSession session, IPacket packet) { S_BroadcastLeaveGame pkt = packet as S_BroadcastLeaveGame; ServerSession serverSession = session as ServerSession; PlayerManager.Instance.LeaveGame(pkt); } public static void S_PlayerListHandler(PacketSession session, IPacket packet) { S_PlayerList pkt = packet as S_PlayerList; ServerSession serverSession = session as ServerSession; PlayerManager.Instance.Add(pkt); } public static void S_BroadcastMoveHandler(PacketSession session, IPacket packet) { S_BroadcastMove pkt = packet as S_BroadcastMove; ServerSession serverSession = session as ServerSession; PlayerManager.Instance.Move(pkt); } }
'Unity Engine Study > Unity 강의 #2' 카테고리의 다른 글
[Unity 강의 #2] 인프런 강의 - Part4: 게임 서버 (섹션 0 ~ 2) (0) | 2024.02.12 |
---|