Echtzeit-Anwendungen mit SignalR in .NET
# Echtzeit-Anwendungen mit SignalR in .NET
Live-Dashboards, Chat-Systeme, kollaborative Editoren, Benachrichtigungs-Feeds -- all das erfordert ein grundlegend anderes Kommunikationsmodell als klassische Request-Response-Architekturen. SignalR abstrahiert die Komplexitaet persistenter Verbindungen, Transport-Verhandlung und Wiederverbindungslogik in ein sauberes Programmiermodell. Bei Echtzeit-Dashboards, die ich fuer Monitoring-Plattformen entwickelt habe, war SignalR durchgehend der schnellste Weg von der Idee zur produktionsreifen bidirektionalen Kommunikation.
Warum SignalR statt roher WebSockets
SignalR ist mehr als ein WebSocket-Wrapper. Es uebernimmt Transport-Fallback (WebSockets, Server-Sent Events, Long Polling), automatische Wiederverbindung, Verbindungs-Lifecycle-Management und Nachrichten-Serialisierung. All das von Grund auf mit rohen WebSockets zu implementieren ist moeglich, aber fuer Geschaeftsanwendungen selten lohnend.
Die wichtigsten Vorteile:
SignalR Hub einrichten
Der Hub ist der serverseitige Einstiegspunkt fuer jede Client-Kommunikation. Jede oeffentliche Methode auf dem Hub kann von verbundenen Clients aufgerufen werden.
Einfacher Hub
public class BenachrichtigungHub : Hub
{
private readonly ILogger<BenachrichtigungHub> _logger;
public BenachrichtigungHub(ILogger<BenachrichtigungHub> logger)
{
_logger = logger;
}
public async Task BenachrichtigungSenden(string benutzer, string nachricht)
{
_logger.LogInformation(
class="code-string">"Benachrichtigung von {ConnectionId} an {Benutzer}",
Context.ConnectionId, benutzer);
await Clients.User(benutzer).SendAsync(class="code-string">"BenachrichtigungEmpfangen", nachricht);
}
public async Task NachrichtAnAlle(string nachricht)
{
await Clients.All.SendAsync(class="code-string">"NachrichtEmpfangen", Context.User?.Identity?.Name, nachricht);
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation(class="code-string">"Client verbunden: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation(
class="code-string">"Client getrennt: {ConnectionId}, Grund: {Grund}",
Context.ConnectionId, exception?.Message ?? class="code-string">"saubere Trennung");
await base.OnDisconnectedAsync(exception);
}
}Registrierung in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.KeepAliveInterval = TimeSpan.FromSeconds(class="code-number">15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(class="code-number">30);
options.MaximumReceiveMessageSize = class="code-number">64 * class="code-number">1024; class=class="code-string">"code-comment">// class="code-number">64 KB
});
var app = builder.Build();
app.MapHub<BenachrichtigungHub>(class="code-string">"/hubs/benachrichtigungen");
app.Run();Strongly-Typed Hubs
String-basierte Methodennamen wie `SendAsync("NachrichtEmpfangen", ...)` sind fragil. Ein Tippfehler kompiliert problemlos, schlaegt aber zur Laufzeit stillschweigend fehl. Strongly-typed Hubs beseitigen dieses Problem vollstaendig.
Client-Interface definieren
public interface IBenachrichtigungClient
{
Task BenachrichtigungEmpfangen(string nachricht);
Task NachrichtEmpfangen(string benutzer, string nachricht);
Task BenutzerGruppeBeigetreten(string benutzer, string gruppe);
Task BenutzerGruppeVerlassen(string benutzer, string gruppe);
Task DashboardUpdateEmpfangen(DashboardDaten daten);
}Strongly-Typed Hub implementieren
public class BenachrichtigungHub : Hub<IBenachrichtigungClient>
{
public async Task BenachrichtigungSenden(string benutzer, string nachricht)
{
class=class="code-string">"code-comment">// Zur Compile-Zeit geprueft -- keine Magic Strings mehr
await Clients.User(benutzer).BenachrichtigungEmpfangen(nachricht);
}
public async Task NachrichtAnAlle(string nachricht)
{
var absender = Context.User?.Identity?.Name ?? class="code-string">"Anonym";
await Clients.All.NachrichtEmpfangen(absender, nachricht);
}
public async Task DashboardUpdateSenden(DashboardDaten daten)
{
await Clients.Group(class="code-string">"dashboard-betrachter").DashboardUpdateEmpfangen(daten);
}
}Bei Echtzeit-Dashboards, die ich entwickelt habe, haben Strongly-typed Hubs mehrere Fehler beim Kompilieren abgefangen, die sonst stille Laufzeitfehler gewesen waeren. Der geringe Anfangsaufwand fuer die Interface-Definition zahlt sich sofort aus.
Client-Server-Kommunikationsmuster
SignalR unterstuetzt verschiedene Kommunikationsmuster, jeweils fuer unterschiedliche Szenarien geeignet.
Server an Client
Das haeufigste Muster. Der Server pusht Daten an verbundene Clients, ohne dass diese sie anfordern.
class=class="code-string">"code-comment">// Innerhalb einer Hub-Methode
await Clients.All.NachrichtEmpfangen(benutzer, nachricht); class=class="code-string">"code-comment">// Alle Clients
await Clients.Caller.BenachrichtigungEmpfangen(class="code-string">"Bestaetigt"); class=class="code-string">"code-comment">// Nur der Aufrufer
await Clients.Others.NachrichtEmpfangen(benutzer, nachricht); class=class="code-string">"code-comment">// Alle ausser Aufrufer
await Clients.User(userId).BenachrichtigungEmpfangen(msg); class=class="code-string">"code-comment">// Bestimmter Benutzer
await Clients.Group(class="code-string">"admins").BenachrichtigungEmpfangen(msg); class=class="code-string">"code-comment">// GruppenmitgliederClient an Server
Clients rufen Hub-Methoden direkt auf. Hier ein JavaScript-Client-Beispiel:
const connection = new signalR.HubConnectionBuilder()
.withUrl(class="code-string">"/hubs/benachrichtigungen", {
accessTokenFactory: () => getAccessToken()
})
.withAutomaticReconnect([class="code-number">0, class="code-number">2000, class="code-number">5000, class="code-number">10000, class="code-number">30000])
.configureLogging(signalR.LogLevel.Information)
.build();
connection.on(class="code-string">"NachrichtEmpfangen", (benutzer, nachricht) => {
console.log(`${benutzer}: ${nachricht}`);
nachrichtAnUIAnhaengen(benutzer, nachricht);
});
connection.on(class="code-string">"DashboardUpdateEmpfangen", (daten) => {
dashboardAktualisieren(daten);
});
async function nachrichtSenden(nachricht) {
await connection.invoke(class="code-string">"NachrichtAnAlle", nachricht);
}
await connection.start();Hub-Methoden von ausserhalb des Hubs aufrufen
In Produktionssystemen muessen Nachrichten oft aus Hintergrunddiensten, API-Controllern oder Event-Handlern gesendet werden -- nicht nur aus Hub-Methoden heraus.
public class BestellverarbeitungService : BackgroundService
{
private readonly IHubContext<BenachrichtigungHub, IBenachrichtigungClient> _hubContext;
public BestellverarbeitungService(
IHubContext<BenachrichtigungHub, IBenachrichtigungClient> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var updates = await GetAusstehendeBenachrichtigungen();
foreach (var update in updates)
{
await _hubContext.Clients.User(update.BenutzerId)
.BenachrichtigungEmpfangen(
$class="code-string">"Bestellung {update.BestellungId} Status: {update.Status}");
}
await Task.Delay(TimeSpan.FromSeconds(class="code-number">5), stoppingToken);
}
}
}Gruppen- und Benutzerverwaltung
Gruppen sind der primaere Mechanismus, um festzulegen, welche Clients welche Nachrichten erhalten. Sie sind leichtgewichtig, dynamisch und erfordern keine Vorregistrierung.
Gruppenoperationen
public class ZusammenarbeitHub : Hub<IZusammenarbeitClient>
{
private readonly IGruppenTracker _gruppenTracker;
public ZusammenarbeitHub(IGruppenTracker gruppenTracker)
{
_gruppenTracker = gruppenTracker;
}
public async Task ProjektBeitreten(string projektId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $class="code-string">"projekt-{projektId}");
var benutzername = Context.User?.Identity?.Name ?? class="code-string">"Anonym";
await _gruppenTracker.BenutzerZuGruppeHinzufuegen(
projektId, benutzername, Context.ConnectionId);
await Clients.Group($class="code-string">"projekt-{projektId}")
.BenutzerGruppeBeigetreten(benutzername, projektId);
}
public async Task ProjektVerlassen(string projektId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $class="code-string">"projekt-{projektId}");
var benutzername = Context.User?.Identity?.Name ?? class="code-string">"Anonym";
await _gruppenTracker.BenutzerAusGruppeEntfernen(projektId, Context.ConnectionId);
await Clients.Group($class="code-string">"projekt-{projektId}")
.BenutzerGruppeVerlassen(benutzername, projektId);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var gruppen = await _gruppenTracker.GruppenFuerVerbindung(Context.ConnectionId);
foreach (var gruppe in gruppen)
{
await _gruppenTracker.BenutzerAusGruppeEntfernen(gruppe, Context.ConnectionId);
await Clients.Group($class="code-string">"projekt-{gruppe}")
.BenutzerGruppeVerlassen(
Context.User?.Identity?.Name ?? class="code-string">"Anonym", gruppe);
}
await base.OnDisconnectedAsync(exception);
}
}Ein entscheidender Punkt: SignalR-Gruppen sind nicht persistent. Bei einem Serverneustart gehen alle Gruppenmitgliedschaften verloren. Wenn Ihre Anwendung dauerhafte Gruppenzugehoerigkeit benoetigt, verwalten Sie diese in Ihrem eigenen Datenspeicher und treten Sie bei Wiederverbindung erneut den Gruppen bei.
Authentifizierung mit SignalR
SignalR integriert sich direkt in die ASP.NET Core Authentifizierung. Dieselbe JWT- oder Cookie-Authentifizierung, die Ihre API-Endpunkte schuetzt, schuetzt auch Ihre Hubs.
JWT-Authentifizierung fuer SignalR
WebSocket-Verbindungen koennen nach dem initialen Handshake keine benutzerdefinierten HTTP-Header senden. SignalR loest dies, indem das Token waehrend der Verhandlungsphase als Query-String-Parameter akzeptiert wird.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration[class="code-string">"Jwt:Issuer"],
ValidAudience = builder.Configuration[class="code-string">"Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration[class="code-string">"Jwt:Key"]!))
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query[class="code-string">"access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments(class="code-string">"/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});Hub-Methoden absichern
[Authorize]
public class SichererHub : Hub<IBenachrichtigungClient>
{
[Authorize(Roles = class="code-string">"Admin")]
public async Task AdminNachricht(string nachricht)
{
await Clients.All.BenachrichtigungEmpfangen(nachricht);
}
[Authorize(Policy = class="code-string">"PremiumBenutzer")]
public async Task PremiumFunktion(string daten)
{
await Clients.Caller.BenachrichtigungEmpfangen($class="code-string">"Premium-Ergebnis: {daten}");
}
}SignalR mit Redis Backplane skalieren
Ein einzelner Server verwaltet alle seine Verbindungen im Speicher. Bei der Skalierung auf mehrere Server hinter einem Load Balancer kann ein Client auf Server A keine Nachrichten von Server B empfangen. Das Redis Backplane loest dieses Problem, indem Nachrichten ueber alle Server hinweg verbreitet werden.
Konfiguration
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString(class="code-string">"Redis")!, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal(class="code-string">"SignalR");
});Funktionsweise
Wann stattdessen Azure SignalR Service
Fuer Produktionslasten mit mehr als einigen tausend gleichzeitigen Verbindungen sollten Sie Azure SignalR Service in Betracht ziehen. Er lagert die gesamte Verbindungsverwaltung aus und eliminiert den Bedarf an Sticky Sessions, Redis-Infrastruktur und Kapazitaetsplanung fuer Verbindungszahlen.
builder.Services.AddSignalR()
.AddAzureSignalR(builder.Configuration[class="code-string">"Azure:SignalR:ConnectionString"]);Fehlerbehandlung und Wiederverbindungsstrategie
Echtzeit-Verbindungen sind von Natur aus fragil. Netzwerke fallen aus, Server starten neu, Clients gehen in den Schlafmodus. Eine robuste Wiederverbindungsstrategie ist fuer Produktionssysteme unverzichtbar.
Serverseitige Fehlerbehandlung
public class RobusterHub : Hub<IBenachrichtigungClient>
{
private readonly ILogger<RobusterHub> _logger;
public RobusterHub(ILogger<RobusterHub> logger)
{
_logger = logger;
}
public async Task DatenVerarbeiten(DatenAnfrage anfrage)
{
try
{
var ergebnis = await ValidierenUndVerarbeiten(anfrage);
await Clients.Caller.BenachrichtigungEmpfangen($class="code-string">"Erfolg: {ergebnis}");
}
catch (ValidationException ex)
{
_logger.LogWarning(ex,
class="code-string">"Validierung fehlgeschlagen fuer {ConnectionId}", Context.ConnectionId);
throw new HubException($class="code-string">"Validierungsfehler: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError(ex,
class="code-string">"Unerwarteter Fehler fuer {ConnectionId}", Context.ConnectionId);
throw new HubException(
class="code-string">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.");
}
}
}Das Werfen einer `HubException` sendet die Nachricht an den Client. Jeder andere Exception-Typ fuehrt zu einer generischen Fehlermeldung, es sei denn `EnableDetailedErrors` ist aktiviert. Aktivieren Sie detaillierte Fehler niemals in Produktion -- sie legen Stack-Traces und internen Zustand offen.
Clientseitige Wiederverbindung
const connection = new signalR.HubConnectionBuilder()
.withUrl(class="code-string">"/hubs/benachrichtigungen")
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
if (retryContext.elapsedMilliseconds < class="code-number">60000) {
return Math.random() * class="code-number">10000;
}
return null; class=class="code-string">"code-comment">// Nach class="code-number">60 Sekunden aufhoeren
}
})
.build();
connection.onreconnecting((error) => {
verbindungsstatusAnzeigen(class="code-string">"Verbindung wird wiederhergestellt...");
});
connection.onreconnected((connectionId) => {
verbindungsstatusAnzeigen(class="code-string">"Verbunden");
class=class="code-string">"code-comment">// Nach Wiederverbindung Gruppen erneut beitreten
gruppenNeuBeitreten();
});
connection.onclose((error) => {
verbindungsstatusAnzeigen(class="code-string">"Getrennt");
setTimeout(() => verbindungStarten(), class="code-number">5000);
});Das entscheidende Detail, das viele Teams uebersehen: Nach einer Wiederverbindung erhaelt der Client eine neue ConnectionId. Alle Gruppenmitgliedschaften der vorherigen Verbindung sind weg. Sie muessen Gruppen explizit erneut beitreten. Bei Echtzeit-Dashboards, die ich gebaut habe, fuehre ich clientseitig eine Liste aktiver Abonnements und spiele sie bei jeder Wiederverbindung erneut ab.
SignalR vs WebSockets vs Server-Sent Events
Jede Technologie bedient unterschiedliche Anwendungsfaelle. Die falsche Wahl fuehrt zu unnoetieger Komplexitaet oder fehlenden Faehigkeiten.
| Aspekt | SignalR | Rohe WebSockets | Server-Sent Events (SSE) |
|---|---|---|---|
| Richtung | Bidirektional | Bidirektional | Nur Server an Client |
| Transport | Automatisch | Nur WebSocket | HTTP-Streaming |
| Wiederverbindung | Eingebaut | Manuelle Implementierung | Eingebaut (EventSource) |
| Browser-Support | Universal (mit Fallback) | Moderne Browser | Moderne Browser |
| Nachrichtenformat | JSON/MessagePack | Rohe Bytes/Text | Nur Text |
| Gruppen/Benutzer | Eingebaut | Manuelle Implementierung | Manuelle Implementierung |
| Skalierung | Redis/Azure Backplane | Eigene Loesung | Eigene Loesung |
| Auth-Integration | ASP.NET Core nativ | Manuell | Standard-HTTP |
| Komplexitaet | Niedrig | Hoch | Niedrig |
| Ideal fuer | .NET Full-Stack-Apps | Eigene Protokolle, Spiele | Einfache Benachrichtigungen |
Waehlen Sie SignalR, wenn Sie im .NET-Oekosystem arbeiten und bidirektionale Kommunikation mit minimalem Boilerplate benoetigen. Waehlen Sie rohe WebSockets, wenn Sie ein eigenes Binaerprotokoll oder eine Multiplayer-Game-Engine bauen. Waehlen Sie SSE, wenn Sie nur Server-zu-Client-Streaming brauchen und die einfachste Implementierung wuenschen.
Haeufige SignalR-Fehler
1. Zustand in Hub-Instanzen speichern
Hubs sind transient. Fuer jeden Methodenaufruf wird eine neue Instanz erstellt. Jeder als Feld auf der Hub-Klasse gespeicherte Zustand geht zwischen Aufrufen verloren.
class=class="code-string">"code-comment">// FALSCH -- dieser Zaehler setzt sich bei jedem Aufruf zurueck
public class SchlechterHub : Hub
{
private int _nachrichtenZaehler = class="code-number">0;
public async Task NachrichtSenden(string msg)
{
_nachrichtenZaehler++; class=class="code-string">"code-comment">// Immer class="code-number">1
await Clients.All.SendAsync(class="code-string">"Zaehler", _nachrichtenZaehler);
}
}
class=class="code-string">"code-comment">// RICHTIG -- injizierten Singleton-Service verwenden
public class GuterHub : Hub
{
private readonly INachrichtenZaehler _zaehler;
public GuterHub(INachrichtenZaehler zaehler) => _zaehler = zaehler;
public async Task NachrichtSenden(string msg)
{
var anzahl = _zaehler.Erhoehen();
await Clients.All.SendAsync(class="code-string">"Zaehler", anzahl);
}
}2. Hub mit lang laufenden Operationen blockieren
Hub-Methoden laufen auf dem Thread der SignalR-Verbindung. Blockieren verhindert, dass der Client andere Nachrichten empfaengt.
3. Gruppen-Neubeitritt nach Wiederverbindung vergessen
Wie oben erwaehnt: Wiederverbindung erstellt eine neue Verbindung. Gruppen werden nicht automatisch wiederhergestellt. Dies ist die haeufigste Ursache fuer Fehler der Art "funktioniert in der Entwicklung, aber nicht in Produktion".
4. CORS nicht korrekt konfigurieren
SignalR nutzt sowohl HTTP (fuer die Verhandlung) als auch WebSockets. CORS muss beides erlauben.
builder.Services.AddCors(options =>
{
options.AddPolicy(class="code-string">"SignalRPolicy", policy =>
{
policy.WithOrigins(class="code-string">"https:class="code-commentclass="code-string">">//app.beispiel.de")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); class=class="code-string">"code-comment">// Erforderlich fuer SignalR
});
});5. Nachrichtengroessen-Limits ignorieren
Die standardmaessige maximale Nachrichtengroesse betraegt 32 KB. Das Senden grosser Payloads ohne Anpassung dieses Limits verursacht stille Verbindungsabbrueche.
6. MessagePack bei High-Throughput-Szenarien nicht verwenden
JSON ist das Standardprotokoll, aber fuer hochfrequente Updates reduziert die binaere MessagePack-Serialisierung die Payload-Groesse um 30-50% und verbessert die Serialisierungsgeschwindigkeit erheblich.
builder.Services.AddSignalR()
.AddMessagePackProtocol();Fazit
SignalR verwandelt Echtzeit-Kommunikation von einer systemnahen Herausforderung in ein unkompliziertes Feature auf Anwendungsebene. Strongly-typed Hub-Muster, eingebaute Gruppenverwaltung und nahtlose Authentifizierungsintegration machen es zur natuerlichen Wahl fuer .NET-Anwendungen, die Live-Updates benoetigen. Die kritischen Entscheidungen drehen sich um die Skalierungsstrategie (Redis vs Azure SignalR Service), Wiederverbindungsbehandlung und das Verstaendnis, dass Hubs transient sind. Wenn diese Punkte stimmen, verschwindet SignalR im Hintergrund -- und genau das sollte gute Infrastruktur tun.
Gerne unterstuetze ich bei der Architektur Ihrer Echtzeit-Features und der Ueberpruefung Ihrer SignalR-Skalierungsstrategie fuer die Produktion.
Verwandte Artikel
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Authentifizierung und Autorisierung in .NET: JWT und Identity
Implementieren Sie sichere Authentifizierung in .NET. JWT, Identity und OAuth2.
Caching-Strategien in .NET: In-Memory, Distributed und Redis
Implementieren Sie effektive Caching-Strategien in .NET. In-Memory, Distributed und Redis.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen