2026/03/11 슬라이드로 보기위한 내용 수정
2026/03/11 슬라이드로 보기위한 내용 수정
C#, SocketProgramming, protobuf, Unity
주요 메서드
public virtual Int32 Serialize(Span<byte> buffer) 메시지를 바이트 스트림으로 직렬화해서 반환합니다.public virtual Int32 Serialize(Span<byte> buffer) 메시지를 직렬화 했을 때 바이트 크기를 반환합니다.
**IMessageParser<T>static abstract int Parse(byte[] data, int size, out T? message) 바이트 스트림을 받아 파싱을 성공한다면, message를 내보내고 파싱에 사용한 바이트 수를 반환합니다. 음수(-1) 반환시 파싱 실패입니다.static abstract Int32 GetMaxSize() 메시지의 최대 크기를 받습니다. 버퍼 크기 설정을 위해 사용합니다.using System.Text;
namespace NetworkController.Message
{
public class BaseMessage : IMessageParser<BaseMessage>
{
string Payload;
public BaseMessage(string payload = "")
{
Payload = payload;
}
public virtual Int32 GetSize()
{
return Encoding.UTF8.GetByteCount(Payload);
}
public static Int32 GetMaxSize()
{
return 1024;
}
public virtual byte[] Serialize()
{
return Encoding.UTF8.GetBytes(Payload);
}
public virtual Int32 Serialize(Span<byte> buffer)
{
if(buffer.Length < Encoding.UTF8.GetByteCount(Payload))
{
throw new Exception("Buffer size is smaller than payload size.");
}
return Encoding.UTF8.GetBytes(Payload, buffer);
}
public static int Parse(byte[] data, Int32 size, out BaseMessage? message)
{
string m = Encoding.UTF8.GetString(data, 0, size);
message = new BaseMessage(m);
return size;
}
}
public interface IMessageParser<T>
{
static abstract int Parse(byte[] data, int size, out T? message);
static abstract Int32 GetMaxSize();
}
public interface IMessageHeader
{
static Int32 HeaderSize { get; }
abstract Int32 Serialize(Span<byte> buffer);
static abstract int Parse(byte[] buffer, int Size, out IMessageHeader? header);
}
}
using Google.Protobuf;
using System.Buffers.Binary;
using Protos;
namespace NetworkController.Message
{
public class ProtobufMessage : BaseMessage, IMessageParser<ProtobufMessage>
{
public enum OpCode : Int32
{
System = 1,
Chatting = 2,
}
public ProtobufMessageHeader Header;
public Google.Protobuf.IMessage Payload;
public ProtobufMessage(Google.Protobuf.IMessage payload, OpCode opCode)
{
Header = new ProtobufMessageHeader(payload.CalculateSize(), (Int32)opCode);
Payload = payload;
if (GetSize() > GetMaxSize())
{
throw new ArgumentException("Payload size exceeds maximum allowed size.");
}
}
public override Int32 GetSize()
{
return ProtobufMessageHeader.HeaderSize + Payload.CalculateSize();
}
public new static Int32 GetMaxSize()
{
return 1024;
}
public override byte[] Serialize()
{
var headerSize = ProtobufMessageHeader.HeaderSize;
var payloadSize = Payload.CalculateSize();
byte[] buffer = new byte[headerSize + Payload.CalculateSize()];
var offset = Header.Serialize(buffer);
Payload.WriteTo(buffer.AsSpan<byte>(offset, payloadSize));
return buffer;
}
public override Int32 Serialize(Span<byte> buffer)
{
Header.Serialize(buffer);
Payload.WriteTo(buffer.Slice(ProtobufMessageHeader.HeaderSize, Payload.CalculateSize()));
return GetSize();
}
public static int Parse(byte[] data, int size, out ProtobufMessage? msg)
{
var offset = ProtobufMessageHeader.Parse(data, size, out var header);
if (offset == -1)
{
msg = null;
return -1;
}
if (offset == 0 || header == null)
{
msg = null;
return 0;
}
var messageHeader = header as ProtobufMessageHeader;
if (size < ProtobufMessageHeader.HeaderSize + messageHeader!.PayloadSize)
{
msg = null;
return 0;
}
var payload = ProtobufParserRegistry.Parse(
messageHeader.OpCode, new ReadOnlySpan<byte>(data, ProtobufMessageHeader.HeaderSize, messageHeader.PayloadSize));
msg = new ProtobufMessage(payload, (OpCode)messageHeader.OpCode);
return ProtobufMessageHeader.HeaderSize + messageHeader.PayloadSize;
}
}
public class ProtobufMessageHeader : IMessageHeader
{
public static Int32 HeaderSize => 20;
public Int32 PayloadSize; // 4
public Int32 OpCode; // 4
private Int64 _TimeStamp; // 8
public Int64 Timestamp => _TimeStamp;
static public Int32 CheckKey = 0x2026; // 4
public ProtobufMessageHeader(Int32 payloadSize, Int32 opCode)
{
PayloadSize = payloadSize;
OpCode = opCode;
_TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
ProtobufMessageHeader(Int32 p, Int32 o, Int64 t, Int32 c)
{
PayloadSize = p;
OpCode = o;
_TimeStamp = t;
CheckKey = c;
}
public int Serialize(Span<byte> buffer)
{
if (buffer.Length < HeaderSize)
throw new ArgumentException("Buffer too small", nameof(buffer));
BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(0, 4), PayloadSize);
BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(4, 4), OpCode);
BinaryPrimitives.WriteInt64LittleEndian(buffer.Slice(8, 8), _TimeStamp);
BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(16, 4), CheckKey);
return HeaderSize;
}
static public int Parse(byte[] buffer, int size, out IMessageHeader? header)
{
if (size < HeaderSize)
{
header = null;
return 0;
}
BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 0, 4), out Int32 payloadSize);
BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 4, 4), out Int32 opCode);
BinaryPrimitives.TryReadInt64LittleEndian(new ReadOnlySpan<byte>(buffer, 8, 8), out Int64 timeStamp);
BinaryPrimitives.TryReadInt32LittleEndian(new ReadOnlySpan<byte>(buffer, 16, 4), out Int32 checkKey);
header = new ProtobufMessageHeader(payloadSize, opCode, timeStamp, checkKey);
if (checkKey != CheckKey)
{
return -1;
}
return HeaderSize;
}
}
static class ProtobufParserRegistry
{
static readonly Dictionary<Int32, MessageParser> Parsers = new()
{
{ (Int32)ProtobufMessage.OpCode.System, SystemMessage.Parser},
{ (Int32)ProtobufMessage.OpCode.Chatting, ChattingMessage.Parser },
};
public static IMessage Parse(Int32 opcode, ReadOnlySpan<byte> payload)
{
return Parsers[opcode].ParseFrom(payload.ToArray());
}
}
}syntax = "proto3";
option csharp_namespace = "Protos";
message SystemMessage {
oneof payload {
LoginRequest login_request = 1;
LoginResponse login_response = 2;
Heartbeat heartbeat = 3;
}
}
message LoginRequest {
string user_name = 1;
}
message LoginResponse {
bool success = 1;
string user_name = 2;
string message = 3;
}
message Heartbeat {
int64 timestamp = 1;
}
// chattingmessage
syntax = "proto3";
option csharp_namespace = "Protos";
message ChattingMessage{
string username = 1;
string Message = 2;
};클라이언트
void Connect(IPAddress ip, UInt16 port): 해당 주소로 소켓 연결을 시도합니다.void Disconnect(): 연결을 해제합니다.void SendMessage(T message): 메시지를 송신합니다.서버
void OpenServer(IPEndPoint endPoint): 파라미터의 endPoint로 소켓을 열어 클라이언트들의 접속을 받기 시작합니다.void CloseServer(): 서버를 닫습니다.void SendMessageTo(UInt32 sessionID, T message): 특정 세션번호를 향해 메시지를 송신합니다. 만약에 해당 세션이 죽었으면 에러를 일으킵니다.event Action<SocketContext<T>>? OnConnect: 클라이언트가 접속했을 때 호출하는 이벤트입니다. 서버측에서 클라이언트 접속에 따른 처리를 하기 위해 제공합니다.event Action<SocketContext<T>>? OnDisconnect: 접속이 끊겼을 때 호출하는 이벤트입니다. 클라이언트 측에서도 서버와의 접속이 끊겼을 때 대응하기 위해 사용할 수 있습니다.일반
void SetReceiveBufferSize(int size): SocketContext에서 사용할 수신 버퍼 사이즈를 설정합니다. 기본값은 메시지 최대 크기의 *10입니다. 수신 버퍼 사이즈를 변경하면 내부적으로 SocketContext에서 사용하는 버퍼 크기도 변경해줘야 하는데 구현되지 않았습니다.bool IsMessageAvailable(): 수신 메시지 큐에 메시지가 존재하는지 확인합니다.T GetMessage(out SocketContext<T> context, CancellationToken cancellationToken = default):using NetworkController.Message;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace NetworkController
{
public class NetworkController<T> where T : BaseMessage, IMessageParser<T>
{
enum NetworkState { None, Client, Server }
NetworkState State = NetworkState.None;
Socket _Socket;
Int32 BufferSize = T.GetMaxSize() * 10;
BlockingCollection<Tuple<SocketContext<T>, T>> ReceiveMessageQueue = new();
SocketContext<T>? ClientContext;
ConcurrentStack<SocketContext<T>> ContextQueue = new();
ConcurrentDictionary<UInt32, SocketContext<T>> ConnectedContext = new();
public event Action<SocketContext<T>>? OnConnect;
public event Action<SocketContext<T>>? OnDisconnect;
UInt32 NextSessionID = 0;
public NetworkController()
{
_Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
}
public void SetReceiveBufferSize(int size)
{
BufferSize = size;
}
public void Connect(IPAddress ip, UInt16 port)
{
if (State != NetworkState.None)
{
throw new Exception("Connect(): NetworkController is already running.");
}
State = NetworkState.Client;
_Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_Socket.Connect(new IPEndPoint(ip, port));
ClientContext = GetSocketContext();
ClientContext.Reset(NextSessionID++, _Socket, ip, port);
ClientContext.StartReceive();
}
public bool IsConnected()
{
if (State != NetworkState.Client)
{
return false;
}
return true;
}
SocketContext<T> GetSocketContext()
{
if (ContextQueue.TryPop(out var context))
{
return context;
}
var c = new SocketContext<T>(BufferSize);
c.OnReceiveMessage += (context, message) =>
{
ReceiveMessageQueue.Add(new Tuple<SocketContext<T>, T>(context, message));
};
c.OnDisconnect += (context) =>
{
SocketDisconnected(context);
};
return c;
}
public void Disconnect()
{
if (State != NetworkState.Client)
{
throw new Exception("Disconnect(): NetworkController is not connected as a client.");
}
ClientContext!.Disconnect();
State = NetworkState.None;
}
public void Disconnect(UInt32 sessionID)
{
if (State != NetworkState.Server)
{
throw new Exception("Disconnect(sessionID): NetworkController is not running as a server.");
}
if (ConnectedContext.TryGetValue(sessionID, out var context))
{
context.Disconnect();
}
else
{
throw new Exception($"Disconnect(sessionID): SessionID {sessionID} not found.");
}
}
public void OpenServer(IPEndPoint endPoint)
{
_Socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_Socket.Bind(endPoint);
_Socket.Listen();
var args = new SocketAsyncEventArgs();
args.Completed += (s, e) =>
{
CompletedAccept(s, e);
};
State = NetworkState.Server;
StartAccept(args);
}
public void CloseServer()
{
if (!State.Equals(NetworkState.Server))
{
throw new Exception("CloseServer(): NetworkController is not running as a server.");
}
_Socket.Close();
foreach (var context in ConnectedContext.Values)
{
context.Disconnect();
}
State = NetworkState.None;
}
public T GetMessage(CancellationToken cancellationToken = default)
{
return ReceiveMessageQueue.Take(cancellationToken).Item2;
}
public T GetMessage(out SocketContext<T> context, CancellationToken cancellationToken = default)
{
var tuple = ReceiveMessageQueue.Take(cancellationToken);
context = tuple.Item1;
return tuple.Item2;
}
public bool IsMessageAvailable()
{
return !ReceiveMessageQueue.IsCompleted && ReceiveMessageQueue.Count > 0;
}
public void SendMessage(T message)
{
if (!State.Equals(NetworkState.Client))
{
throw new Exception("SendMessage: NetworkController is not connected as a client.");
}
ClientContext!.SendMessage(message);
}
public void SendMessageTo(UInt32 sessionID, T message)
{
if (!State.Equals(NetworkState.Server))
{
throw new Exception("SendMessageTo: NetworkController is not running as a server.");
}
if (ConnectedContext.TryGetValue(sessionID, out var context))
{
context.SendMessage(message);
}
else
{
throw new Exception($"SendMessageTo: SessionID {sessionID} not found.");
}
}
void StartAccept(SocketAsyncEventArgs e)
{
e.AcceptSocket = null;
if (!_Socket.AcceptAsync(e))
{
CompletedAccept(this, e);
}
}
void CompletedAccept(object? sender, SocketAsyncEventArgs e)
{
var clientSocket = e.AcceptSocket;
var remoteEndPoint = clientSocket!.RemoteEndPoint as IPEndPoint;
if (remoteEndPoint == null)
{
return;
}
var context = GetSocketContext();
while (ConnectedContext.TryGetValue(NextSessionID, out var existingContext))
{
NextSessionID++;
}
ConnectedContext[NextSessionID] = context;
context.Reset(NextSessionID++, clientSocket, remoteEndPoint!.Address, (UInt16)remoteEndPoint.Port);
context.StartReceive();
OnConnect?.Invoke(context);
StartAccept(e);
}
void SocketDisconnected(SocketContext<T> context)
{
ConnectedContext.TryRemove(context.SessionID, out var _);
OnDisconnect?.Invoke(context);
ContextQueue.Push(context);
if (State == NetworkState.Client)
{
Disconnect();
}
}
}
}public void Reset(UInt32 sessionID, Socket socket, IPAddress ip, UInt16 port):public void StartReceive(): 할당된 소켓으로부터 수신을 처리하기 시작합니다.public void SendMessage(T message): 할당된 소켓에 메시지를 송신합니다. 내부적으로 송신 큐에 메시지를 큐잉하고서 차레대로 송신합니다.public void Disconnect(): 연결을 해제합니다.public event Action<SocketContext<T>, T>? OnReceiveMessage: 수신한 바이트스트림을 파싱성공하여 메시지를 완성하면 해당 이벤트를 호출합니다.public event Action<SocketContext<T>>? OnDisconnect: 소켓 연결이 끊겼을 때 해당 이벤트를 호출합니다.namespace NetworkController
{
public class SocketContext<T> where T : BaseMessage, IMessageParser<T>
{
Socket? Socket = null;
SocketAsyncEventArgs SendArgs;
SocketAsyncEventArgs RecvArgs;
public UInt32 SessionID;
public IPAddress? RemoteAddress = null;
public UInt16 RemotePort = 0;
byte[] RecvBuf;
int RecvBufOffset = 0;
int RecvBufDataSize = 0;
byte[] SendBuf;
int SendBufDataSize = 0;
bool IsSending;
bool IsConnected = false;
ConcurrentQueue<T> SendMessageQueue = new();
T? MessageForSend;
public event Action<SocketContext<T>, T>? OnReceiveMessage;
public event Action<SocketContext<T>>? OnDisconnect;
public SocketContext(Int32 bufferSize)
{
RecvBuf = new byte[bufferSize];
SendBuf = new byte[bufferSize];
SendArgs = new();
RecvArgs = new();
SendArgs.UserToken = this;
SendArgs.Completed += (s, e) =>
{
this.CompletedSend(e.BytesTransferred);
};
RecvArgs.UserToken = this;
RecvArgs.Completed += (s, e) =>
{
this.CompletedReceive(e.BytesTransferred);
};
SendArgs.SetBuffer(SendBuf, 0, 0);
RecvArgs.SetBuffer(RecvBuf, 0, RecvBuf.Length);
}
public void Reset(UInt32 sessionID, Socket socket, IPAddress ip, UInt16 port)
{
if (Interlocked.CompareExchange(ref IsConnected, true, false) == true)
{
throw new Exception("Already Connected");
}
SessionID = sessionID;
Socket = socket;
RemoteAddress = ip;
RemotePort = port;
RecvBufOffset = 0;
RecvBufDataSize = 0;
SendBufDataSize = 0;
}
public void Disconnect()
{
if (Interlocked.CompareExchange(ref IsConnected, false, true) == false)
{
return;
}
Socket!.Shutdown(SocketShutdown.Both);
Socket!.Close();
OnDisconnect?.Invoke(this);
}
public void StartReceive()
{
RecvArgs.SetBuffer(RecvBuf, RecvBufOffset, RecvBuf.Length - (RecvBufOffset + RecvBufDataSize));
if (!Socket!.ReceiveAsync(RecvArgs))
{
CompletedReceive(RecvArgs.BytesTransferred);
}
}
void CompletedReceive(int bytesTransferred)
{
while (true)
{
if (bytesTransferred <= 0)
{
Disconnect();
return;
}
RecvBufDataSize += bytesTransferred;
while (true)
{
int parsed = T.Parse(RecvBuf, RecvBufDataSize, out T? message);
if (parsed < 0)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Wrong data received...");
Console.ForegroundColor = ConsoleColor.White;
Disconnect();
return;
}
if (message == null)
{
break;
}
OnReceiveMessage?.Invoke(this, message);
RecvBufOffset += parsed;
RecvBufDataSize -= parsed;
}
// 수신한 데이터가 모두 파싱되어 비어있으면 offset 자동으로 0으로 옮겨줌
if (RecvBufDataSize == 0)
{
RecvBufOffset = 0;
}
// 버퍼에 메시지 하나를 수신할 공간이 부족하면 현재 유효 데이터를 앞으로 옮기고 오프셋 이동
else if (RecvBuf.Length - (RecvBufOffset + RecvBufDataSize) < T.GetMaxSize())
{
Console.WriteLine($"{RecvBuf.Length}, {RecvBufOffset}, {RecvBufDataSize}");
Buffer.BlockCopy(RecvBuf, RecvBufOffset, RecvBuf, 0, RecvBufDataSize);
RecvBufOffset = RecvBufDataSize;
}
RecvArgs.SetBuffer(
RecvBufOffset + RecvBufDataSize,
RecvBuf.Length - (RecvBufOffset + RecvBufDataSize)
);
if (Socket!.ReceiveAsync(RecvArgs))
return;
bytesTransferred = RecvArgs.BytesTransferred;
}
}
public void SendMessage(T message)
{
SendMessageQueue.Enqueue(message);
if (Interlocked.CompareExchange(ref IsSending, true, false) == false)
{
TrySend();
}
}
void TrySend()
{
while (true)
{
if (MessageForSend != null && MessageForSend.GetSize() < SendBuf.Length - SendBufDataSize)
{
MessageForSend.Serialize(SendBuf.AsSpan(SendBufDataSize));
SendBufDataSize += MessageForSend.GetSize();
MessageForSend = null;
}
if (MessageForSend == null)
{
while (SendMessageQueue.TryDequeue(out var msg))
{
int size = msg.GetSize();
if (size > SendBuf.Length - SendBufDataSize)
{
MessageForSend = msg;
break;
}
msg.Serialize(SendBuf.AsSpan(SendBufDataSize));
SendBufDataSize += size;
}
}
if (SendBufDataSize == 0)
{
Interlocked.Exchange(ref IsSending, false);
if (!SendMessageQueue.IsEmpty && Interlocked.CompareExchange(ref IsSending, true, false) == false)
{
continue;
}
return;
}
SendArgs.SetBuffer(SendBuf, 0, SendBufDataSize);
bool pending = Socket!.SendAsync(SendArgs);
if (pending)
{
return;
}
// 송신이 즉시 완료되면 계속 루프 돌면서 송신
SendBufDataSize -= SendArgs.BytesTransferred;
if (SendBufDataSize > 0)
{
Buffer.BlockCopy(SendBuf, SendArgs.BytesTransferred, SendBuf, 0, SendBufDataSize);
}
}
}
void CompletedSend(int bytesTransferred)
{
SendBufDataSize -= bytesTransferred;
if (SendBufDataSize > 0)
{
Buffer.BlockCopy(SendBuf, bytesTransferred, SendBuf, 0, SendBufDataSize);
}
TrySend();
}
}
}주요 동작
namespace GameClientConsole
{
internal class Program
{
static void Main(string[] args)
{
Console.InputEncoding = System.Text.Encoding.UTF8;
Console.OutputEncoding = System.Text.Encoding.UTF8;
NetworkController<ProtobufMessage> Netcon = new NetworkController<ProtobufMessage>();
Netcon.OnDisconnect += (context) =>
{
Console.WriteLine($"Server disconnected");
};
var chat = new Protos.ChattingMessage
{
Message = "hello, world!!"
};
_ = Task.Run(() =>
{
while (true)
{
var message = Netcon.GetMessage();
if (message.Payload is Protos.ChattingMessage chatMessage)
{
Console.WriteLine($"[{chatMessage.Username}]: {chatMessage.Message}");
}
else if (message.Payload is Protos.SystemMessage)
{
}
}
});
bool sendHeartbeat = false;
_ = Task.Run(() =>
{
while (true)
{
Thread.Sleep(1000);
if (sendHeartbeat)
{
Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
{
Heartbeat = new Protos.Heartbeat
{
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
}
}
, ProtobufMessage.OpCode.System));
}
}
});
PrintCommand();
while (true)
{
var input = Console.ReadLine();
chat.Message = String.IsNullOrEmpty(input) ? "blank" : input;
var message = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
switch (input)
{
case "test":
for (int i = 0; i < 1000; ++i)
{
Netcon.SendMessage(message);
continue;
}
break;
case "quit":
sendHeartbeat = false;
Netcon.Disconnect();
break;
case "connect":
Netcon.Connect(IPAddress.Parse("127.0.0.1"), 5000);
sendHeartbeat = true;
break;
case "system":
Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
{
Heartbeat = new Protos.Heartbeat
{
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
}
}
, ProtobufMessage.OpCode.System));
break;
case "login":
Console.Write("Enter username: ");
var username = Console.ReadLine();
Netcon.SendMessage(new ProtobufMessage(new Protos.SystemMessage
{
LoginRequest = new Protos.LoginRequest
{
UserName = string.IsNullOrEmpty(username) ? "Guest" : username
}
},
ProtobufMessage.OpCode.System));
break;
case "h":
if (sendHeartbeat)
{
Console.WriteLine("Stop sending heartbeat.");
sendHeartbeat = false;
}
else
{
Console.WriteLine("Start sending heartbeat.");
sendHeartbeat = true;
}
break;
default:
if (Netcon.IsConnected())
{
Netcon.SendMessage(message);
}
else
{
PrintCommand();
}
break;
}
}
}
static void PrintCommand()
{
Console.WriteLine("Client Command List: connect, system, test, login, h, quit");
}
}
}connect: 서버에 접속합니다.quit: 서버와의 연결을 해제합니다.system: 하트비트 메시지(시스템 메시지)를 보냅니다.h: 하트비트 플래그를 끄고 켭니다. 하트비트 메시지를 보내지 않을 시 자동으로 연결이 끊기는 것을 볼 수 있습니다.login: 추가적으로 이름을 입력해 로그인 요청 메시지를 보냅니다.test: 서버한테 메시지를 막 날립니다.default: 서버와의 연결이 수립돼있으면 채팅 메시지를 보냅니다.채팅 메시지 수신 시 송신자의 이름과 채팅 내용을 콘솔에 출력합니다.
static async Task Main(string[] args)
{
Console.InputEncoding = System.Text.Encoding.UTF8;
Console.OutputEncoding = System.Text.Encoding.UTF8;
Server server = new();
bool running = true;
PrintCommand();
while (running)
{
var input = Console.ReadLine();
if (string.IsNullOrEmpty(input))
{
continue;
}
input.ToLower();
switch (input)
{
case "start":
server.Start();
break;
case "stop":
await server.Stop();
break;
case "quit":
running = false;
break;
default:
PrintCommand();
break;
}
}
await server.Stop();
}
static void PrintCommand()
{
Console.WriteLine("Server Command List: start, stop, quit");
}namespace GameServer
{
internal class Server
{
NetworkController<ProtobufMessage> Netcon;
Task? ServerTask;
Task HeartbeatTask;
int CheckInterval = 5000; // 5sec
bool IsRunning = false;
CancellationTokenSource token = new();
MessageProcessor.MessageProcessor Processor;
ConcurrentDictionary<UInt32, ClientSession> Clients = new();
public event Action<ClientSession>? OnConnect;
public event Action<ClientSession>? OnDisconnect;
public LoginService LoginService;
public ChattingService ChattingService;
public Server()
{
Netcon = new NetworkController<ProtobufMessage>();
Netcon.OnConnect += (context) =>
{
OnConnected(context);
};
Netcon.OnDisconnect += (context) =>
{
OnDisconnected(context);
};
Processor = new(this);
LoginService = new(this);
ChattingService = new(this);
HeartbeatTask = new Task(async () =>
{
Console.WriteLine("Checking heartbeat");
while (!token.IsCancellationRequested)
{
await Task.Delay(CheckInterval);
CheckHeartbeat();
}
Console.WriteLine("Heartbeat task terminated");
});
}
public void Start()
{
Netcon.OpenServer(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 5000));
Console.WriteLine($"Server start running, IP: 127.0.0.1, Port: 5000");
IsRunning = true;
token = new();
HeartbeatTask.RunSynchronously();
ServerTask = Task.Run(() =>
{
Console.WriteLine("Server start receiving message");
try
{
while (!token.Token.IsCancellationRequested)
{
var message = Netcon.GetMessage(out var context, token.Token);
if (TryGetSession(context.SessionID, out var session))
{
if (session == null)
{
continue;
}
Processor.HandleMessage(session, message);
}
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Server task terminaterd");
}
catch (Exception ex)
{
Console.WriteLine($"ServerTask error: {ex}");
}
Console.WriteLine("Server work terminated");
});
}
public async Task Stop()
{
if (!IsRunning)
{
return;
}
Netcon.CloseServer();
IsRunning = false;
token.Cancel();
await ServerTask!;
}
void OnConnected(SocketContext<ProtobufMessage> context)
{
if (Clients.TryGetValue(context.SessionID, out var session))
{
Console.WriteLine("Server.OnConnected: ???");
context.Disconnect();
return;
}
var newSession = new ClientSession(context.SessionID);
if (!Clients.TryAdd(newSession.SessionID, newSession))
{
Console.WriteLine("Server.OnConnected: !!!");
}
Console.WriteLine($"New client connected. SessionID:{context.SessionID}, EndPoint: {context.RemoteAddress}:{context.RemotePort}");
OnConnect?.Invoke(newSession);
}
void OnDisconnected(SocketContext<ProtobufMessage> context)
{
if (!Clients.TryGetValue(context.SessionID, out var session))
{
Console.WriteLine("Server.OnDisconnected: ???");
context.Disconnect();
return;
}
OnDisconnect?.Invoke(session);
Clients.TryRemove(context.SessionID, out var sesion);
Console.WriteLine($"Client disconnected. SessionID:{context.SessionID}, EndPoint: {context.RemoteAddress}:{context.RemotePort}");
}
void CheckHeartbeat()
{
var copy = Clients.Values.ToArray();
var currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var c in copy)
{
if (c.LastActiveTime + CheckInterval < currentTime)
{
Console.WriteLine($"SessionID: {c.SessionID}{(c.IsAuthenticated ? " " + c.UserName : "")} has no response long time. Disconnect");
Netcon.Disconnect(c.SessionID);
}
}
}
public bool TryGetSession(UInt32 sessionID, out ClientSession? session)
{
var ret = Clients.TryGetValue(sessionID, out session);
return ret;
}
public void SendMessage(ClientSession session, ProtobufMessage message)
{
Netcon.SendMessageTo(session.SessionID, message);
}
}
}internal class ClientSession
{
public UInt32 SessionID;
public string UserName = "";
public bool IsAuthenticated => !string.IsNullOrEmpty(UserName);
public Int64 LastActiveTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
public ClientSession(UInt32 sessionID)
{
SessionID = sessionID;
}
}namespace GameServer.MessageProcessor
{
internal class MessageProcessor
{
Server Server;
SystemMessageHandler SystemHandler;
ChattingMessageHandler ChattingHandler;
public MessageProcessor(Server server)
{
Server = server;
SystemHandler = new SystemMessageHandler(server);
ChattingHandler = new ChattingMessageHandler(server);
}
public void HandleMessage(ClientSession session, ProtobufMessage message)
{
if (message != null)
{
session.LastActiveTime = message.Header.Timestamp;
switch ((ProtobufMessage.OpCode)message.Header.OpCode)
{
case ProtobufMessage.OpCode.Chatting:
ChattingHandler.HandleMessage(session, message);
break;
case ProtobufMessage.OpCode.System:
SystemHandler.HandlerMessage(session, message);
break;
}
}
}
}
}using NetworkController.Message;
using Protos;
using System;
using System.Collections.Generic;
using System.Text;
namespace GameServer.MessageProcessor
{
internal class SystemMessageHandler
{
Server Server;
public SystemMessageHandler(Server server)
{
Server = server;
}
public void HandlerMessage(ClientSession session, ProtobufMessage message)
{
if (message.Payload is not SystemMessage msg)
{
Console.WriteLine("Invalid ChattingMessage payload");
return;
}
switch (msg!.PayloadCase)
{
case SystemMessage.PayloadOneofCase.LoginRequest:
HandleLogin(session, msg.LoginRequest);
break;
case SystemMessage.PayloadOneofCase.Heartbeat:
HandleHeartbeat();
break;
}
}
void HandleLogin(ClientSession session, LoginRequest loginRequest)
{
Server.LoginService.Login(session, loginRequest);
}
void HandleHeartbeat()
{
}
}
}using Protos;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
namespace GameServer.Service
{
internal class LoginService
{
Server Server;
ConcurrentDictionary<string, ClientSession> LoginedClients = new();
public LoginService(Server server)
{
Server = server;
Server.OnDisconnect += (session) =>
{
Logout(session);
};
}
public bool Login(ClientSession session, LoginRequest requestMessage)
{
var username = requestMessage.UserName;
if (LoginedClients.ContainsKey(username))
{
return false;
}
session.UserName = username;
Console.WriteLine($"User {username} logged in.");
return LoginedClients.TryAdd(username, session);
}
public void Logout(ClientSession session)
{
if (!session.IsAuthenticated)
{
return;
}
LoginedClients.TryRemove(session.UserName, out var _);
Console.WriteLine($"User {session.UserName} logged out.");
session.UserName = string.Empty;
}
public IReadOnlyCollection<ClientSession> GetLoggedInSessionsSnapshot()
{
return LoginedClients.Values.ToArray();
}
}
}using NetworkController.Message;
using Protos;
namespace GameServer.Service
{
internal class ChattingService
{
Server Owner;
public ChattingService(Server owner)
{
Owner = owner;
}
public void ProcessChatting(ClientSession session, ChattingMessage message)
{
var targets = Owner.LoginService.GetLoggedInSessionsSnapshot();
var chat = new ChattingMessage
{
Username = session.UserName,
Message = message.Message
};
var msg = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
foreach (var target in targets)
{
Owner.SendMessage(target, msg);
}
}
}
}public class NetworkManager : MonoBehaviour
{
private NetworkController<ProtobufMessage> Netcon;
public int HeartbeatInterval = 1000; // milliseconds
private float lastHeartbeatTime;
public event Action<ProtobufMessage> OnMessageRecv;
private void OnEnable()
{
Netcon = new NetworkController<ProtobufMessage>();
lastHeartbeatTime = Time.time;
Netcon.OnDisconnect += Disconnect;
}
public void Login(string userName)
{
if (!Netcon.IsConnected())
{
Netcon.Connect(IPAddress.Parse("127.0.0.1"), 5000);
}
Netcon.SendMessage(new ProtobufMessage(
new SystemMessage
{
LoginRequest = new LoginRequest
{
UserName = userName
}
},
ProtobufMessage.OpCode.System)
);
}
private void Update()
{
if (Netcon.IsConnected())
{
if (Netcon.IsMessageAvailable())
{
var message = Netcon.GetMessage();
if (message == null)
{
return;
}
OnMessageRecv.Invoke(message);
}
}
HandleHeartbeat();
}
private void HandleHeartbeat()
{
if (HeartbeatInterval <= 0)
return;
if (!Netcon.IsConnected())
return;
float intervalSeconds = HeartbeatInterval / 1000f;
if (Time.time - lastHeartbeatTime >= intervalSeconds)
{
SendHeartbeat();
lastHeartbeatTime = Time.time;
}
}
private void SendHeartbeat()
{
SendMessage(new ProtobufMessage(
new SystemMessage
{
Heartbeat = new Heartbeat
{
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
}
},
ProtobufMessage.OpCode.System)
);
}
private void OnDestroy()
{
if (Netcon != null && Netcon.IsConnected())
{
Netcon.Disconnect();
}
}
public void SendChat(string text)
{
var chat = new ChattingMessage
{
Message = text
};
var message = new ProtobufMessage(chat, ProtobufMessage.OpCode.Chatting);
SendMessage(message);
}
public void SendMessage(ProtobufMessage message)
{
if (!Netcon.IsConnected())
{
return;
}
Netcon.SendMessage(message);
}
public void Disconnect()
{
if (Netcon.IsConnected())
{
Netcon.Disconnect();
}
}
public void Disconnect(SocketContext<ProtobufMessage> context)
{
Debug.Log("Server disconnected");
Disconnect();
}
}syntax = "proto3";
option csharp_namespace = "Protos";
message RoomMessage{
oneof Payload{
RoomList room_list = 1;
CreateRoom create_room = 2;
JoinRoom join_room = 3;
}
}
message RoomList{
repeated RoomInfo Rooms = 1;
}
message RoomInfo{
string RoomName = 1;
string OwnerName = 2;
repeated string Players = 3;
};
message CreateRoom{
string RoomName = 1;
};
message JoinRoom{
string RoomName = 1;
string OwnerName = 2;
};
syntax = "proto3";
option csharp_namespace = "Protos";
message GameMessage{
int32 session_iD = 1;
string user_name = 2;
bool do_broadcast = 3;
oneof Payload{
SyncMessage game_sync = 4;
RPC rpc = 5;
}
};
message SyncMessage{
int32 player_id = 1;
int32 prefab_id = 2;
int32 position_x = 3;
int32 position_y = 4;
int32 move_x = 5;
int32 move_y= 6;
string current_map = 7;
string user_name = 8;
int32 score = 9;
};
message RPC{
int32 player_id = 1;
string rpc_name = 2;
repeated string values = 3;
};public class GameNetworkCon : MonoBehaviour
{
public GameObject scoreboard;
public List<GameObject> scores;
int playerID = -1; // hostUser = 0, unauthorized = -1
ConcurrentDictionary<int, GameObject> Players = new(); // key = sessionid
public float SyncInterval = 1;
private float lastSyncTime = 0;
public SceneController sceneController;
void Awake()
{
GameManager.Instance.NetworkCon = this;
GameManager.Instance.NetworkManager.OnMessageRecv += HandleMessage;
}
private void OnDestroy()
{
GameManager.Instance.NetworkManager.OnMessageRecv -= HandleMessage;
}
void Start()
{
if (!GameManager.Instance.Session.IsMulti)
return;
scoreboard.SetActive(true);
Debug.Log(GameManager.Instance.Session.IsHost ? "This is host" : "This is guest");
}
void Update()
{
if (!GameManager.Instance.Session.IsMulti)
{
return;
}
UpdateScoreboard();
SendSyncMessage();
CheckVisibility();
}
void CheckVisibility()
{
var localPlayer = GameManager.Instance.GamePlayer?.GetComponent<Player>();
if (localPlayer == null) return;
foreach (var obj in Players.Values)
{
if (obj == null) continue;
var p = obj.GetComponent<Player>();
if (p == null) continue;
bool shouldActive = p.CurrentMap == localPlayer.CurrentMap;
if (obj.activeSelf != shouldActive)
obj.SetActive(shouldActive);
}
}
void UpdateScoreboard()
{
if (scores == null || scores.Count == 0)
return;
List<Player> players = new();
foreach (var obj in Players.Values)
{
if (obj == null) continue;
var p = obj.GetComponent<Player>();
if (p != null)
players.Add(p);
}
var localPlayer = GameManager.Instance.GamePlayer?.GetComponent<Player>();
if (localPlayer != null)
players.Add(localPlayer);
players.Sort((a, b) => b.Score.CompareTo(a.Score));
foreach (var s in scores)
if (s != null) s.SetActive(false);
for (int i = 0; i < players.Count && i < scores.Count; i++)
{
if (scores[i] == null) continue;
scores[i].transform.GetChild(0)
.GetComponent<TMP_Text>().text =
$"{players[i].UserName}: {players[i].Score}";
scores[i].SetActive(true);
}
}
void SendSyncMessage()
{
if (Time.time - lastSyncTime < SyncInterval)
return;
lastSyncTime = Time.time;
// 자신 게임의 상태를 송신
var s = GameManager.Instance.GamePlayer.GetComponent<Player>().GetSyncInfo();
s.PlayerId = playerID;
var g = new GameMessage
{
DoBroadcast = GameManager.Instance.Session.IsHost,
GameSync = s
};
var message = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
GameManager.Instance.NetworkManager.SendMessage(message);
// 호스트 유저라면 자기 컴퓨터에 있는 모든 유저들의 정보를 전파
if (GameManager.Instance.Session.IsHost)
{
foreach (var (i, p) in Players)
{
var info = p.GetComponent<Player>().GetSyncInfo();
info.PlayerId = i;
var gameMessage = new GameMessage
{
DoBroadcast = true,
GameSync = info
};
var msg = new ProtobufMessage(gameMessage, ProtobufMessage.OpCode.Game);
GameManager.Instance.NetworkManager.SendMessage(msg);
Debug.Log(gameMessage.ToString());
}
}
}
void HandleMessage(ProtobufMessage message)
{
switch ((ProtobufMessage.OpCode)message.Header.OpCode)
{
case ProtobufMessage.OpCode.Game:
HandleGamemessage(message.Payload as GameMessage);
break;
}
}
void HandleGamemessage(GameMessage message)
{
switch (message.PayloadCase)
{
case GameMessage.PayloadOneofCase.GameSync:
if (GameManager.Instance.Session.IsHost)
{
HostPlayerSyncGame(message);
}
else
{
SyncGame(message);
}
break;
case GameMessage.PayloadOneofCase.Rpc:
HandleRPC(message.Rpc);
break;
}
}
// 호스트 플레이어 전용
void HostPlayerSyncGame(GameMessage msg)
{
var message = msg.GameSync;
if (Players.TryGetValue(msg.SessionID, out var p))
{
}
else
{
var newPlayer = sceneController.SpawnPlayer(message);
newPlayer.GetComponent<Player>().UserName = message.UserName;
newPlayer.GetComponent<Player>().Sync(message);
Players.TryAdd(msg.SessionID, newPlayer);
Debug.Log("New player: " + message.UserName + "join to game");
InitClient(msg);
}
}
int InitClient(GameMessage msg)
{
var g = new GameMessage
{
SessionID = msg.SessionID,
DoBroadcast = false,
Rpc = new RPC
{
PlayerId = msg.SessionID,
RpcName = "SetPlayerID",
}
};
Debug.Log(msg.SessionID + " New Client try To Join " + g.Rpc.PlayerId);
Debug.Log($"Send Set Playr ID RPC to {g.SessionID}");
var m = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
GameManager.Instance.NetworkManager.SendMessage(m);
return g.Rpc.PlayerId;
}
void SyncGame(GameMessage msg)
{
var message = msg.GameSync;
// 아직 호스트 유저로부터 번호 부여 안 받은 상태
if (playerID == -1)
{
return;
}
if(playerID == message.PlayerId)
{
GameManager.Instance.GamePlayer.GetComponent<Player>().Sync(message);
return;
}
if (Players.TryGetValue(message.PlayerId, out var p))
{
p.GetComponent<Player>().Sync(message);
}
else
{
var newPlayer = sceneController.SpawnPlayer(message);
newPlayer.GetComponent<Player>().Sync(message);
newPlayer.GetComponent<Player>().UserName = message.UserName + "(" + message.PlayerId.ToString() + ")";
Players.TryAdd(message.PlayerId, newPlayer);
Debug.Log("New player: " + message.UserName + "join to game");
}
}
void HandleRPC(RPC message)
{
if (message.PlayerId == playerID)
{
return;
}
switch (message.RpcName)
{
case "SetPlayerID":
{
playerID = message.PlayerId;
GameManager.Instance.GamePlayer.GetComponent<Player>().UserName += $"({message.PlayerId})";
}
Debug.Log("Set Player ID: " + playerID);
break;
case "Move":
ProcessRPCMove(message);
break;
case "Attack":
ProcessRPCAttack(message);
break;
}
}
public void RPC_Move(InputValue value)
{
if (!GameManager.Instance.Session.IsMulti)
{
return;
}
var x = value.Get<Vector2>().x;
var y = value.Get<Vector2>().y;
var g = new GameMessage
{
Rpc = new RPC
{
PlayerId = playerID,
RpcName = "Move",
Values = { x.ToString(), y.ToString() }
}
};
if (GameManager.Instance.Session.IsHost)
{
g.DoBroadcast = true;
}
var m = new ProtobufMessage(g, ProtobufMessage.OpCode.Game);
GameManager.Instance.NetworkManager.SendMessage(m);
}
void ProcessRPCMove(RPC message)
{
if (!Players.TryGetValue(message.PlayerId, out var p))
return;
if (!float.TryParse(message.Values[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x))
return;
if (!float.TryParse(message.Values[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
return;
p.GetComponent<Player>().MovDir = new Vector2(x, y);
if (GameManager.Instance.Session.IsHost)
{
GameManager.Instance.NetworkManager.SendMessage(
new ProtobufMessage(new GameMessage
{
DoBroadcast = true,
Rpc = message
}, ProtobufMessage.OpCode.Game));
}
}
void ProcessRPCAttack(RPC message)
{
if (!Players.TryGetValue(message.PlayerId, out var p))
return;
p.GetComponent<Player>().Attack();
if (GameManager.Instance.Session.IsHost)
{
GameManager.Instance.NetworkManager.SendMessage(
new ProtobufMessage(new GameMessage
{
DoBroadcast = true,
Rpc = message
}, ProtobufMessage.OpCode.Game));
}
}
public void RPC_Attack()
{
if (!GameManager.Instance.Session.IsMulti)
return;
var g = new GameMessage
{
Rpc = new RPC
{
PlayerId = playerID,
RpcName = "Attack",
},
DoBroadcast = GameManager.Instance.Session.IsHost
};
GameManager.Instance.NetworkManager.SendMessage(
new ProtobufMessage(g, ProtobufMessage.OpCode.Game));
}
}