Echtzeitkommunikation ist heutzutage nicht mehr weg zu denken. In kollaborativen Apps, Dashboards, Messaging Apps, Spiele oder dergleichen, überall werden in Echtzeit Daten transportiert. Zur Realisierung solcher Echtzeitkommunikation bietet Microsoft das Open-Source-Framework ASP.NET Core SignalR an.

ASP.NET Core SignalR vereinfacht die Implementierung einer bidirektionalen Echtzeitkommunikation zwischen Server und Clients. In dieser kleinen Artikelserie, möchte ich anhand einer Demoanwendung zeigen, wie eine Echtzeitkommunikation mit Hilfe von SignalR aufgebaut und abgesichert werden kann.

Artikelserie

  1. Echtzeitkommunikation in Action ⬅
  2. Absicherung der Echtzeitkommunikation

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.

Screenshot der Demoanwendung

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:

Architektur der Demoanwendung

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.

Ablaufdiagramm vom Spiel

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.

Cheat Sheet: Realtime Connection mit ASP.NET Core SignalR

Patrick Jahr hat zum Thema Realtime Connection mit ASP.NET Core SignalR ein Cheat Sheet erstellt, auf dem er kompakt alles Wissenswerte zusammenfasst hat.

Melden Sie sich kostenlos zu unserem Newsletter an, um das Cheat Sheet per E-Mail zu erhalten.

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<GamesHub>("/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<any>();
    private hubConnection: signalR.HubConnection;

    public async startConnection(): Promise<void> {
        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.

SignalR Verbindungsaufbau

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.

SignalR Verbindung mit dem GamesHub

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).

Event-Log in den Chrome DevTools

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<GamesHub> 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<string, int>(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<void> {
    // ...
    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.

Wenn Du über neue Artikel, Screencasts und Webinare unsere Experten informiert werden möchtest, kannst Du Dich hier für unseren kostenlosen, monatlichen Dev-Newsletter anmelden.

Related Articles

signalr
ASP.NET Core SignalR: Absicherung der Echtzeitkommunikation - Teil 2
Im ersten Teil der Artikelserie haben wir uns den generellen Verbindungsaufbau und Datenaustausch zwischen Client und Server mithilfe von SignalR angeschaut. Im zweiten Teil widmen wir uns nun der Absicherung unserer Echtzeitkommunikation. Ich zeige euch, wie ihr durch kleine…
Patrick Jahr
pwa
Additional Approaches: Advanced Progressive Web Apps - Push Notifications Under Control - Part 4
In the previous parts of this article series, we learned that Apple does not support the standardized web-based push mechanisms, and there is no sign of a possible timeline for implementation. Therefore we have to look at additional ways to bring the users' attention back to our…
Christian Liebel
pwa
HTTP Web Push: Advanced Progressive Web Apps - Push Notifications under Control - Part 3
The third part of the PWA push notification series will take a closer look at the HTTP Web Push protocol. If you want to learn more about the Notifications API or the Push API, check out the first two parts. Article Series Notifications API Push API HTTP Web Push ⬅ Additional…
Christian Liebel
pwa
Push API: Advanced Progressive Web Apps - Push Notifications Under Control - Part 2
This part of our article series on PWA push notifications focuses on the Push API that deals with creating push subscriptions and receiving push messages. If you want to learn more about web-based notifications in general, check out the first part of our series on the…
Christian Liebel