ASP.NET Core SignalR vereinfacht die Implementierung einer bidirektionalen Echtzeitkommunikation zwischen Server und Clients. In dieser Artikelserie, möchte ich anhand einer Demoanwendung zeigen, wie eine Echtzeitkommunikation mit Hilfe von SignalR aufgebaut und abgesichert werden kann.
Artikelserie
- Echtzeitkommunikation in Action ⬅
- Absicherung der Echtzeitkommunikation
- Mehr Sicherheit bei Tokens
Grundlagen
Aus der Vogelperspektive betrachtet bietet SignalR eine API zur Realisierung von bidirektionalen Remote Procedure Calls (RPC). Somit ist es möglich, dass Clients Methoden auf einem .NET-Core-Server aufrufen können, die in einem Hub definiert sind. Umgekehrt kann ein Server Funktionen aufrufen, die im Client-Code definiert sind.
Hinweis: Wenn ich hier im Artikel SignalR schreibe, meine ich ASP.NET Core SignalR, und nicht das “alte” ASP.NET SignalR.
Aufbau der Demoanwendung
Bei der Demoanwendung handelt sich um ein kleines bekanntes Spiel: Tic Tac Toe. Die einzelnen Züge des Spiels werden in Echtzeit an den anderen Nutzer übertragen. Für die Echtzeitübertragung der Daten wird eine API genutzt, die Clients nutzen, um eine SignalR-Verbindung herzustellen. Jedoch kann die Verbindung ohne eine Authentifizierung nicht genutzt werden.
Um sich bei der API zu authentifizieren, müssen sich die Benutzer des Clients via OIDC Authorization Code Flow beim IdentityServer einloggen. Danach erhält der Client ein Authentifizierungstoken des Benutzers, mit dem er sich beim Server authentifizieren kann. Diesem Teil widmen wir uns im zweiten Teil dieser Artikelserie.
Um die Registrierung und Nutzung der SignalR-Verbindungen zu demonstrieren, wurde ein Client mit Angular entwickelt. Natürlich kann auch jedes beliebige andere Framework (oder gar keines) genutzt werden. Das folgende Schaubild zeigt die grundlegende Architektur der Beispielapplikation auf:
In diesem Artikel beschreibe ich den Aufbau der SignalR-Verbindung sowie das Transportieren der Daten in Echtzeit zwischen dem Server und dem Client. Den gesamten Source Code findest Du in diesem GitHub Repository.
Anwendungen SignalR-fähig machen
Im folgenden Kapitel zeige ich, wie Events vom Server zum Client und umgekehrt versendet werden. Das Diagramm beschreibt den Ablauf des Spiels. Es besteht aus zwei Clients (Bob und Alice) und einem Server, die untereinander die Events versenden.
Spielablauf:
Um einem Spiel beizutreten, sendet ein Client das Event JoinSession
an den Server. Eine Session besteht aus zwei Spielern und speichert die aktuellen Spielzüge. Ist eine Session vollständig, also sind zwei Spieler vorhanden, sendet der Server an die Spieler der Session das Event StartGame
. Der erste Spieler einer Session startet danach das Spiel mit dem ersten Spielzug. Der Spieler sendet den Zug (also den Wert des TicTacToe Feldes) über das Event PlayRound
an den Server. Dieser speichert den Zug in der Session und sendet den Wert an den Gegenspieler weiter, in dem er das Event Play
schickt. Danach trägt der Gegenspieler den Zug im Spielfeld ein und macht seinen eigenen Zug. Hierzu wird wieder das Event PlayRound
gesendet. Dieser Abschnitt wird solange wiederholt, bis das Spiel beendet ist. Ist ein Spiel beendet, sendet der Server an beide Spieler der Session das Event GameOver
. Nachdem das Spiel vorbei ist, hat der Spieler die Option einem neuen Spiel beizutreten. Dazu sendet er wieder das Event JoinSession
und der Spielverlauf beginnt wieder von vorne.
Server
SignalR ist mittlerweile standardmäßig in .NET Core integriert und muss daher nicht separat über Nuget installiert werden. Um SignalR dem Server hinzuzufügen, muss im ersten Schritt die Startup.cs
angepasst werden. Zuerst wird SignalR der ServiceCollection
hinzugefügt mit dem Aufruf der Methode AddSignalR
.
Im nächsten Schritt wird ein Hub
benötigt, um Clients die Möglichkeit zu bieten, eine Echtzeitverbindung mit dem Server aufzubauen. Hierzu muss eine Klasse erstellt werden, die von der Basisklasse Hub
abgeleitet wird.
public class GamesHub : Hub
{
// ...
public override async Task OnConnectedAsync()
{
await _usersService.AddUserAsync(Context.ConnectionId, Context.User.UserName());
await base.OnConnectedAsync();
}
public async Task JoinSession()
{
var user = await _usersService.GetUserAsync(Context.ConnectionId);
Console.WriteLine($"User joined the session: {JsonSerializer.Serialize(user)}");
if (user != null)
{
await _manager.AddUserAsync(user);
}
}
public async Task PlayRound(int data)
{
Console.WriteLine(
$"PlayRound: User {Context.User.UserName()}; ConnectionId: {Context.ConnectionId} Wert {data}");
await _manager.PlayRoundAsync(Context.ConnectionId, data);
}
}
Hubs ermöglichen die Kommunikation von Client zu Server und von Server zu Client – also bidirektional. Öffentliche Methoden des Hubs können vom Client aufgerufen werden und umgekehrt können sie Funktionen aufrufen, die im Client-Code definiert sind. Für jeden Client, der sich mit einem Hub verbindet, wird die Methode OnConnectedAsync
aufgerufen. Diese überschreiben wir, um jede Verbindung in unserem UsersService
zu registrieren.
Die Methode JoinSession
dient dazu, dass ein Spieler einer neuen Session beitreten kann. Möchte ein Spieler eine Runde spielen, ruft der Client die Methode PlayRound
auf dem Server auf, mit dem aktuellen Spielwert.
Der letzte Schritt, um dem Client die Methoden des Hubs zur Verfügung zu stellen, ist diesen in der Startup.cs
als neuen Endpunkt zu registrieren. Im folgenden Sample wird der Hub mit der Route /tictactoe
als Endpunkt registriert.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
app.UseEndpoints(endpoints =>
{
endpoints.MapHub("/tictactoe");
endpoints.MapControllers();
});
}
Nach diesem Abschnitt ist es möglich, dass Clients eine SignalR-Verbindung mit dem Server aufbauen können und zudem auch im Hub definierte Methoden aufrufen können. Schauen wir uns den Code für den Client an.
Client
In diesem Abschnitt wird mithilfe eines Angular-Clients gezeigt, wie eine Verbindung zwischen dem Server und den Clients aufgebaut wird. Im Client muss im ersten Schritt die SignalR-Bibliothek @microsoft/signalr
installiert werden.
npm install @microsoft/signalr
Im Beispiel wurde für die SignalR-Verbindung ein Angular-Service (signalr.service.ts
) implementiert, der die SignalR-Verbindung verwaltet und Events sendet und empfängt. Um eine Verbindung aufzubauen, beziehungsweise zu starten, muss zuerst eine HubConnection
angelegt werden. In der startConnection
-Methode wird die Verbindung zum Server gestartet. Zum Erstellen einer neuen HubConnection
-Instanz wird der HubConnectionBuilder
genutzt. Dieser erstellt mit der Server-URL eine neue Instanz einer HubConnection
. Um eine Verbindung zum API Server aufzubauen, wird die Methode start
der HubConnection
aufgerufen. Schließlich haben wir Methoden wie addTransferPlayroundListener
, in der wir das Event Play
abonnieren und die Daten vom Server verarbeiten.
export class SignalRService {
public userPlayed$ = new Subject();
private hubConnection: signalR.HubConnection;
public async startConnection(): Promise {
this.hubConnection = new signalR.HubConnectionBuilder()
.withUrl(`${environment.apiBaseUrl}tictactoe`)
.build();
await this.hubConnection.start();
this.addTransferPlayRoundListener();
};
private addTransferPlayRoundListener(): void {
this.hubConnection.on('Play', data => this.userPlayed$.next(data));
};
}
Details zum Verbindungsaufbau
Beim Starten der SignalR-Verbindung wird im ersten Schritt ein HTTP POST negotiate
an den Server geschickt. Dieser antwortet dann mit den möglichen Verbindungstypen wie WebSocket, LongPolling oder ServerSentEvents. Zusätzlich bekommt der Client eine connectionId
vom Server zurück, der zur Identifikation des Clients dient.
Im folgenden Bild kann man erkennen, dass SignalR eine WebSocket-Verbindung mit dem GamesHub
aufgebaut hat Über ein Keep-Alive-Mechanismus wird geprüft, ob die Verbindung zum Server noch vorhanden ist. Sobald die physische Verbindung abbricht, beendet SignalR die Verbindung direkt.
Echtzeitkommunikation zwischen Server und Client
Nachdem der Server konfiguriert ist und auch in den Clients alles vorbereitet wurde, um eine Echtzeitkommunikation aufzubauen, fehlt jetzt natürlich noch das Senden und Empfangen von Events. In unserer Demoapplikation wird das erste Event gefeuert, sobald die Verbindung gestartet wurde. Hier wird das Event JoinSession
an den Server gesendet (siehe DevTools Screenshot). Sind zwei Spieler einer Session beigetreten, startet das Spiel. Startet ein Spiel, sendet der Server an die Spieler der Session das Event StartGame
(siehe DevTools Screenshot).
Server
Um den Verlauf des Spiels und die Spieler zu managen, wurde im Server die Klasse GameSessionManager
erstellt. Der GameSessionManager
erhält über Dependency Injection eine Instanz von einem IHubContext<GamesHub>
. Mithilfe eines IHubContext
hat man Zugriff auf eine Instanz des eigentlichen Hubs und somit auch auf dessen Methoden. So ist es möglich, in Services, Middlewares oder API-Controllern Events an die Clients zu versenden.
public GameSessionManager(IHubContext hubContext)
{
_hubContext = hubContext;
}
Um das Verwalten von Clients und das Senden von Events auf der Seite des Servers näher zu erläutern, schauen wir uns den GameSessionManager
mal etwas genauer an. In der Methode AddUser
kommt der IHubContext
direkt zweimal zum Einsatz.
Das erste Mal wird ein Spieler einer Gruppe hinzugefügt. Eine Gruppe ist eine Anzahl von Clients, die einer bestimmten Gruppe zugewiesen sind. Eine Gruppe wird automatisch erstellt, sobald ein Client einer Gruppe hinzugefügt wird, deren Name noch nicht existiert. In unserem Beispiel beschreibt eine Gruppe eine Session. Sie ist vollständig, sobald zwei Clients einer Gruppe zugewiesen worden sind. Jedoch kann eine Gruppe beliebig viele Clients aufnehmen.
Beim zweiten Einsatz des IHubContext
sendet dieser an eine bestimmte Gruppe eine Nachricht. Hierzu nutzt der IHubContext
die Client
Property des Hubs und ruft die Methode Group(string groupName)
auf. Mit dem Parameter, gibt er die Gruppe an, an die er ein Event senden möchte. Zum Schluss sendet der Server mit der Methode SendAsync
das Event StartGame
an alle Clients der ausgewählten Gruppe.
public async Task AddUserAsync(User client)
{
// Check if there is an open session to add the new user
var session = _sessions.FirstOrDefault(s => s.UserTwo == null && s.UserOne != null);
if (session != null)
{
Console.WriteLine(
$"Session found which is open and not started. {JsonSerializer.Serialize(session)}");
session.UserTwo = client;
session.ActiveUser = session.UserOne.ConnectionId;
}
else
{
// Create new session
session = new GameSession
{
SessionId = $"Game{_sessions.Count}",
UserOne = client
};
_sessions.Add(session);
}
// Add user to group. If the group does not yet exist, it is created automatically
await _hubContext.Groups.AddToGroupAsync(client.ConnectionId, session.SessionId);
if (!String.IsNullOrWhiteSpace(session.ActiveUser))
{
Console.WriteLine(
$"New Game will start now. Session: {session.SessionId}, User One: {session.UserOne}, User Two: {session.UserTwo}, Active User: {session.ActiveUser}");
// Send StartGame when the session is full and the game can be started
await _hubContext.Clients.Group(session.SessionId).SendAsync("StartGame", session);
}
}
In der Methode PlayRoundAsync
wird, wie auch in der AddAsync
-Methode, eine Nachricht an eine bestimmte Gruppe von Clients gesendet. Ist ein Spiel vorbei und ein Gewinner ermittelt, ist die Session vorbei und kann somit aufgelöst werden. Hierzu werden die Clients, aus der Gruppe wieder entfernt. Dazu wird die Methode RemoveFromGroupAsync
auf dem IHubContext
aufgerufen. Hier wird der angegebene Client aus der angegebenen Gruppe entfernt. Ist der letzte Client aus einer Gruppe entfernt, wird diese automatisch gelöscht.
Ist das Spiel nicht vorbei, wird ein Spielzug durchgeführt. Hierzu wird an alle Clients, außer dem aktuellen eine Nachricht versendet. Mit der Methode GroupExcept
können Clients, die die Nachricht nicht erhalten sollen, ausgeschlossen werden. Natürlich könnte man in diesem Fall auch die Nachricht direkt an den Gegenspieler senden, da es in unserem Beispiel maximal zwei Clients gibt. Hierzu würde man in unserem Beispiel die Methode folgendermaßen aufrufen: _hubContext.Clients.Client(session.ActiveUser).SendAsync("Play", value)
.
// GameSessionManager.cs
public async Task PlayRoundAsync(string clientId, int value)
{
var session = _sessions.FirstOrDefault(s =>
(s.UserOne?.ConnectionId == clientId || s.UserTwo?.ConnectionId == clientId) &&
s.ActiveUser == clientId);
if (session != null)
{
session.Moves.Add(new KeyValuePair(clientId, value));
if (CheckSessionState(session, out var winner))
{
// Stop game and remove user from group
await _hubContext.Clients.Group(session.SessionId).SendAsync("GameOver", winner);
await _hubContext.Groups.RemoveFromGroupAsync(session.UserOne.ConnectionId, session.SessionId);
await _hubContext.Groups.RemoveFromGroupAsync(session.UserTwo.ConnectionId, session.SessionId);
_sessions.Remove(session);
}
else
{
session.ActiveUser = session.UserOne.ConnectionId == clientId
? session.UserTwo.ConnectionId
: session.UserOne.ConnectionId;
// Play round, and send the value to the other user in the group
await _hubContext.Clients.GroupExcept(session.SessionId, clientId).SendAsync("Play", value);
}
}
}
Client
Um die vom Server gesendeten Events auch im Client zu verarbeiten, schauen wir uns den SignalRService
etwas genauer an. Im Angular-Client wird die hubConnection
genutzt, die bereits eine Verbindung zum Hub aufgebaut hat. Um ein Event vom Hub zu abonnieren, wird die Methode on
der Klasse HubConnection
genutzt. Im folgenden Sample Code abonniert der Client die Events StartGame
, GameOver
und PlayRound
.
this.hubConnection.on('StartGame', (session: GameSession) => {
this.gameRunning$.next(true);
this.activeSession$.next(session);
});
this.hubConnection.on('Play', (data) => {
this.userPlayed$.next(data);
});
this.hubConnection.on('GameOver', result => {
this.gameRunning$.next(false);
this.gameOver$.next(result);
this.activeSession$.next(null);
});
Damit die Events auch beim Client ankommen, müssen diese von Beginn an abonniert werden. Nachdem die SignalR-Verbindung gestartet wurde, möchte sich der Client an einer Session anmelden. Dies wird über joinNewSession
gemacht. Um eine Nachricht an den Server zu senden, wird die Methode invoke
der HubConnection
-Klasse genutzt. Wichtig ist, dass die Methodennamen im Server Case-sensitive sind und genau so dann auch im Client aufgerufen werden müssen. Das Gleiche gilt im Übrigen auch für die Methoden im Client.
public async startConnection(): Promise {
// ...
this.addStartGameListener();
this.addGameOverListener();
this.addTransferPlayRoundListener();
this.addReconnectListener();
await this.hubConnection.start();
await this.joinNewSession();
};
public async joinNewSession() {
await this.hubConnection.invoke('JoinSession');
}
await this.hubConnection.invoke('PlayRound', `${data}`);
Fallstricke bei der Entwicklung und Nutzung von Echtzeitkommunikation mit SignalR
- Wird eine Verbindung unterbrochen, zum Beispiel durch das Ausfallen eines Server oder durch den Verlust der Internetverbindung, muss man darauf achten, dass die SignalR-Verbindung wieder aufgebaut werden muss. Hierzu bietet die SignalR-Bibliothek eine
Reconnect
Methode. Jedoch ist hier zu beachten, dass Nachrichten verloren gehen können und somit der aktuelle Status an Daten im Client und Server überprüft werden muss. - Wird eine Methode falsch geschrieben oder ein falsches Model über die Events versendet, kann es zu Fehlern kommen. Hierzu muss eine eigene Fehlerbehandlung sowohl im Client als auch im Server implementiert werden.
- Wird SignalR auf mehreren Servern gehostet, kann es hier zu Problemen kommen, da eine persistente Verbindung verlangt wird. Um SignalR auf mehreren Servern zu hosten, beziehungsweise nach oben zu skalieren, stehen zwei Optionen zur Verfügung: entweder der Azure SignalR Service, der die Verwaltung der SignalR Verbindungen übernimmt oder Redis, der die Verbindungen in einem Key-Value Store verwaltet und die Events an die richtigen Server und Clients weiterleitet.
Fazit
Anhand der Demoapplikation haben wir gesehen, dass der reine Verbindungsaufbau mit SignalR einfach und schnell zu implementieren ist. Die eigentliche Implementierung, also welche Daten und Events versendet werden, sind stark vom Use Case abhängig. Ein einfaches Signalisieren von “Es sind neue Daten da, bitte laden” ist einfach zu bewerkstelligen. Im Falle unseres TicTacToes wird das Signalisieren schon etwas umfangreicher, da wir mehr States auf dem Server berücksichtigen müssen. Die Fallstricke haben aufgezeigt, dass wir beispielsweise beim Verlust der Verbindung und dem darauffolgenden Reconnect, den Server State wieder an den Client übertragen müssten. Man muss ich daher vorab viele Gedanken darüber machen, wie man mit solchen Szenarien umgehen will, da dies eine Implementierung auf Server- und Clientseite mit sich bringt.
Im zweiten Teil zeigen wir, wie eine ASP. NET Core SignalR-Verbindung in wenigen Schritten schnell abgesichert werden kann.