ASP.NET Core SignalR: Echtzeitkommunikation in Action – Teil 1

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.

In diesem Artikel:

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

  1. Echtzeitkommunikation in Action ⬅
  2. Absicherung der Echtzeitkommunikation
  3. 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<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.

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

Mehr Artikel zu SignalR, Angular, ASP.NET Core, SPA
Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Angular
SL-rund

View Transition API Integration in Angular—a brave new world (Part 1)

If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
Low-angle photography of metal structure
AI
cl-neu

AI-Funktionen zu Angular-Apps hinzufügen: lokal und offlinefähig

Künstliche Intelligenz (KI) ist spätestens seit der Veröffentlichung von ChatGPT in aller Munde. Wit WebLLM können Sie einen KI-Chatbot in Ihre eigenen Angular-Anwendungen integrieren. Wie das funktioniert und welche Vor- und Nachteile WebLLM hat, lesen Sie hier.
26.02.2024
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
Angular
SL-rund

Konfiguration von Lazy Loaded Angular Modulen

Die Konfigurierbarkeit unserer Angular-Module ist für den Aufbau einer wiederverwendbaren Architektur unerlässlich. Aber in der jüngsten Vergangenheit hat uns Angular seine neue modullose Zukunft präsentiert. Wie sieht das Ganze jetzt aus? Wie konfigurieren wir jetzt unsere Lazy-Komponenten? Lasst uns gemeinsam einen Blick darauf werfen.
03.08.2023
ASP.NET Core
favicon

Architektur-Modernisierung: Migration von WCF zu gRPC mit ASP.NET Core – ein pragmatischer Ansatz

Viele Projekte mit verteilten Anwendungen in der .NET-Welt basieren noch auf der Windows Communication Foundation (WCF). Doch wie kommt man weg von der "Altlast" und wie stellt man seinen Code auf sowohl moderne als auch zukunftssichere Beine? Eine mögliche Lösung ist gRPC.

13.04.2023