Herschreven Server¶
Doelstelling¶
Ik vond de server-side in de template onhandig. Deels omdat het verwarrend in elkaar zat zoals de client-side, maar ook omdat het in javascript is geschreven in plaats van C#. Als het geschreven was in C# zou code tussen de client en server gedeeld kunnen worden. Wat het maken van het spel veel gemakkelijker maakt.
Toegepaste oplossing¶
ASP.net Core¶
Voor de server side heb ik ASP.net core gekozen, omdat het de standaard server is voor C#. Van Microsoft zelf.
Ook is het veel sneller dan Express, wat in de template gebruikt werd. Zie de web framework benchmark van Tech Empower(Web Framework Benchmark, 2023).
Requests per seconde | Plaintext | JSON Serialisatie |
---|---|---|
Express | 113,117 | 92,604 |
ASP.net Core | 7,006,142 | 1,042,029 |
De andere benchmarks gaan over database gebruik, waarvoor beide ASP.net als Express meerdere opties voor hebben.
SignalR¶
Een andere reden om ASP.net Core te gebruiken is dat het komt met SignalR ingebouwd. Dit is belangrijk omdat de originele socket client, SocketIO, geen server-side versie heeft dat in C# is geschreven. SignalR heeft dit wel.
Daarnaast is het niet veel anders dan SocketIO. Bijvoorbeeld supporten ze beide meerdere soorten transports zoals websockets, server-sent events en long polling. Samen met ingebouwde afhandeling van verbindingen.
Hub¶
Om met ASP.net core en SignalR berichten op de server te ontvangen is er maar eigenlijk één onderdeel nodig.
De SignalR hub.
Alleen moet de reactie voor elke packet hierin gezet worden,
dus heb ik gebruik gemaakt van de partial
keyword om dit een beetje te verdelen over meerdere bestanden.
public partial class HubHandler : Microsoft.AspNetCore.SignalR.Hub
{
public const string LOBBY_GROUP = "Lobby";
protected readonly ILogger<HubHandler> logger;
public readonly PlayersService Players;
public readonly RoomsService Rooms;
public HubHandler(ILogger<HubHandler> log, PlayersService players, RoomsService room)
{
logger = log;
Players = players;
Rooms = room;
}
public Task Ready(ReadyPacket packet)
{
var id = Guid.NewGuid().ToString().Replace("-", "");
var player = new Player(id);
Players.AddPlayer(player);
logger.LogInformation("User ({id}) connected, with connection ({connection})", id, Context.ConnectionId);
var pingPacket = new PingPacket{
id = Guid.NewGuid().ToString().Replace("-", "")
};
return Task.WhenAll([
Groups.AddToGroupAsync(Context.ConnectionId, id),
Groups.AddToGroupAsync(Context.ConnectionId, LOBBY_GROUP),
Clients.Caller.SendPacketAsync(pingPacket),
Clients.Caller.SendPacketAsync(new RoomListPacket{
rooms = Rooms.GetPublicRoomData().ToArray()
})
]);
}
public async override Task OnDisconnectedAsync(Exception? e)
{
var player = Players.GetPlayer(Context.GetUserId() ?? "");
if (player == null)
logger.LogWarning("Disconnecting connection ({connection}) somehow didn't have a valid user id", Context.ConnectionId);
else
await RemovePlayerConnection(player);
await base.OnDisconnectedAsync(e);
}
}
Hier ook een deel van het rooms gedeelte van de hub. Om ook te laten zien hoe op bepaalde packets word gereageerd.
public partial class HubHandler
{
...
[PlayerBarrier]
[RoomBarrier(inverted = true)]
public async Task CreateRoom(CreateRoomPacket create)
{
var room = Rooms.CreateRoom(create.roomName);
var roomUpdate = new RoomListUpdatePacket()
{
room = room.ToPublicRoom(),
change = RoomListUpdateType.Added
};
await Task.WhenAll([
AddPlayerToRoom(Player, room),
Clients.Group(LOBBY_GROUP).SendPacketAsync(roomUpdate)
]);
}
[PlayerBarrier]
public Task JoinRoom(JoinRoomPacket join)
{
var room = Rooms.GetRoom(join.roomId)
?? throw new UserFriendlyException("Failed to join room", "Could not join the given room, as it doesn't exist.");
return AddPlayerToRoom(Player, room);
}
[PlayerBarrier]
public async Task LeaveRoom(LeaveRoomPacket leave)
=> await RemovePlayerFromRoom(Player);
...
}
Services¶
De hub heeft alleen één probleem, het word opnieuw geinstantiëerd voor elke request. Dus erin kan niet informatie opgeslagen worden.
Als oplossing hiervoor heb ik services moeten maken, die door middel van dependency injection van ASP.net beschikbaar worden gemaakt waar nodig.
public class RoomsService
{
protected readonly ILogger<RoomsService> logger;
public Dictionary<string, Room> rooms = new();
public RoomsService(ILogger<RoomsService> log)
{
logger = log;
}
public IEnumerable<PublicRoomData> GetPublicRoomData()
=> rooms
.Values
.Select(room => room.ToPublicRoom());
public Room CreateRoom(string name)
{
var room = new Room(name);
rooms.Add(room.id, room);
return room;
}
public Room? GetRoom(string roomId)
=> rooms.GetValueOrDefault(roomId);
}
Hieronder ook de Room class zelf, die in de RoomsService word opgeslagen.
public class Room
{
public string id = Guid.NewGuid().ToString().Replace("-", "");
public string name;
public List<Player> players = new List<Player>();
public GameInfo? game;
public Room(string roomName)
{
name = roomName;
}
public void AddPlayer(Player player)
=> players.Add(player);
public void RemovePlayer(Player player)
=> players.Remove(player);
public PublicRoomData ToPublicRoom()
=> new(id, name);
}
Hub filters¶
Omdat er in de methods van de hub veel herhalende checks worden gedaan heb ik later ook filters gemaakt, die dit veel makkelijker en mooier maken. Deze zijn in de vorm van attributes voor deze methods te zien, zoals hier:
[PlayerBarrier]
[RoomBarrier]
[GameBarrier(checkForTurn = true)]
public Task TilePlaced(PlaceTilePacket place)
{
...
Base barrier filter¶
Omdat deze zelf ook herhaling hadden heb ik een basis barrier/filter gemaakt.
public abstract class BaseBarrierFilter : IHubFilter
{
public abstract ValueTask<object?> InvokeMethodAsync(HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next);
protected T? GetAttribute<T>(HubInvocationContext context) where T : class
=> Attribute.GetCustomAttribute(context.HubMethod, typeof(T)) is T t
? t
: null;
protected T CastHubTo<T>(HubInvocationContext context) where T : class
{
if (context.Hub is T t)
return t;
throw new InvalidOperationException(
$"{GetType().Name} requires that hub '{context.Hub}' with called method '{context.HubMethodName}' implements {typeof(T).Name}"
);
}
}
Met een struct voor de titel en bericht van gebruikers vriendelijke exceptions, zodat dit gemakkelijk als instelling in de attributes van filters toegevoegd kan worden.
public struct BarrierMessage(string title, string message)
{
public string title = title;
public string message = message;
public UserFriendlyException ToException(Exception? inner = null)
=> new(title, message, inner);
}
Player barrier¶
Hier is een van de barrier/filters dat de basis barrier/filter gebruikt.
public class PlayerBarrierFilter : BaseBarrierFilter
{
...
public override async ValueTask<object?> InvokeMethodAsync(HubInvocationContext context, Func<HubInvocationContext, ValueTask<object?>> next)
{
var playerBarrier = GetAttribute<PlayerBarrierAttribute>(context);
if (playerBarrier == null)
return await next(context);
var barrierable = CastHubTo<IPlayerBarrierable>(context);
var player = players.GetPlayer(context.Context.GetUserId() ?? "")
?? throw playerBarrier.message.ToException();
barrierable.Player = player;
return await next(context);
}
}
public class PlayerBarrierAttribute : Attribute
{
public BarrierMessage message = new("Failure", "You are somehow not a player.");
}
Deze heeft ook een simpele interface zodat het informatie in de hub kan zetten.
Log filter¶
Als aller laatste filter heb ik ook een algemene log filter gemaakt, dat simpelweg elke request logt. Met informatie zoals welke method en de data dat verstuurd is.
public class HubLogFilter : IHubFilter
{
...
public async ValueTask<object?> InvokeMethodAsync(HubInvocationContext context, Func<HubInvocationContext, ValueTask<object?>> next)
{
hubLogger.LogInformation(
"({conn}) '{method}': {data}",
context.Context.ConnectionId,
context.HubMethodName,
context.HubMethodArguments.Select(arg => $"({arg?.GetType().Name} {JsonSerializer.Serialize(arg, BasePacket.JSON_OPTIONS)})")
);
Hier word ook speciale afhandeling gedaan voor verschillende soorten exceptions. Zodat de gebruiker altijd een mooie error bericht ontvangt, dat nooit geheime informatie over de werking van de server weggeeft.
Hiervoor heb ik ook de speciale UserFriendlyException gemaakt, dat speciaal wel de informatie erin naar clients stuurt. Zodat overal in de code een mooie bericht gemaakt kan worden voor clients.
try {
return await next(context);
}
catch (HubException)
{
throw;
}
catch (UserFriendlyException e)
{
await context.Hub.Clients.Caller.SendPacketAsync(new ErrorPacket{
origin = context.HubMethodName,
title = e.Title,
message = e.Message
});
throw;
}
catch (Exception e)
{
await context.Hub.Clients.Caller.SendPacketAsync(new ErrorPacket{
origin = context.HubMethodName,
title = "Failure",
message = "Something went wrong, please try again."
});
throw new HubException($"Method {context.HubMethodName} failed without user friendly exception", e);
}
}
public Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
{
hubLogger.LogInformation("New connection ({connection})", context.Context.ConnectionId);
return next(context);
}
public Task OnDisconnectedAsync(HubLifetimeContext context, Exception? e, Func<HubLifetimeContext, Exception?, Task> next)
{
if (e != null)
{
hubLogger.LogInformation(
"Connection ({connection}) for user ({id}) disconnected with exception: {e}",
context.Context.ConnectionId,
context.Context.GetUserId(),
e
);
} else {
hubLogger.LogInformation(
"Connection ({connection}) for user ({id}) disconnected properly",
context.Context.ConnectionId,
context.Context.GetUserId()
);
}
return next(context, e);
}
}
public class UserFriendlyException(string title, string message, Exception? inner = null) : Exception(message, inner)
{
public string Title { get; } = title;
}
Packets¶
Nadat de server aangepast werd had ik gekozen om ook de structuur van de soorten berichten dat verstuurd worden aan te passen. Dit was deels nodig omdat SignalR natuurlijk anders gebruikt moet worden dan SocketIO. Maar ook om dit op te schonen en een mooiere structuur aan te geven.
BasePacket¶
[Flags]
public enum PacketTarget
{ ToServer, ToClient }
public abstract class BasePacket(string method, PacketTarget target)
{
public static JsonSerializerOptions JSON_OPTIONS {
get {
var options = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
IgnoreReadOnlyFields = true,
IgnoreReadOnlyProperties = true,
Na veel getest werkte deze optie om fields ook te deserializen naar json niet. Dus heb ik het opgegeven en alle data naar properties veranderd.
Later toen ik de daaronder gedefineerde JsonNumberEnumConverter
toevoegde werkte het niet,
totdat ik mijn visual studio code herstartte.
Volgens mij is toen ook deze fields optie weer gaan werken,
maar ik heb er niet echt naar gekeken.
Volgens mij komt het omdat deze SignalR protocol System.Text.Json
gebruikt,
dat code genereert om efficiënter te kunnen serializen en deserializen.
In plaats van iets zoals Newtonsoft.Json
dat dit dynamisch doet.
Alleen word dit volgens mij niet volledig gedaan totdat je dotnet compleet opnieuw opstart.
Wat deze problemen veroorzaakte,
die opgelost werd door simpelweg vscode alleen opnieuw op te starten.
IncludeFields = true
};
options.Converters.Add(new JsonNumberEnumConverter<TileEdgeType>());
options.Converters.Add(new JsonStringEnumConverter());
return options;
}
}
[JsonIgnore]
public readonly PacketTarget target = target;
[JsonIgnore]
public readonly string method = method;
public static T FromJson<T>(string json) where T : BasePacket, new()
{
return JsonSerializer.Deserialize<T>(json, JSON_OPTIONS)
?? throw new ArgumentNullException(nameof(json));
}
}
Hier heb ik ook nog een C# extensie gemaakt voor packets.
Dit heb ik alleen niet in de packet zelf gestopt,
omdat dan de type informatie over de specifieke packet die naar json geserialized moet worden verloren gaat.
Wat System.Text.Json
nodig heeft om te weten welke properties er bestaan en in de json moeten komen te staan.
public static class PacketExtensions
{
public static string ToJson<T>(this T packet) where T : BasePacket
{
return JsonSerializer.Serialize(packet, BasePacket.JSON_OPTIONS);
}
}
PingPacket¶
Dit is dan hoe de echte packets eruitzien. Ik heb gekozen om alleen één hier te laten zien omdat ze onder elkaar niet verschillen. Het enigste verschil dat ze hebben is de inhoud, bij welke method ze horen en of de packet bestemd is voor clients, de server of ze allebei.
public class PingPacket() : BasePacket("Ping", PT.ToClient | PT.ToServer)
{
public string id { get; set; }
}
SocketClient¶
Natuurlijk om dit te gebruiken moet als laatste ook de client aangepast worden. Gelukkig was dit gemakkelijk dankzij de geabstraheerde vorm van de SocketClient. Ik hoefde alleen de inhoud van de methods aan te passen om het werkend te krijgen.
Maar omdat ik hier toch mee bezig was had ik gekozen om om het ook gelijk op te schonen.
Hierdoor moest ik natuurlijk wel alle code dat de SocketClient gebruikt aanpassen.
Maar als resultaat is het veel simpeler geworden.
Bijvoorbeeld is er nu geen event
meer,
de handlers van packets worden namelijk afgehandeld met IDisposable.
public class SocketClient
{
...
private SocketClient()
{
ServerAddressReader.ServerAddress serverAddress = ServerAddressReader.Read();
string location = serverAddress.Location;
string path = serverAddress.Path;
connection = new HubConnectionBuilder()
.WithUrl(new Uri(location + path))
#if DEBUG
.WithServerTimeout(TimeSpan.FromMinutes(15))
.WithKeepAliveInterval(TimeSpan.FromMinutes(15))
#endif
.WithAutomaticReconnect()
.WithStatefulReconnect()
.ConfigureLogging(options => {
options.ClearProviders();
options.AddConsole();
options.AddDebug();
})
.AddJsonProtocol(options => {
options.PayloadSerializerOptions = BasePacket.JSON_OPTIONS;
})
.Build();
}
public async void Initialize()
{
SubscribeToPacket<PingPacket>(HandlePingPacket);
try {
await connection.StartAsync();
} catch (Exception e) {
throw new WebException($"Failed to connect to server:\n{e.Message}");
}
SendQueuedDataPackets();
connection.Reconnected += async (connectionId) => {
SendQueuedDataPackets();
};
}
public IDisposable SubscribeToPacket<T>(Action<T> onDataReceived) where T : BasePacket, new()
{
var t = new T();
if (!t.target.HasFlag(PacketTarget.ToClient))
{
throw new ArgumentException($"Tried subscribing to packet type {typeof(T).Name}, which does not get sent to clients", nameof(T));
}
return connection.On<T>(t.method, (data) => {
var actionName = onDataReceived.GetMethodInfo().DeclaringType?.Name;
var stringData = JsonSerializer.Serialize(data, BasePacket.JSON_OPTIONS);
App.Logger.LogInformation(
"({methodHolder}) '{method}': ({dataType} {data})",
actionName ?? "?",
t.method,
data?.GetType().Name,
stringData
);
onDataReceived.Invoke(data!);
});
}
public void UnsubscribeAllFromPacket<T>() where T : BasePacket, new()
=> connection.Remove(new T().method);
public void SendDataPacket<T>(T packet) where T : BasePacket
{
if (!packet.target.HasFlag(PacketTarget.ToServer))
{
throw new ArgumentException($"Tried sending packet type ${packet.GetType().Name} to server, which cannot be done", nameof(packet));
}
if (connection.State == HubConnectionState.Connected)
{
connection.InvokeAsync(packet.method, packet);
}
else
{
queuedDataPackets.Enqueue(packet);
}
}
...
}
Sequentiediagram¶
Bronnen¶
- Web Framework Benchmarks. (2023, 17 oktober) techempower.com.
Geraadpleegd op 13 februari 2024, van https://www.techempower.com/benchmarks/#hw=ph&test=plaintext§ion=data-r22 - C# documentation. (z.d.) learn.microsoft.com.
Laatst geraadpleegd op 25 februari 2024, van https://learn.microsoft.com/en-uk/dotnet/csharp/ - ASP.NET core documentation. (z.d.) learn.microsoft.com.
Laatst geraadpleegd op 25 februari 2024, van https://learn.microsoft.com/en-us/aspnet/core/?view=aspnetcore-8.0 - SignalR core documentation. (z.d.) learn.microsoft.com.
Laatst geraadpleegd op 25 februari 2024, van https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction?view=aspnetcore-8.0
Gecreëerd: March 11, 2024