Tutoriel pour apprendre à coder un streamer vidéo basique en Haskell et à le déployer sur Heroku

Pour réagir au contenu de ce tutoriel, un espace de dialogue vous est proposé sur le forum.

Commentez Donner une note  l'article (5)

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Haskell est un classique dans le monde de la recherche sur les langages de programmation, mais il fournit également un écosystème agréable pour développer des applications concrètes. En effet, c'est un langage très expressif, et qui dispose de nombreuses bibliothèques. De plus, il est purement fonctionnel et repose sur un système de types évolué, ce qui lui permet d'éviter de nombreuses sources de bugs.

L'objectif de ce tutoriel est d'illustrer l'utilisation de Haskell sur une petite application concrète (de l'implémentation jusqu'au déploiement), ainsi que les avantages apportés par le langage (lisibilité de code, sûreté de développement…). L'application présentée ici est un streamer vidéo basique dont le code source est disponible en ligne. Il s'agit d'une version simplifiée d'un projet réel, qui a également donné lieu à une présentation dans un meetup.

II. Présentation du projet

L'objectif du projet est de réaliser un système de streaming vidéo permettant de diffuser une région de son écran via une connexion réseau à faible débit montant.

L'architecture envisagée est composée :

  • d'un serveur web streamer-serve, qui fournit aux spectateurs une page web et le flux d'images, en HTTP ;
  • d'un programme streamer-record, qui s'exécute sur la machine du diffuseur pour prendre des captures d'écran et les envoyer au server web, via un websocket.

Image non disponible

On notera que cette application ne diffuse pas de son, ni même de véritable flux vidéo (avec compression temporelle), mais uniquement des images successives.

Image non disponible

III. Implémentation

III-1. Organisation du code

On peut organiser notre code source de différentes façons. Ici on va implémenter les deux programmes, streamer-record et streamer-serve, dans deux projets Haskell distincts, et en utilisant l'outil Stack.

 
Sélectionnez
streamer
??? streamer-record
?   ??? Main.hs
?   ??? stack.yaml
?   ??? streamer-record.cabal
??? streamer-serve
    ??? Dockerfile
    ??? Main.hs
    ??? stack.yaml
    ??? static
    ?   ??? index.html
    ?   ??? welcome.jpg
    ??? streamer-serve.cabal

Chaque projet comporte un fichier de code Main.hs (en Haskell), un fichier de configuration stack.yaml et un fichier de description de projet .cabal. Le server web comporte également quelques fichiers statiques et un Dockerfile pour le déploiement.

III-2. Serveur

Le serveur streamer-serve est le programme central de l'application. D'un côté, il doit fournir la page web et le flux d'images au client HTTP. De l'autre côté, il doit récupérer le flux d'images depuis le client Websocket. Comme expliqué précédemment, on ne gère pas un vrai flux vidéo, mais simplement une image courante. On doit donc gérer un accès concurrent à cette donnée : écriture de l'image pour la connexion Websocket, lecture de l'image pour les connexions HTTP.

Le programme serveur est implémenté dans le fichier streamer-serve/Main.hs. Comme tout programme Haskell, on commence par déclarer les modules à importer. Ici on utilise la bibliothèque scotty pour le serveur web, websockets pour les websockets et IORef pour les données à accès concurrents.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
{-# LANGUAGE OverloadedStrings #-}

import           Control.Monad (forever)
import qualified Data.ByteString.Lazy as BS
import           Data.IORef (readIORef, atomicWriteIORef, IORef, newIORef)
import           Data.Maybe (fromMaybe)
import           Network.Wai (Application)
import           Network.Wai.Handler.WebSockets (websocketsOr)
import qualified Network.WebSockets as WS
import           System.Environment (lookupEnv)
import qualified Web.Scotty as SC

Vient ensuite la fonction main, point d'entrée d'un programme Haskell. Ici on initialise l'image initiale du flux à partir d'un fichier statique. Pour gérer les accès concurrents, on stocke l'image courante dans la référence mutable imgRef. On récupère le port à utiliser pour notre serveur à partir de la variable d'environnement PORT ou de la valeur par défaut 3000. Enfin, on lance le serveur proprement dit, via la fonction scotty. Ce serveur est composé de deux applications : wsApp pour l'application Websocket, et httpApp pour l'application HTTP. Comme ces deux applications ont besoin d'accéder à l'image courante, on leur passe imgRef en paramètre.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
main :: IO ()
main = do
    img <- BS.readFile "static/welcome.jpg"
    imgRef <- newIORef img
    port <- read . fromMaybe "3000" <$> lookupEnv "PORT"
    putStrLn $ "listening port " ++ show port ++ "..."
    SC.scotty port $ do
        SC.middleware (wsApp imgRef)
        httpApp imgRef

L'application wsApp écoute les demandes de connexion en websocket et gère chaque demande via la fonction wsHandle. Cette dernière accepte la connexion et, en boucle, attend de recevoir des données (une nouvelle image courante), qu'elle écrit alors dans imgRef de façon atomique.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
wsApp :: IORef BS.ByteString -> Application -> Application
wsApp imgRef = websocketsOr WS.defaultConnectionOptions (wsHandle imgRef)

wsHandle :: IORef BS.ByteString -> WS.PendingConnection -> IO ()
wsHandle imgRef pc = do
    conn <- WS.acceptRequest pc
    forever (WS.receiveData conn >>= atomicWriteIORef imgRef)

Enfin, l'application httpApp est une application web classique qui fournit deux routes. Pour la route racine /, elle renvoie le fichier static/index.html. Pour la route /img, elle renvoie les données de l'image courante lues dans imgRef, avec un en-tête adapté.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
httpApp :: IORef BS.ByteString -> SC.ScottyM ()
httpApp imgRef = do
    SC.get "/" $ SC.file "static/index.html"
    SC.get "/img" $ do
        SC.addHeader "Content-Type" "image/jpeg"
        SC.raw =<< SC.liftAndCatchIO (readIORef imgRef)

Ceci termine le code du serveur. On notera que ce code est relativement concis, lisible et sûr. En particulier, les types de données sont connus et vérifiés par le compilateur. Par exemple, le contenu d'une image est de type ByteString, d'où la référence mutable de type IORef ByteString. De même, la fonction principale main s'exécute dans un contexte IO, ce qui signifie que cette fonction est une séquence d'actions effectuant chacune une entrée/sortie. En revanche, les fonctions pures, à l'intérieur de ces actions, sont garanties sans effet de bord. Autre exemple, la fonction httpApp s'exécute dans un contexte ScottyM, ce qui signifie qu'elle effectue uniquement des actions correspondant à ce contexte (ou des actions compatibles, comme les actions IO, après conversion explicite).

III-3. Client HTTP

Pour voir le flux en cours de diffusion, un client web se connecte au serveur sur la route racine /. Le serveur lui renvoie alors la page static/index.html suivante. Il s'agit d'une page très simple, contenant une image my_img et une fonction de mise à jour updateImg appelée toutes les 500 ms. Cette fonction de mise à jour envoie une requête au serveur sur la route /img et récupère les données de l'image diffusée, qu'elle affiche dans la page via my_img.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
<!DOCTYPE html>
<html>
    <body>
        <img id="my_img"> </img>
        <script>
            function updateImg() {
                fetch("img")
                    .then(response => response.blob())
                    .then(function(myBlob){
                        URL.revokeObjectURL(my_img.src);
                        my_img.src = URL.createObjectURL(myBlob);
                    });
            }
            const my_interval = setInterval(updateImg, 500);
        </script>
    </body>
</html>

III-4. Client Websocket

Enfin le client websocket est implémenté dans le fichier streamer-record/Main.hs. Il s'agit d'un programme Haskell qui prend une capture d'écran et l'envoie au serveur. Pour cela, on utilise la bibliothèque gi-gtk (un binding Haskell de GTK) et la bibliothèque websockets.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
{-# LANGUAGE OverloadedStrings #-}

import           Control.Concurrent (threadDelay)
import           Control.Monad (forever)
import qualified GI.Gdk as Gdk
import qualified GI.Gtk as Gtk
import           GI.GdkPixbuf.Objects.Pixbuf
import           GI.GObject.Objects.Object
import qualified Network.WebSockets as WS
import           System.Environment (getArgs)

La fonction principale main initialise la fenêtre à capturer (fonction initGtk), récupère les arguments de la ligne de commande (IP et port du serveur), puis lance un client websocket (via l'application clientApp).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
main :: IO ()
main = do
    window <- initGtk
    args <- getArgs
    case args of
        [ip, portStr] -> WS.runClient ip (read portStr) "" (clientApp window)
        _ -> putStrLn "usage: <ip> <port>"

La fonction initGtk se résume à initialiser GTK et à retourner l'objet permettant de faire les captures d'écran.

 
Sélectionnez
1.
2.
3.
4.
5.
initGtk :: IO Gdk.Window
initGtk = do
    _ <- Gtk.init Nothing
    Just screen <- Gdk.screenGetDefault
    Gdk.screenGetRootWindow screen

Enfin, la fonction clientApp fait des captures en boucle. Pour cela, elle capture une région de l'écran dans un pixel buffer, convertit celui-ci en image JPEG, envoie les données au serveur via le websocket, libère le pixel buffer puis attend 500 ms avant de recommencer. On notera que la capture du pixel buffer peut échouer, c'est pourquoi on récupère la valeur optionnelle mPixBuf (de type Maybe Pixbuf), que l'on traite ensuite dans un case ... of.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
clientApp :: Gdk.Window -> WS.ClientApp ()
clientApp window conn = forever $ do
    mPixBuf <- Gdk.pixbufGetFromWindow window 0 0 640 480
    case mPixBuf of
        Nothing -> putStr "warning: pixbufGetFromWindow failed"
        Just pixBuf -> do
            img <- pixbufSaveToBufferv pixBuf "jpeg" ["quality"] ["50"]
            WS.sendBinaryData conn img
            objectUnref pixBuf
    threadDelay 500000

III-5. Tester en local

Pour tester notre application localement, on commence par lancer le serveur. Pour cela, on exécute, dans le dossier streamer-serve, la commande :

 
Sélectionnez
stack run

Le serveur fournit alors l'application web ainsi que le flux (pour l'instant, l'image d'accueil). Pour y accéder, il suffit d'aller à l'URL localhost:3000 depuis un navigateur web.

Image non disponible

Pour capturer et diffuser la zone d'écran prévue, on lance le client websocket. Pour cela, on exécute, dans le dossier streamer-record, la commande :

 
Sélectionnez
stack run 127.0.0.1 3000

Le flux d'images est alors envoyé au serveur, et diffusé automatiquement au client web.

Image non disponible

IV. Déploiement

Pour que notre application soit utilisable, il faut que le serveur soit accessible (en HTTP pour les spectateurs et en Websocket pour le diffuseur). Il existe de nombreuses façons de mettre en place ce genre de serveur : configurer sa propre machine, utiliser des services de cloud… Ici on va utiliser le service Heroku, qui propose une offre gratuite permettant de déployer facilement des conteneurs.

IV-1. Construire une image Docker

Pour déployer un conteneur Docker sur Heroku, on crée tout d'abord une image Docker de notre application serveur. Pour cela, on écrit le Dockerfile suivant, qui construit en fait deux images. La première image contient l'environnement de développement (Haskell + Stack) et compile notre programme serveur. La seconde image est celle qui sera déployée et ne contient que le nécessaire, à savoir le programme serveur compilé précédemment et les fichiers statiques qu'il utilise.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
FROM fpco/stack-build:lts-14.27 as builder
WORKDIR /root
ADD Main.hs /root
ADD stack.yaml /root
ADD streamer-serve.cabal /root
RUN stack build
RUN cp $(stack path --local-install-root)/bin/streamer-serve .

FROM ubuntu:18.04
RUN useradd -m streamer
WORKDIR /home/streamer
USER streamer
COPY --from=builder /root/streamer-serve /home/streamer/streamer-serve
ADD static /home/streamer/static
CMD /home/streamer/streamer-serve

Pour construire l'image sous le nom streamer:latest, on lance la commande suivante :

 
Sélectionnez
docker build -t streamer:latest

On obtient une image Docker de 79,5 Mo, ce qui n'est pas particulièrement excessif (le programme streamer-serve seul fait environ 14 Mo). Éventuellement, on peut tester cette image localement avec la commande :

 
Sélectionnez
docker run --rm -it -p 3000:3000 streamer:latest

IV-2. Déployer une application Heroku

Déployer une image Docker sur Heroku est très simple. Il faut d'abord se créer un compte sur Heroku puis installer le client heroku. Ensuite, on se connecte au système de conteneur et on crée une nouvelle application Heroku, ici mystreamer-serve :

 
Sélectionnez
1.
2.
3.
heroku login
heroku container:login
heroku create mystreamer-serve

Pour déployer notre image docker, on la renomme selon le nom correspondant à l'image de l'application Heroku, puis on envoie les données de l'image et on demande à Heroku de rendre l'application disponible :

 
Sélectionnez
1.
2.
3.
docker tag streamer:latest registry.heroku.com/mystreamer-serve/web
docker push registry.heroku.com/mystreamer-serve/web
heroku container:release web --app mystreamer-serve

IV-3. Utilisation

Finalement, l'application est disponible à l'URL https://mystreamer-serve.herokuapp.com. Pour réaliser une diffusion, il suffit que les spectateurs ouvrent cette URL dans leur navigateur et que le diffuseur compile et lance le programme streamer-record, par exemple avec la commande :

 
Sélectionnez
stack run mystreamer-serve.herokuapp.com 80

V. Conclusion

Dans ce tutoriel, nous avons vu comment implémenter un système de streaming vidéo basique en Haskell et comment le déployer sur Heroku via une image Docker. Pour cela, nous avons utilisé quelques bibliothèques Haskell bien pratiques et obtenu un code plutôt lisible et concis (le code Haskell complet fait 60 lignes).

Mais l'avantage le plus appréciable de Haskell est sans doute la sûreté qu'il apporte lors du développement. En effet, son côté fonctionnel pur et son système de types permettent de distinguer facilement les fonctions pures des fonctions à effets de bord. Les fonctions pures sont très pratiques pour implémenter et tester la logique métier d'une application. Ici, l'application est très simple et utilise principalement des fonctions à effets de bord, mais même dans ce cas, l'utilisation de Haskell est avantageuse, car elle permet de définir explicitement et de vérifier automatiquement le type des effets de bord réalisés. C'est notamment pour cela que Simon PEYTON JONES, un des principaux concepteurs de Haskell, a prétendu, de façon semi-humoristique : "Haskell is the world’s finest imperative programming language".

VI. Note de la rédaction de Developpez.com

Nous tenons à remercier Claude Leloup pour la relecture orthographique de ce tutoriel.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2020 Julien Dehos. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.