feat: improve scalability of TcpGameServer

This commit is contained in:
Aaron Yarborough 2025-02-21 22:36:38 +00:00
parent ba40a00cf2
commit 2ad81aa54d
6 changed files with 117 additions and 60 deletions

View file

@ -56,11 +56,11 @@ public static class Program
// Request server list as soon as login has succeeded
if (authResultMessage.IsSuccessful)
{
Console.WriteLine("Getting server list...");
_ = tcpClient.Client.Send(new[] { (byte)ServerPacketIn.ListServers });
}
// if (authResultMessage.IsSuccessful)
// {
// Console.WriteLine("Getting server list...");
// _ = tcpClient.Client.Send(new[] { (byte)ServerPacketIn.ListServers });
// }
break;

View file

@ -1,9 +0,0 @@
using System.Net.Sockets;
using GServer.Common.Networking.Core;
namespace GServer.Common.Networking.Messages;
public interface IMessageHandler
{
Task HandleMessageAsync(Socket clientSocket, MessageMemoryStream messageStream);
}

View file

@ -0,0 +1,25 @@
using System.Net.Sockets;
namespace GServer.Server;
/// <summary>
/// Holds information related to a connected client.
/// </summary>
/// <param name="client"></param>
public class ClientState(
TcpClient client)
{
public TcpClient Client { get; } = client;
/// <summary>
/// The ID of the associated player.
/// </summary>
public Guid PlayerId { get; set; }
/// <summary>
/// The username of the associated player.
/// </summary>
public string Username { get; set; }
/// <summary>
/// The timestamp (UTC) of the last received packet from the client.
/// </summary>
public DateTime LastHeartbeat { get; set; }
}

View file

@ -17,13 +17,10 @@ internal sealed class Program
// Register services
_ = builder.Services.AddScoped<IAuthService, AuthService>();
_ = builder.Services.AddScoped<ITcpMessageHandler, TcpMessageHandler>();
_ = builder.Services.AddTransient<ITcpGameServer>((services) =>
{
return new TcpGameServer(
new IPEndPoint(IPAddress.Any, ListenPort),
services.GetRequiredService<ITcpMessageHandler>()
);
});
_ = builder.Services.AddTransient<ITcpGameServer>((services) => new TcpGameServer(
new IPEndPoint(IPAddress.Any, ListenPort),
services.GetRequiredService<ITcpMessageHandler>()
));
// Start service
using IHost host = builder.Build();
@ -33,12 +30,12 @@ internal sealed class Program
private static void ApplicationLifetime(IServiceProvider hostProvider)
{
using IServiceScope serviceScope = hostProvider.CreateScope();
Thread serverWorker = new(() =>
{
using IServiceScope serviceScope = hostProvider.CreateScope();
ITcpGameServer server = serviceScope.ServiceProvider.GetRequiredService<ITcpGameServer>();
server.Start();
server.StartAsync();
while (true)
{

View file

@ -1,67 +1,85 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using GServer.Common.Networking.Core;
namespace GServer.Server;
public interface ITcpGameServer
public interface ITcpGameServer : IDisposable
{
void Dispose();
void Start();
/// <summary>
/// Bind the server to the given endpoint.
/// </summary>
Task StartAsync();
}
public class TcpGameServer(
IPEndPoint endPoint,
ITcpMessageHandler messageHandler
) : IDisposable, ITcpGameServer
) : ITcpGameServer
{
private readonly TcpListener _tcpListener = new(endPoint);
/// <summary>
/// Bind the server to the given endpoint.
/// </summary>
public void Start()
private readonly ConcurrentDictionary<TcpClient, ClientState> _clients = new();
private bool _disposed;
public async Task StartAsync()
{
Console.WriteLine($"Starting ${nameof(TcpGameServer)} listener...");
_tcpListener.Start();
Console.WriteLine($"{nameof(TcpGameServer)} listening on {endPoint}");
Console.WriteLine($"{nameof(TcpGameServer)} listening on {endPoint}...");
while (true)
while (!_disposed)
{
try
{
Console.WriteLine("Waiting for a connection...");
TcpClient client = _tcpListener.AcceptTcpClient();
Console.WriteLine("Client accepted!");
Thread worker = new(new ParameterizedThreadStart(HandleClient!)); // TODO: use thread pools instead
worker.Start(client);
TcpClient client = await _tcpListener.AcceptTcpClientAsync();
Console.WriteLine($"Client accepted: {client.Client.RemoteEndPoint}");
ClientState clientState = new(client);
_clients.TryAdd(client, clientState);
// Handle client asynchronously using the thread pool
_ = Task.Run(() => HandleClientAsync(client, clientState));
}
catch (Exception ex)
catch (Exception ex) when (!_disposed)
{
Console.WriteLine($"An error occured while processing a tcp connection: {ex.Message}");
Console.WriteLine($"Error accepting client: {ex.Message}");
}
}
}
private async void HandleClient(object clientObj)
private async Task HandleClientAsync(TcpClient client, ClientState state)
{
if (clientObj is not TcpClient tcpClient)
{
return;
}
Console.WriteLine($"Processing request from client: {client.Client.RemoteEndPoint} (Player Id = {state.PlayerId}, Username = {state.Username})");
try
{
using (tcpClient)
using (NetworkStream stream = tcpClient.GetStream())
using (client)
await using (NetworkStream stream = client.GetStream())
{
byte[] data = new byte[tcpClient.ReceiveBufferSize];
while (stream.Read(data, 0, data.Length) != 0)
byte[] buffer = ArrayPool<byte>.Shared.Rent(client.ReceiveBufferSize);
try
{
// Use the in-memory buffer to process the message
await messageHandler.HandleMessageAsync(stream.Socket, new MessageMemoryStream(data));
while (client.Connected)
{
// TODO: support cancellation tokens
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
break; // Client disconnected
await messageHandler.HandleMessageAsync(stream.Socket, new MessageMemoryStream(buffer), state);
state.LastHeartbeat = DateTime.UtcNow;
}
}
catch (Exception e)
{
Console.WriteLine($"Error occured reading buffer: {e.Message}");
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
@ -69,17 +87,39 @@ public class TcpGameServer(
{
Console.WriteLine($"Error handling client: {ex.Message}");
}
finally
{
_clients.TryRemove(client, out _);
Console.WriteLine($"Client disconnected: {client.Client.RemoteEndPoint}");
}
}
private void Stop()
{
if (_disposed)
return;
Console.WriteLine($"Stopping ${nameof(TcpGameServer)} listener...");
// Stop listening for new TCP connections
_tcpListener.Stop();
// Disconnect all connected clients
foreach (TcpClient client in _clients.Keys)
{
client.Close();
}
// Stop tracking all clients
_clients.Clear();
Console.WriteLine($"Stopped ${nameof(TcpGameServer)} listener.");
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Stop();
GC.SuppressFinalize(this);
}

View file

@ -12,14 +12,14 @@ namespace GServer.Server;
public interface ITcpMessageHandler
{
Task HandleMessageAsync(Socket clientSocket, MessageMemoryStream messageStream);
Task HandleMessageAsync(Socket clientSocket, MessageMemoryStream messageStream, ClientState state);
}
public class TcpMessageHandler(
IAuthService authService
) : IMessageHandler, ITcpMessageHandler
) : ITcpMessageHandler
{
public async Task HandleMessageAsync(Socket clientSocket, MessageMemoryStream messageStream)
public async Task HandleMessageAsync(Socket clientSocket, MessageMemoryStream messageStream, ClientState state)
{
ServerPacketIn serverPacketIn = (ServerPacketIn)messageStream.ReadByte();
@ -36,6 +36,10 @@ public class TcpMessageHandler(
? new AuthResponseMessage(true, Guid.NewGuid().ToString(), failureReason: null)
: new AuthResponseMessage(false, null, AuthResponseFailure.IncorrectLoginOrPassword);
await SendMessageAsync(resp, clientSocket);
// TODO: Placeholder for now -- set up actual username and player ID
state.Username = msg.Username;
state.PlayerId = Guid.NewGuid();
break;
}