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.
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.
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.
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.
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.
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.
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é.
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.
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.
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).
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.
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
.
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 :
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.
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 :
stack run 127
.0
.0
.1
3000
Le flux d'images est alors envoyé au serveur, et diffusé automatiquement au client web.
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.
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 :
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 :
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 :
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 :
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 :
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.