Videokonferenzen in Webanwendungen entwickeln mit Blazor .NET, C# und WebRTC

Seit dem Beginn der Corona-Pandemie sind Videokonferenzen in sämtlichen Bereichen zum festen Bestandteil geworden. In diesem Zusammenhang wandte sich ein Kunde an uns, der im Rahmen einer Software-Modernisierung nicht nur eine Webanwendung entwickeln, sondern auch eine Videokonferenzmöglichkeit für Kundenrückfragen in seine Webapp integrieren wollte. In diesem Artikel möchten wir eine grundlegende Einführung in die Echtzeitkommunikation mit WebRTC geben. Dabei wird erklärt, was WebRTC ist und wie man eine Peer-to-Peer-Verbindung zwischen zwei Clients mithilfe eines dritten Rechners aufbauen kann. Zusätzlich wird eine Beispielimplementierung mit .NET Blazor, C# und JavaScript vorgestellt. Zum Schluss werfen wir einen Blick auf die Einschränkungen von WebRTC.

Das erwartet Sie im folgenden Blog-Beitrag:

Überblick

Aktuell gibt es zahlreiche professionelle, umfangreiche und gute Systeme, die sich auf diese Form der Kommunikation spezialisiert haben: Zoom und Microsoft Teams sind nur zwei davon. Für uns als Entwickler ist es nicht nur spannend zu erfahren, wie diese genutzt werden, sondern auch wie eine Webanwendung um Funktionen zu Audio- und Videokonferenz ergänzt werden kann. Im Rahmen dieses Artikels betrachte ich, wie eine solche Funktionalität (unter Nutzung von C#, Blazor und JavaScript) in eigenen Webanwendungen entwickelt werden kann. An dieser Stelle soll das Rad nicht neu erfunden, sondern der WebRTC Standard genutzt werden, um eine Videokonferenz zu erstellen.

WebRTC ist bereits seit einiger Zeit auf dem Markt, was den großen Vorteil bietet, dass es inzwischen von allen großen Browserherstellern unterstützt wird. Zunächst soll beschrieben werden, um was es sich bei WebRTC handelt, welche Vorteile es bietet und wie es technisch funktioniert.

Aufbauend darauf soll zuerst ein kurzer Blick auf mögliche Bibliotheken geworfen werden. Die Beispiel-Implementierung selbst soll ganz bewusst mit .NET-Bordmitteln erfolgen – unter Mithilfe von JavaScript. Das Ziel ist es, einen Prototypen für eine Videokonferenz mit .NET C# und Blazor zu entwickeln. Auch wenn eine Gruppe von Teilnehmern möglich ist, soll das Beispiel zur Vereinfachung auf zwei Teilnehmer begrenzt sein.

Was ist WebRTC?

Bei WebRTC (Abkürzung für Web Real-Time Communication) handelt es sich um einen offenen Standard für Echtzeitkommunikation, der ursprünglich unter der Federführung von Google entwickelt wurde. Anschließend wurde dieser von einer unternehmensübergreifenden Arbeitsgruppe des World Wide Web Consortiums (W3C) weiterentwickelt – mit Unterstützung der Internet Engineering Task Force (IETF).

WebRTC unterstützt dabei Video-, Sprach- und Datenübertragung zwischen den Clients untereinander, ohne dass bei der Datenübertragung zwingend ein Server beteiligt sein muss. Dadurch, dass eine direkte Verbindung zwischen den Clients angestrebt wird, wird ein etwaiger Server entlastet und die Übertragungsgeschwindigkeit erhöht.

Für Entwickler definiert WebRTC nicht nur eine Sammlung von Protokollen, sondern auch eine Programmierschnittstelle (API). Diese API unterstützt den Entwickler zum einen bei dem Zugriff auf Mikrofon, Kamera und den Bildschirm für Screensharing, als auch beim Verbindungsaufbau mit einem anderen Client. Diese API steht inzwischen in allen großen Browsern (wie Firefox, Chrome, Edge, Safari) zur Verfügung. Für viele Betriebssysteme stehen native Bibliotheken zur Verfügung, so dass WebRTC Funktionalität auch in eigenen nativen Anwendungen genutzt werden kann.

Im folgenden Abschnitt soll der Verbindungsaufbau zwischen den Clients genauer betrachtet werden.

Verbindungsaufbau im Rahmen von WebRTC

Soll eine direkte Verbindung zwischen zwei Clients, z.B. Browsern aufgebaut werden, ergeben sich zwei Herausforderungen, die bewältigt werden müssen. Erstens kennen die Clients in der Regel nicht die IP-Adresse des anderen und zweitens werden die Clients durch eine Firewall geschützt sein. Diese Firewall wird eingehenden Datenverkehr oder eingehende Verbindungsanfragen blockieren. Dass das Deaktivieren der Firewall keine Lösung ist, versteht sich von selbst.

Um das erste Problem zu lösen: für die unbekannte IP-Adresse des Partners wird ein offener dritter Rechner benötigt, den beide Clients kennen. Dieser dritte Rechner übermittelt die IP-Adressen an den jeweils anderen Rechner weiter. Das Problem der unbekannten IP-Adresse ist damit jedoch nur teilweise gelöst. Die jeweiligen Clients kennen zwar ihre eigene private IP-Adressen, jedoch nicht ihre öffentliche Adresse. Diese öffentliche IP-Adresse wird oftmals von mehreren Rechnern gleichzeitig verwendet. Befindet sich der Partner-Client im selben privaten Netzwerk, stellt das kein Problem dar. Befindet sich der Partner-Client jedoch in einem anderen Netz und hinter einem sogenannten NAT-Router, muss der Client dem Partner seine öffentliche IP-Adresse zusenden. Weitere Informationen hierzu liefern Johnston & Burnett in “WebRTC: APIs and RTCWEB Protocols of the HTML5 Real-Time Web“.

Das bedeutet, dass der Client zunächst seine öffentliche IP-Adresse ermitteln muss. Dies geschieht über einen STUN-Server (Session Traversal Utilities for NAT). Der Client sendet eine Anfrage an den STUN-Server, der ihm seine externe IP-Adresse zurückliefert. Der Client sammelt damit alle möglichen eigene IP-Adressen, sowohl externe als auch interne, und sendet diese dann über den dritten Rechner an seinen Partner. Diese IP-Adressen werden auch als ICE Kandidaten bezeichnet. Damit ist das erste Problem gelöst. Definiert ist dies im RFC zu Interactive Connectivity Establishment (ICE).

Offen ist noch die zweite Frage, wie die Firewall des Partners passiert werden kann. Voraussetzung ist, dass die jeweiligen Firewalls der Clients „Antworten“ von UDP-Datenpaketen, die versendet wurden, akzeptiert. Um diesen Zustand zu erreichen, senden beide Clients an den jeweils anderen Client ein UDP-Datenpaket. Der Inhalt des Datenpakets spielt dabei keine Rolle, da damit gerechnet wird, dass die jeweils andere Firewall dieses Datenpaket nicht durchlässt. Jedoch gehen jetzt die Firewalls beider Clients davon aus, dass eine Anfrage gesendet wurde und zukünftig „Antworten“ von dem jeweils anderen Client akzeptiert werden. Damit ist die Peer-to-Peer-Verbindung hergestellt. Diese Technik wird als „Hole Punching“ bezeichnet. Aufgrund der Art der Verbindung ergibt sich, dass ein verbindungsorientiertes Protokoll wie TCP nicht möglich ist. Ist eine zuverlässige Datenübertragung erforderlich, muss dies auf Basis des UDP-Stacks selbst erstellt werden.

Während ein verlorenes Paket bei dem Versand einer Datei durchaus problematisch ist, spielt der Verlust von einzelnen Paketen bei der Audio- und Videokommunikation eine eher untergeordnete Rolle. Einzelne Pakete werden im Video vom Benutzer oft nicht mal registriert, gehen mehrere Pakete verloren, werden diese beispielsweise als “Ruckler” im Video bemerkt, das Video selbst kann dann jedoch weiter betrachtet werden.

Diese beschriebene Technik funktioniert in den meisten Fällen. In einigen Fällen scheitert diese Peer-to-Peer-Verbindung jedoch aufgrund restriktiver NATs oder Firewalls. In solchen Fällen kann ein TURN-Server (Traversal Using Relays around NAT) unterstützen. Dieser nimmt alle Datenpakete von einem Client an und leitet diese dann an den Partner-Client weiter. In einem solchen Fall sind die Performance und die Datenübertragungsrate des TURN-Servers entscheidend.

Auch wenn es als Entwickler sinnvoll ist, gewisses Hintergrundwissen über den Verbindungsaufbau zu haben, ist diese Implementierung gekapselt und kann im Browser über eine standardisierte API verwendet werden. Die Implementierung kann dabei sowohl über eine Bibliothek erfolgen als auch direkt über die API, die über JavaScript angesprochen werden kann.

Mögliche Bibliotheken und Frameworks für Blazor

Die folgende Auflistung soll lediglich eine Übersicht der unterschiedlichen auf dem Markt verfügbaren Bibliotheken in Zusammenhang mit C# und .NET darstellen. Eine Empfehlung oder ein Erfahrungsbericht soll an dieser Stelle nicht geliefert werden.

frozenmountain liveswitchFrozenmountain ist schon lange Zeit am Markt und liefert als kommerzieller Anbieter ein Komplettpaket mit STUN und TURN-Server, sowie Bibliotheken für zahlreiche Programmiersprachen wie .NET, Android, iOS, Java, ….
SipsorceryHierbei handelt es sich um eine Open Source Bibliothek speziell für C# .NET Core.
MixedReality-WebRTCMicrosoft hatte selbst einmal eine Bibliothek für WebRTC unter .NET entwickelt, diese Entwicklung jedoch wieder eingestellt. Dies kann zwar als Basis für eine eigene Bibliothek verwendet werden, wird jedoch nicht weiterentwickelt und inzwischen als „deprecated“ eingestuft.
Übersicht der unterschiedlichen auf dem Markt verfügbaren Bibliotheken in Zusammenhang mit C# und .NET.

Sucht man in Nuget, findet man durchaus das ein oder andere Projekt oder eine Bibliothek. Im Rahmen von Blazor, .NET und C# ist es jedoch gar nicht erforderlich, eine separate Bibliothek zu verwenden, da als Webanwendung über den Browser auf die API von WebRTC zugegriffen werden kann. Details zur Browser WebRTC API lassen sich auf der Seite von MND Web Docs finden. 

Beispielimplementierung mit .NET, Blazor und SignalR

Im Folgenden soll der Aufbau eines Prototyps skizziert werden. Um das Beispiel übersichtlich zu halten, soll es bewusst einfach gehalten und nur in Auszügen dargestellt werden.

Als Technologie für den Zugriff auf Kamera und Mikrofon wird JavaScript verwendet. Über die WebRTC API des Browsers ist hier ein Zugriff möglich. Für den Austausch der initialen Daten, d.h. der IP-Adressen soll als Vermittlung SignalR verwendet werden. Hierfür ist ein SignalR Server mit einem entsprechenden SignalR Hub notwendig.

Client: Zugriff auf Kamera und Audiofunktion mit JavaScript

Der Zugriff auf Kamera und Mikrofon erfolgt mit Hilfe von JavaScript. Für das Initiieren des Zugriffs auf Kamera und den anschließenden Verbindungsaufbaus habe ich für den ersten Schritt einen Button in Blazor erstellt, der dann eine JavaScript Funktion aufruft.

				
					// Zugriff auf Video und Kamera anfordern
getUserMedia(
    {
        video: true,
        audio: true
    },
    function (stream) {
       // Initialisiere den connectionManager (siehe nächster Schritt) 
       connectionManager.initialize(...);

       // wir haben den Stream :) also speichern wir ihn
       _mediaStream = stream;

       // um den eigenen Stream anzuzeigen, stellen wir ihn dar
       var videoElement = document.querySelector('.video.mine');
       attachMediaStream(videoElement, _mediaStream);

       // Hinweis an unseren C# Teil, dass Video und Kamera bereit sind
       _viewModel.notifyMediaIsReady();
    },
    function (error) {
       // kein Zugriff auf Video und Kamera möglich :(
       _viewModel.notifyErrorOcured(WebRtcError.CouldNotGetMedia);
    }
);
				
			

Nachdem wir jetzt den Zugriff auf die Kamera haben, bereiten wir im nächsten Schritt alles vor, um die ICE-Kandidaten zu ermitteln. Ein Großteil der Arbeit für unsere Webanwendung übernimmt hier die WebRTC Schnittstelle des Browsers. Wir müssen den Verbindungsaufruf jedoch initialisieren und uns an die Events hängen und Informationen, die über den Signalisierungsserver ausgetauscht werden sollen, übermitteln. Die Kommunikation mit dem STUN-Server übernimmt in unserer Webanwendung die WebRTC Schnittstelle.

				
					WebRtcDemo.ConnectionManager = (function () {
 
    let _signaler;
    let _connections = {};
    let _connectionsStarted = {};
    let _iceServers = [{ url: 'stun: ***' }]; // hier muss die IP-Adresse des STUN Servers eingefügt werden, 
        //sonst funktioniert es nicht
 
        /* Leere callbacks */
    let _onReadyForStreamCallback;
    let _onStreamAddedCallback;
    let _onStreamRemovedCallback;
    let _onStateChanged;
      
        // Connection Manager erstellen
    let _initialize = function (signaler, onReadyForStream, onStreamAdded, onStreamRemoved, onStateChanged) {
                // Referenz auf Signalisierungsschnittstelle und Callbacks speichern
...

        },
 
        // Neue WebRTC Peer Connection erstellen
        _createConnection = function (partnerClientId) {
 
            _connectionsStarted[partnerClientId] = true;
 
            // PeerConnection erstellen
            let connection = new RTCPeerConnection({ iceServers: _iceServers });
 
            // ICE Candidate Callback
            connection.onicecandidate = function (event) {
                if (event.candidate) {
                    // Kandidaten gefunden
                    _signaler.sendSignal(JSON.stringify({ "candidate": event.candidate }), partnerClientId);
                } else {
                    // null => keine weiteren ICE Kandidaten
                }
            };
 
            // Verbindungsstatus wurde geändert => Reconnect erforderlich?
            connection.oniceconnectionstatechange  = function () {
                    // hier bei Bedarf Reconnect veranlassen
                    ...               
            };
 
            // Neue Streams müssen dem HTML hinzugefügt und ggf. auch wieder entfernt werden
            connection.onaddstream = function (event) {
                _onStreamAddedCallback(connection, event);
            };
            connection.onremovestream = function (event) {
                _onStreamRemovedCallback(connection, event.stream.id);
            };
 
            // neue Verbindung speichern           		
            _connections[partnerClientId] = connection;
 
            return connection;
        },
 
        // Wir haben neue Informationen von einem Partner erhalten, dann antworten wir darauf
        _receivedSdpSignal = function (connection, partnerClientId, sdp) {
 
            connection.setRemoteDescription(new RTCSessionDescription(sdp), function () {
                if (connection.remoteDescription.type === "offer") {
                    _onReadyForStreamCallback(connection);
 
                    connection.createAnswer(function (desc) {
                        connection.setLocalDescription(desc, function () {
                            _signaler.sendSignal(JSON.stringify({ "sdp": connection.localDescription }), partnerClientId);
                        });
                    },
                    function (error) { 
                        // irgendwas ging schief: ggf. Loggen und Fehler behandeln
                }
            });
        },
 
        // Wird aufgerufen von der Signalisierung, wenn ein neues Signal ankam.
        _newSignal = function (partnerClientId, data) {
            let signal = JSON.parse(data),
            connection = _getConnection(partnerClientId);
 
            // Signal entsprechend behandeln
            if (signal.sdp) {
                _receivedSdpSignal(connection, partnerClientId, signal.sdp);
            } else if (signal.candidate) {
                _receivedCandidateSignal(connection, partnerClientId, signal.candidate);
            }
        },
 
        // Verarbeitet ein neu empfangenes ICE-Signal
        _receivedCandidateSignal = function (connection, partnerClientId, candidate) {
            connection.addIceCandidate(new RTCIceCandidate(candidate));
        },
 
      
        // Initiiert einen Verbindungsaufbau
        _initiateOffer = function (partnerClientId, stream) {
            let connection = _connections[partnerClientId];
 
            // Connection für eine Verbindung hinzufügen
            let connection = _getConnection(partnerClientId);
 
            // Eigenen Stream hinzufügen
            connection.addStream(stream);
 
            // Offer erstellen und über die Signalisierung versenden
            connection.createOffer(function (desc) {
                connection.setLocalDescription(desc, function () {
                    _signaler.sendSignal(JSON.stringify({ "sdp": connection.localDescription }), partnerClientId);
                });
            }, function (error) {
                // Fehlerbehandlung
});
        }

...
 
    return { /** Erforderliche Funktionen nach außen geben */};
})();
				
			

Wie bereits beschrieben, müssen wir sicherstellen, dass Signalisierungsinformationen ausgetauscht werden können.

Client: Versenden der Signalisierungsinformationen an unseren Partner

Für die Verwendung von SignalR in unserer Webanwendung stehen uns in diesem Fall zwei Optionen zur Verfügung: eine JavaScript und eine .NET-Bibliothek. In diesem Fall fiel die Entscheidung auf die Nutzung der Schnittstelle mit C# mit dem Hintergedanken, dass später weitere Informationen zwischen den einzelnen Clients zuverlässig ausgetauscht werden sollen.

Server: zur Vermittlung der ICE Candidates SignalR

Im Folgenden wird hier der SignalR Hub vorgestellt. Seine Aufgabe besteht darin, dem jeweiligen anderen Client mitzuteilen, wenn der andere Partner ebenfalls bereit ist, an der Videokonferenz teilzunehmen. Weiterhin ermöglicht der SignalR Hub den Austausch von ICE-Kandidaten. Der folgende Code ist in C# dargestellt:

				
					public class WebRTCHub : Hub
    {
 
        // ein neuer Client tritt der Gruppe bei, alle vorhandenen Teilnehmer benachrichtigen
        public void Join(string username)
        {
            AddUser(username);
            SendUserListUpdate();
        }
 
        // Client verlässt die Gruppe
        public void Leave()
        {
            RemoveUser();
            SendUserListUpdate();
        }
 
        // Sendet eine Nachricht an den Partner
        public void SendSignal(string signal, string targetConnectionId)
        {
            var callingUser = Context.ConnectionId; 
            Clients.Client(targetConnectionId).SendAsync("receiveSignal", callingUser, signal);
        }
 
        private void AddUser(string username)
        {
            …
        }
 
        private void RemoveUser()
        {
            …
        }
 
        private void SendUserListUpdate()
        {
            Clients.All.SendAsync("updateUserList", Users);
        }
 
    }
				
			

Damit haben wir eine Signalisierung zwischen den Clients sichergestellt. Dieses Beispiel ist bewusst einfach gehalten. Möglichkeiten für Authentifizierungen oder unterschiedliche Chat-Räume, wie es im Endprodukt erforderlich ist, wird in dem Codebeispiel nicht berücksichtigt. Als abschließenden Schritt benötigen wir für unseren Prototyp noch einen STUN-Server, der die IP-Adresse ermitteln kann.

STUN/TURN Server: Weitere Infrastruktur

Als weitere Infrastruktur ist zumindest ein STUN-Server erforderlich, besser noch ein TURN-Server. Für die Entwicklung und das Prototypen-Testen kann auch ein freier STUN-Server von Google verwendet werden. In einer Produktivumgebung kann dieser nicht verwendet werden und es muss auf eine alternative Lösung zurückgegriffen werden.

Mit OpenRelay kann ein kostenloser TURN Server genutzt werden. Für größere Anwendungen oder wenn eine eigene Administration gewünscht ist, kann entweder auf kommerzielle Anbieter oder aber auch auf die freie OpenSource Implementierung Coturn zurückgegriffen werden.

Einschränkungen

Auf diese Weise kann eine einfache Videokonferenz erstellt werden. Im Gesamtbild ergibt sich dabei folgende Übersicht:

Verbindungsaufbau bei einer Videokonferenz
Überblick über den Verbindungsaufbau

Während diese Lösung bei einer kleineren Gruppe von Teilnehmern gut funktioniert, sind dieser Lösung Grenzen gesetzt. Diese Einschränkung besteht dabei weniger während des Verbindungsaufbaus, sondern während der aktiven Videokonferenz. Hierbei ergibt sich folgender Zustand:

Aktive Verbindungen während der Videokonferenz mit mehreren Teilnehmern
Aktive Verbindungen während der Videokonferenz mit mehreren Teilnehmern

Gut erkennbar ist hier, dass aufgrund der Peer-to-Peer-Verbindung, jeder Teilnehmer mit jedem eine Verbindung aufbaut. Das bedeutet, dass jeder Client von jedem anderen Client dessen Video und Audiodaten empfängt und auch an jeden Einzelnen sendet. Je nach Ressourcen und Bandbreite der einzelnen Endgeräte können diese hier schnell an ihre Grenzen gelangen.

Auf dieses Problem kann hier nicht im Detail eingegangen werden, es ergeben sich jedoch mehrere Ansatzpunkte. Hilfreich kann es beispielsweise im ersten Schritt sein, wenn jeder Client das eigene Videobild in der Größe reduziert und die Daten nicht mit maximaler Größe übertragen werden. Das reduziert zwar die Qualität, aber es kann davon ausgegangen werden, dass die Endgeräte auch nicht die Bildschirmgröße haben, um alle Konferenzpartner in voller Größe darzustellen. Aber auch mit diesem Ansatz hat jeder Partner zu jedem anderen Partner eine Direktverbindung.

Für eine weitere Optimierung kann auf eine serverbasierte Lösung zurückgegriffen werden. In einem solchen Lösungsansatz sind dann keine Peer-to-Peer-Verbindungen mehr erforderlich, sondern jeder Client hat Verbindung zu einem zentralen Server. Dorthin senden die Clients ihre Audio- und Videodaten. Der Server bereitet diese entsprechend auf und verteilt diese wieder an die einzelnen Endgeräte. Mit diesem Ansatz lassen sich auch Videokonferenzen mit großen Teilnehmerzahlen umsetzen. Der Vorteil des ursprünglichen WebRTC Ansatz, dass nur geringe Serverkapazitäten erforderlich sind, geht dadurch zwar verloren, jedoch werden Ressourcen und Bandbreite der Endgeräte geschont.

Fazit

Im Rahmen dieses Artikels wurde ein Überblick über die Echtzeitkommunikation mit WebRTC gegeben. Darauf aufbauend konnte ein erster einfacher Prototyp mit Hilfe von .NET, Blazor, C# und JavaScript und SignalR erstellt werden. Wie an dem Beispiel ersichtlich ist, ist die Implementierung überschaubar und tiefergehendes Wissen über Netzwerke, Video- und Audiokomprimierung sind für die ersten Schritte mit WebRTC nicht erforderlich.

Sofern diese Lösung produktiv eingesetzt wird, muss diese ergänzt werden, um geeignete Fehlerbehandlung und Konfiguration und der Möglichkeit mehrere Videokonferenzen zur selben Zeit über den SignalR-Hub zu starten.

Aber insbesondere bei einer kleinen Teilnehmeranzahl kann mit einer auf WebRTC basierenden Lösung auf leistungsstarke Server verzichtet werden, ohne Qualitätseinbußen bei der Videokonferenz hinnehmen zu müssen.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Bitte beachte unsere Nutzungsrichtlinien

Mehr zu diesem Thema

Um unsere Webseite für Sie optimal zu gestalten und fortlaufend verbessern zu können, verwenden wir Cookies. Weitere Informationen finden Sie in unserer Datenschutzerklärung.