Mein erster eigener Streaming-Server in F# – Teil 2

Willkommen zurück zu meiner kleinen Serie, in der wir einen Streaming-Server in F# implementieren werden. Zur Erinnerung, im ersten Teil haben wir uns ausschließlich darum gekümmert, unsere Entwicklungsumgebung vorzubereiten. Am Ende waren wir dann soweit, dass wir relativ problemlos und komfortabel in F# für .NET Core entwickeln konnten. Heute werden den ersten wichtigen Schritt tun und eine lokale Video-Datei über HTTP streamen. Packen wir’s an.

Notwendige Abhängigkeiten einrichten

Beim letzten Mal haben wir zum Ende hin noch den Paketmanager Paket integriert. Wir beginnen heute auch da, wo wir letztes Mal aufgehört haben. Starten wir also den Code und öffnen zuerst unsere paket.dependencies. Bisher ist diese noch unberührt und so gut wie leer.

Zuerst ergänzen wir als erste Zeile die storage-Direktive, mit storage: none. Damit geben wir dem Paket zu verstehen, dass es die runtergeladenen Pakete zentral ablegt und nicht in einem lokalen Verzeichnis. Mit diesem Verhalten sind wir näher am typischen Verhalten, das auch der nuget-Manager für .NET Core Projekte hat. Okay, als nächstes ergänzen wir zwei Abhängigkeiten. Zum einen Suave. Suave ist ein leichtgewichtiger Web-Server, der es erlaubt sein Verhalten und seine Routen über die einfache Kombination von mitgelieferten Operatoren und Funktionen zu definieren. Und die zweite Abhängigkeit ist FParsec. FParsec ist eine Parser-Kombinator-Bibliothek, die es problemlos erlaubt aus kleineren Parsern, Parser für größere Strukturen zusammen zu stecken. FParsec bringt dazu einen Satz an primitiven Parsern und Kombinatoren mit, die wir im Folgenden im Einsatz sehen werden.

Streaming-Server - paket.dependencies, generic.de AG

Abbildung 1: paket.dependencies

Als nächstes tragen wir beides auch in unsere paket.references ein, da wir im Folgenden beides brauchen werden.

Streaming-Server - paket.references, generic.de AG

Abbildung 2: paket.references

Dann installieren wir die Pakete und aktualisieren danach die project.assets.json.

Falls die Autovervollständigung die neuen Module, Namespaces, Typen, etc. nicht vorschlägt, müsst ihr wie am Ende des letzten Teils erwähnt, nun leider Code einmal neustarten, damit die Autovervollständigung, die neuen Referenzen erkennt.

Hello World aus dem Web

Im nächsten Schritt werden wir unser vorhandenes Hello World umbauen, so dass es statt auf der Konsole im Browser ausgegeben wird. Öffnen wir dazu erst mal ein paar Namespaces, damit wir nicht alles vollständig qualifizieren müssen.

Als nächstes definieren wir unsere Routen, bzw. momentan ist es nur eine. Das sieht dann wie folgt aus:

Zuletzt starten wir nun den Server mit unserer Route.

Wenn wir das Programm nun starten, können wir die Früchte unserer harten Arbeit ernten. Dazu müssen wir einen Browser öffnen und http://localhost:8080 aufrufen. Die Adresse und der Port kommen aus der verwendeten defaultConfig und können bei Bedarf natürlich angepasst werden. Aber für unsere Zwecke sind diese Standards vollkommen ausreichend.

Streaming-Server - Hello World im Browser, generic.de AG

Abbildung 3: Hello World im Browser

Jetzt wo wir im Web erreichbar sind, können wir uns an die eigentliche Aufgabe wagen und eine Datei über HTTP streamen.

HTTP: Range Header und Partial Content

Unterstützung für Streaming über das HTTP Protokoll setzt im Grunde zwei Dinge voraus. Eine Streaming-Anfrage bearbeiten zu können und eine passende Antwort erzeugen zu können. Der wichtigste Teil der Anfrage ist der Range Header. Wenn ein Client eine Anfrage mit diesem Header schickt, möchte er nur einen oder mehrere Ausschnitte aus der zugehörigen Ressource abfragen. Damit ist der Client in der Lage beliebig vorwärts und rückwärts im Ressourceninhalt zu springen. Wenn die Ressource eine Video-Datei im passenden Format ist, ist das alles was notwendig ist. Im Grunde will der Client einen Netzwerkstream haben mit bekannter Länge und der Möglichkeit einen Cursor an beliebiger Position zu setzen.

Die Antwort auf eine solche Anfrage benutzt den Statuscode 206 Partial Content. Im Header der Antwort müssen wir angeben welche Ausschnitte wir nun zurückgeben. Und anschließend müssen wir die Inhalte liefern. Da Video-Dateien recht groß sein können, sollten wir die Datei nicht komplett in Speicher lesen, sondern in Stückchen laden und schreiben, streamen eben.

Suave bringt leider weder für Anfragen mit Range Header noch für Antworten vom Typ Partial Content Unterstützung mit. Beides werden wir im Folgenden daher selber bauen.

FParsec und der Range Header

Der Range Header ist im RFC 7233 definiert und wird auch auf den Developer Seiten von Mozilla erklärt. Zum Streaming wäre nur ein rudimentärer Support des Headers notwendig, aber damit ich mich mal wieder mit FParsec beschäftigen konnte, habe ich alle unterstützten Formen implementiert.

Beispiel für einen Range Header
Range: bytes=200-1000, 2000-6576, 19000-

Die Syntax erlaubt die Angabe einer Einheit gefolgt von einer Liste mit mindestens einem Element. Jedes Element beschreibt ein offenes Intervall oder geschlossenes Intervall.

Diese Struktur wollen wir nun mit FParsec zerlegen. Schauen wir uns nun den Code an für die Parser.

Mit dem ersten Parser können wir ein einzelnes Intervall parsen, offen oder geschlossen. Das erste pint64 liest den Start ein. Danach kommt ein Parser der einen Bindestrich erwartet, ihn aber nur überspringt und danach einen weiteren int64 erwartet, aber diesmal optional. Mit pipe2 werden beide Teilergebnisse zusammengeführt. In obigen Beispiel einfach nur in ein Tupel.

Der zweite Parser benutzt den ersten, um eine Liste von Intervallen zu parsen. Zusätzlich erwarten wir als Einheit bytes. Grundsätzlich wären hier auch andere Einheiten möglich. Für den Zweck eines einfachen Streaming-Servers ist es aber ausreichend. Daher können wir den geparsten string verwerfen. Mit sepBy1 können wir einen Parser wiederholt anwenden, aber erwarten zwischen den geparsten Elementen einen Trenner. In unserem ein einfaches Komma.

Die letzten beiden Funktionen entpacken das Ergebnis des Parsers in eine gemeinsame Datenstruktur, in diesem Fall in eine Choice.

Im nächsten Schritt legen wir einen neuen WebPart an mit dem wir eine lokale Datei streamen können.

Suave: Unser erster Streaming WebPart

Für die Integration unseres Parsers in Suave werde ich nur das notwendige Minimum implementieren. Der Artikel ist bereits länger als gedacht. Also ran an die Arbeit.

Suave arbeitet mit einem Konzept namens WebPart. Ein WebPart ist eine Funktion, die einen HttpContext konsumiert und eine optionale Antwort asynchron generiert. Wer davor schon mal mit nodejs und expressjs gearbeitet hat, dem kommt das vielleicht bekannt vor.

Eine solche Funktion – einen WebPart – werden wir nun implementieren, mit dem Ziel einen Range Header falls vorhanden zu parsen und ggf. eine passende Antwort zu streamen.

Abhängig von eurem Testclient braucht ihr ein Video im passenden Format. Falls ihr einfach den Browser nehmen möchtet mittels HTML5-Video-Element, sollte es ein MP4 Video sein. Falls kein passendes zur Hand ist, gibt es unter http://www.sample-videos.com eine kleine Auswahl.

Genug der Vorrede.

Gehen wir den Code durch. Beginnen wir bei dem Integrationspart ganz am Ende. Dort wird der Ablauf zusammengesteckt. Damit der Code etwas leichter lesbarer wird, habe ich ein paar Erweiterungen für das Choice-Modul gemacht. Die Erweiterungen sind im Quellcode enthalten, aber hier werde ich sie nicht zeigen. Zuerst wird der Range-Header rausgesucht, falls nicht vorhanden, ersetze ich die Fehlermeldung mit einem Statuscode 400. Falls er vorhanden ist, benutze ich die vorher implementierten Parser, um die Intervalle zu bekommen. Falls das Parsen fehlschlägt, wird stattdessen ein Statuscode 400 zurückgegeben. Zuletzt wird streamFileImpl benutzt, um den Inhalt aus dem übergebenen Dateipfad zu streamen.

Schauen wir streamFileImpl noch etwas genauer an. Also zuerst kommt eine Vereinfachung. Ich behandle nur Anfragen mit einem Intervall. Alles andere führt zu einem Statuscode 500. Anschließend öffne ich die Datei, ermittle Start, Ende und Länge und schreibe die notwendigen Header in den Stream. Für diese Art von Lowlevel-Operationen bietet Suave die Möglichkeit mit einer Socket-Expression zu arbeiten. In dem Fall, weil wir eben ganz unten auf Socketebene sind, muss ich selber anschließend noch eine Leerzeile schreiben, um den Header zu beenden. Danach springe ich an den gewünschten Start in der Datei und benutze Suaves transferStream Funktion, um den Rest zu übertragen. Auch das ist wieder eine Vereinfachung, da ich damit potentiell mehr übertrage als angefragt.

Testen mit HTML5-Video und VLC

Erweitern wir unsere Routen nun, um unseren neuen WebPart und testen das Ergebnis. Einmal im Browser über das HTML5-Video-Element und einmal noch in VLC.

Starten wir unser Programm und probieren es aus.

Streaming-Server -Browser mit Video-Element und unserem Stream, generic.de AG

Abbildung 4: Browser mit Video-Element und unserem Stream

Der Stream in VLC geöffnet funktioniert auch:

Streaming-Server - VLC mit dem lokalen Stream, generic.de AG

Abbildung 5: VLC mit dem lokalen Stream

Sehr schön. Da die Wiedergabe von lokalen Dateien funktioniert fehlt nur noch der letzte Schritt für unseren ersten kleinen Streaming-Server: die Anbindung ans Azure Backend.

Aber das machen wir dann im dritten und letzten Teil dieser Serie. Den Quellcode zur Serie gibt es hier.

Danke fürs Lesen.