code source

Web fullstack en C++ et en Haskell, application de dessin collaboratif

Une application web peut être implémentée selon différentes architectures mais comporte généralement une partie client et une partie serveur. De nombreux langages proposent des frameworks pour implémenter la partie serveur. En revanche pour la partie client, seuls les langages HTML, CSS et JavaScript sont supportés directement par les navigateurs. Certains outils permettent cependant d’écrire le code client dans un autre langage puis de transpiler vers du code JavaScript compréhensible par un navigateur. On peut alors coder toute une application (partie client et partie serveur) avec un seul langage, grâce à ce genre de framework « fullstack ».

Dans cet article, on considère un framework C++ « basé widget » (Wt) et un framework Haskell « isomorphique » (Miso/Servant). L’application réalisée permet de faire du dessin collaboratif : chaque utilisateur peut dessiner des chemins en cliquant et en déplaçant sa souris; lorsqu’un chemin est terminé (le bouton de la souris est relaché), le chemin est envoyé au serveur qui le diffuse à tous les clients. Dans cette applications, la partie serveur gère les chemins et les connexions, et la partie client gère le dessin interactif.

En JavaScript

Avant de voir les frameworks proposés, voyons comment implémenter l’application en JavaScript, de façon simple.

Code serveur

Pour le serveur (src/app.js), on dispose d’outils très classiques : node.js, express.js, socket.io. Node.js permet d’implémenter le serveur web de base, qui contient l’ensemble des chemins dessinés. Express.js permet d’implémenter le routage d’URL, c’est-à-dire ici la route racine “/”, qui envoie au client les fichiers statiques (du dossier “static” de la machine serveur). Enfin, socket.io permet de diffuser des messages aux clients connectés :

"use strict";

const port = 3000;

const express = require("express");
const app = express();

const http = require("http").Server(app);
const io = require("socket.io")(http);

let paths = [];

// serve static files
app.use("/", express.static("./static"));

// client connection
io.on("connection", function(socket){

    // when a new connection is accepted, send all paths
    socket.emit("stoh all paths", paths);

    // when a client sends a new path
    socket.on("htos new path", function(path){
        // store the new path
        paths.push(path);
        // send the new path to all clients
        io.emit("stoh new path", path);
    });
});

http.listen(port, function () {
    console.log(`Listening on port ${port}...`);
});

Code client

Pour le client (static/index.html), on définit un canvas de dessin et quelques fonctions auxiliaires pour récupérer la position de la souris, tracer un chemin, etc.

    <canvas id="canvas_draw" width="400" height="300" 
        style="border:1px solid black"> </canvas>

    <script>
        function getXY(canvas, evt) {
            const rect = canvas.getBoundingClientRect();
            const x = evt.clientX - rect.left;
            const y = evt.clientY - rect.top;
            return {x, y};
        }

        function setDrawingStyle(ctx) {
            ctx.fillStyle = 'black';
            ctx.lineWidth = 4;
            ctx.lineCap = "round";
        }

        function drawPath(canvas, path) {
            if (path.length >= 2) {
                const ctx = canvas_draw.getContext("2d");
                setDrawingStyle(ctx);
                ctx.beginPath();
                [p0, ...ps] = path;
                ctx.moveTo(p0.x, p0.y);
                ps.map(p => ctx.lineTo(p.x, p.y));
                ctx.stroke();
            }
        }
    </script>

On utilise également socket.io pour gérer les communications avec le serveur : récupérer les chemins initiaux, récupérer les nouveaux chemins successifs. Enfin, on gère les événements utilisateurs de façon habituelle et on propage les données correspondantes au serveur : créer et mettre à jour un chemin courant lorsqu’on appuie et déplace la souris, envoyer le chemin lorsqu’on relache.

    <script src="/socket.io-2.2.0.js"></script>

    <script>
        const socket = io();

        // when the server sends all paths
        socket.on("stoh all paths", function (paths) {
            paths.forEach(path => drawPath(canvas_draw, path));
        });

        // when the server sends a new path
        socket.on("stoh new path", function (path) {
            drawPath(canvas_draw, path);
        });

        let current_path = [];

        // when the user begins to draw a path
        canvas_draw.onmousedown = function(evt0) {
            const ctx = canvas_draw.getContext("2d");
            setDrawingStyle(ctx);
            let p0 = getXY(canvas_draw, evt0);
            current_path = [p0];

            // set mousemove callback
            canvas_draw.onmousemove = function(evt1) {
                const p1 = getXY(canvas_draw, evt1);
                current_path.push(p1);
                ctx.beginPath();
                ctx.moveTo(p0.x, p0.y);
                ctx.lineTo(p1.x, p1.y);
                ctx.stroke();
                p0 = p1;
            };
        };

        // when the user finishes to draw a path
        canvas_draw.onmouseup = function(evt) {
            // unset mousemove callback
            canvas_draw.onmousemove = {};
            // send the path to the server
            socket.emit("htos new path", current_path);
        };
    </script>

Le code JavaScript complet est plutôt clair et concis; les fonctions pour dessiner dans un canvas et la bibliothèque socket.io sont particulièrement simples. On notera cependant que les données manipulées dans cette application, ainsi que les fonctionnalités implémentées, sont très limitées. Sur une application plus réaliste, on utiliserait plutôt une vraie architecture de code (par exemple MVC, Flux…) et des bibliothèques dédiées (React, Vue.js…).

En Haskell « isomorphique »

Une application web isomorphique est une application dont le code s’exécute à la fois côté client et côté serveur. Il s’agit généralement d’une application mono-page, c’est-à-dire avec un code client assez lourd, mais dont le premier rendu est réalisé par le serveur. Ceci permet de fournir une première vue à l’utilisateur avant que l’application soit complètement chargée dans le navigateur.

Le langage JavaScript est souvent utilisé pour implémenter ce genre d’application car il peut directement s’exécuter dans un navigateur. Cependant il existe également des outils permettant d’écrire le code client dans d’autres langages et de le transpiler ensuite vers JavaScript.

En Haskell, les bibliothèques Miso et Servant permettent d’implémenter des applications web isomorphiques avec une architecture de type Flux. Pour cela, on définit le modèle des données, les actions possibles et les fonctions pour calculer une vue et pour gérer les actions. Ces éléments sont ensuite utilisés automatiquement et de façon asynchrone dans l’application du client. Ils peuvent également être utilisés pour la partie serveur.

Code commun

Dans le code commun au client et au serveur (src/Common.hs), on définit le modèle (données manipulées par l’application), la fonction de rendu (qui calcule la vue d’un modèle) et les actions (que peut générer la vue).

-- model

type Path = [(Double, Double)]

data Model = Model 
    { allPaths_ :: [Path]               -- all the paths, sent by the server
    , currentPath_ :: Path              -- current path (when the user is drawing)
    , currentXy_ :: (Double, Double)    -- last position of the mouse (when drawing)
    , drawing_ :: Bool                  -- set whether the user is drawing or not
    } deriving (Eq, Show)

initialModel :: Model
initialModel = Model [] [] (0,0) False

-- view

homeView :: Model -> View Action
homeView _ = div_ 
    []
    [ p_ [] [ text "isopaint_miso" ]
    , canvas_ 
        [ id_ "canvas_draw" , width_ "400" , height_ "300"
        , style_  (singleton "border" "1px solid black")
        , onMouseDown MouseDown     -- when mouse down, generate a MouseDown action
        , onMouseUp MouseUp         -- when mouse up, generate a MouseUp action
        ]
        []
    ]

-- actions

data Action
    = NoOp
    | RedrawCanvas
    | MouseDown
    | MouseUp 
    | MouseMove (Int, Int)
    | SetXy (Double, Double)
    | SetPaths [Path]
    | SendXhr Path
    | RecvSse (Maybe Path)
    | InitAllPaths
    deriving (Eq, Show)

Code client

Dans le code spécifique au client (src/client.hs), on définit la fonction de mise-à-jour du modèle en fonction des actions demandées (requêtes AJAX au serveur, dessin interactif, redessin complet…).

updateModel :: Action -> Model -> Effect Action Model

-- nothing to do
updateModel NoOp m = noEff m

-- mouse down: begin drawing a path
updateModel MouseDown m = noEff m { currentPath_ = [], drawing_ = True }

-- mouse move: get position and ask to update the model using a SetXy action
updateModel (MouseMove (x,y)) m = m <#
    if drawing_ m 
    then do 
        left <- jsRectLeft
        top <- jsRectTop
        let x' = fromIntegral $ x - left
        let y' = fromIntegral $ y - top
        pure $ SetXy (x', y')
    else pure NoOp

-- update position and ask to redraw the canvas using a RedrawCanvas action
updateModel (SetXy xy) m =
    m { currentPath_ = xy : currentPath_ m } <# pure RedrawCanvas

-- mouse up: finish drawing the current path (send the path to the server)
updateModel MouseUp (Model a c xy _) = Model a [] xy False <# pure (SendXhr c)

-- send a path to the server
updateModel (SendXhr path) m = m <# (xhrPath path >> pure NoOp)

-- register to Server-Sent Event, for receiving new paths from other clients
updateModel (RecvSse Nothing) m = noEff m
updateModel (RecvSse (Just path)) m =
    m { allPaths_ = path : allPaths_ m } <# pure RedrawCanvas

-- clear the canvas and redraw the paths
updateModel RedrawCanvas m = m <# do
    w <- jsWidth
    h <- jsHeight
    ctx <- jsCtx
    clearRect 0 0 w h ctx
    lineCap LineCapRound ctx
    mapM_ (drawPath ctx) $ allPaths_ m
    drawPath ctx $ currentPath_ m
    pure NoOp

-- initialize paths: ask all paths to the server then update using a SetPaths action
updateModel InitAllPaths m = m <# do SetPaths <$> xhrAllPaths

-- update paths then ask to redraw the canvas
updateModel (SetPaths paths) m = m { allPaths_ = paths } <# pure RedrawCanvas

On définit également quelques fonctions auxiliaires pour implémenter les requêtes AJAX au serveur, et le dessin dans le canvas.

-- send a new path to the server ("/xhrPath" endpoint)
xhrPath :: Path -> IO ()
xhrPath path = void $ xhrByteString $ Request POST "/xhrPath" Nothing hdr False dat 
    where hdr = [("Content-type", "application/json")]
          dat = StringData $ toMisoString $ encode path

-- ask for the paths ("/xhrPath" endpoint)
xhrAllPaths :: IO [Path]
xhrAllPaths = fromMaybe [] . decodeStrict . fromJust . contents <$> 
    xhrByteString (Request GET "/api" Nothing [] False NoData)

-- handle a Server-Sent Event by generating a RecvSse action
ssePath :: SSE Path -> Action
ssePath (SSEMessage path) = RecvSse (Just path)
ssePath _ = RecvSse Nothing

drawPath :: Context -> Path -> IO ()
drawPath ctx points = 
    when (length points >= 2) $ do
        let ((x0,y0):ps) = points
        lineWidth 4 ctx
        beginPath ctx
        moveTo x0 y0 ctx
        mapM_ (\(x,y) -> lineTo x y ctx) ps
        stroke ctx

foreign import javascript unsafe "$r = canvas_draw.getContext('2d');"
    jsCtx :: IO Context

foreign import javascript unsafe "$r = canvas_draw.clientWidth;"
    jsWidth :: IO Double

foreign import javascript unsafe "$r = canvas_draw.clientHeight;"
    jsHeight :: IO Double

foreign import javascript unsafe "$r = canvas_draw.getBoundingClientRect().left;"
    jsRectLeft :: IO Int

foreign import javascript unsafe "$r = canvas_draw.getBoundingClientRect().top;"
    jsRectTop :: IO Int

Enfin, la fonction principale de l’application client regroupe ces éléments selon l’architecture demandée par Miso.

main :: IO ()
main = miso $ const App 
    { initialAction = InitAllPaths
    , model = initialModel
    , update = updateModel
    , view = homeView
    , events = defaultEvents
    , subs = [
        sseSub "/ssePath" ssePath,  -- register Server-Sent Events to the ssePath function
        mouseSub MouseMove          -- register mouseSub events to the MouseMove action
        ]
    , mountPoint = Nothing
    }

Code serveur

Côté serveur (src/server.hs), on implémente un serveur web classique. Il contient la liste des chemins dessinés par les clients et fournit une API web ainsi qu’un système de notifications des clients (Server-Sent Events), pour diffuser les nouveaux chemins dessinés.

main :: IO ()
main = do
    pathsRef <- newIORef []     -- list of drawn paths
    chan <- newChan             -- Server-Sent Event handler
    run 3000 $ logStdout (serverApp chan pathsRef)  -- run serverApp on port 3000

-- define the API type
type ServerApi
    =    "static" :> Raw    -- "/static" endpoint, for static files
    :<|> "ssePath" :> Raw   -- "/ssePath" endpoint, for registering SSE...
    :<|> "xhrPath" :> ReqBody '[JSON] Path :> Post '[JSON] NoContent
    :<|> "api" :>  Get '[JSON] [Path] 
    :<|> ToServerRoutes (View Action) HtmlPage Action    -- "/" endpoint

-- define a function for serving the API
serverApp :: Chan ServerEvent -> IORef [Path] -> Application
serverApp chan pathsRef = serve (Proxy @ServerApi)
    (    serveDirectoryFileServer "static"  -- serve the "/static" endpoint (using the "static" folder)
    :<|> Tagged (eventSourceAppChan chan)   -- serve the "/ssePath" endpoint...
    :<|> handleXhrPath chan pathsRef
    :<|> handleApi pathsRef
    :<|> handleClientRoute 
    )

-- when a path is sent to "/xhrPath", add the path in pathsRef and update clients using SSE
handleXhrPath :: Chan ServerEvent -> IORef [Path] -> Path -> Handler NoContent
handleXhrPath chan pathsRef path = do
    liftIO $ do
        modifyIORef' pathsRef (\ paths -> path:paths)
        writeChan chan (ServerEvent Nothing Nothing [lazyByteString $ encode path])
    pure NoContent

-- when a client requests "/api", send all paths
handleApi :: IORef [Path] -> Handler [Path]
handleApi pathsRef = liftIO (readIORef pathsRef)

-- when a client requests "/", render and send the home view 
handleClientRoute :: Handler (HtmlPage (View Action))
handleClientRoute = pure $ HtmlPage $ homeView initialModel

On notera qu’on réutilise ici la fonction de rendu homeView pour générer la première vue de l’application. Cette vue est intégrée dans une page complète avec la fonction toHtml suivante :

newtype HtmlPage a = HtmlPage a deriving (Show, Eq)

instance L.ToHtml a => L.ToHtml (HtmlPage a) where

    toHtmlRaw = L.toHtml

    -- main function, for rendering a view to a HTML page
    toHtml (HtmlPage x) = L.doctypehtml_ $ do
        L.head_ $ do
            L.meta_ [L.charset_ "utf-8"]
            L.with 
                (L.script_ mempty) 
                [L.src_ "static/all.js", L.async_ mempty, L.defer_ mempty] 
        L.body_ (L.toHtml x)  -- render the view and include it in the HTML page

En C++ « basé widgets »

D’un point de vue utilisateur, une application web et une application native sont assez similaires. Il s’agit essentiellement d’une interface utilisateur graphique (dans un navigateur ou dans une fenêtre) qui interagit avec un programme principal (un programme serveur ou une autre partie du même programme). Ainsi les frameworks web basés widgets, comme Wt, reprennent logiquement la même architecture que les frameworks natifs, comme Qt ou Gtk. Le développeur écrit un programme classique où l’interface graphique est définie via des widgets; le framework se charge de construire l’interface dans le navigateur client et de gérer les connexions réseaux. En pratique, cette architecture n’est pas complètement transparente et le développeur doit tout de même tenir compte de l’aspect réseau de l’application.

Application principale

Pour implémenter l’application de dessin collaboratif avec Wt, on peut définir le programme principal suivant (src/isopaint.cpp). Ici, on a choisi d’organiser le code selon une architecture de type MVC. Le contrôleur fait le lien entre le modèle (les chemins dessinés par les clients) et les vues. Les vues correspondent aux clients qui se connectent, c’est pourquoi on les construit à la demande, via la lambda fonction mkApp.

int main(int argc, char ** argv) {

    // controller: handle client connections and data (drawn paths)
    Controller controller;

    Wt::WServer server(argc, argv, WTHTTP_CONFIGURATION);

    // endpoint "/": create a client app and register connection in the controller
    auto mkApp = [&controller] (const Wt::WEnvironment & env) {
        return std::make_unique<AppDrawing>(env, controller);
    };
    server.addEntryPoint(Wt::EntryPointType::Application, mkApp, "/");

    server.run();
    return 0;
}

Contrôleur

Le contrôleur gère le modèle et les connexions client (src/Controller.hpp). Comme ici le modèle est très simple (un tableau de chemins), on l’implémente par un attribut du contrôleur. Quelques méthodes permettent d’implémenter le protocole de communication avec les clients : connexion, déconnexion, accès au tableau de chemins, ajout d’un nouveau chemin. Enfin, on utilise un mutex pour que l’application puisse s’exécuter en multi-thread.

class Controller {
    private:
        mutable std::mutex _mutex;
        std::vector<Wt::WPainterPath> _paths;
        std::map<AppDrawing*, std::string> _connections;

    public:
        // register client app
        void addClient(AppDrawing * app) {
            std::unique_lock<std::mutex> lock(_mutex);
            _connections[app] = app->instance()->sessionId();
        }

        // unregister client app
        void removeClient(AppDrawing * app) {
            std::unique_lock<std::mutex> lock(_mutex);
            _connections.erase(app);
        }

        // get all paths
        std::vector<Wt::WPainterPath> getPaths() const {
            std::unique_lock<std::mutex> lock(_mutex);
            return _paths;
        }

        // add a new path and update all client apps
        void addPath(const Wt::WPainterPath & path) {
            std::unique_lock<std::mutex> lock(_mutex);
            _paths.push_back(path);
            for (auto & conn : _connections) {
                auto updateFunc = std::bind(&AppDrawing::addPath, conn.first, path);
                Wt::WServer::instance()->post(conn.second, updateFunc);
            }
        }
};

Application de dessin

Pour implémenter l’application de dessin proprement dite (src/AppDrawing.hpp et src/AppDrawing.cpp), on utilise les widgets fournis par Wt. Cette application va donner lieu à une interface graphique côté client, avec des communications réseaux, mais ceci reste transparent pour le développeur, qui manipule du code orienté objet classique. Par exemple, l’application client expose une fonction addPath qui permet au serveur d’envoyer un nouveau chemin au client, via un appel de méthode classique.

// headers

class AppDrawing : public Wt::WApplication {
    private:
        Controller & _controller;
        Painter * _painter;

    public:
        AppDrawing(const Wt::WEnvironment & env, Controller & controller);
        ~AppDrawing();

        // add a path (sent by the server)
        void addPath(const Wt::WPainterPath & path);
};

// implementation

AppDrawing::AppDrawing(const Wt::WEnvironment & env, Controller & controller) :
    Wt::WApplication(env), _controller(controller)
{
    // build the interface
    root()->addWidget(std::make_unique<Wt::WText>("isopaint_wt "));
    root()->addWidget(std::make_unique<Wt::WBreak>());
    _painter = root()->addWidget(std::make_unique<Painter>(controller, 400, 300));
    // register the client in the controller
    _controller.addClient(this);
    // enable updates from the server
    enableUpdates(true);
}

AppDrawing::~AppDrawing() {
    // unregister the client
    _controller.removeClient(this);
}

void AppDrawing::addPath(const Wt::WPainterPath & path) {
    // add a path sent by the server
    _painter->addPath(path);
    // request updating the interface
    triggerUpdate();
}

Enfin, pour implémenter la zone de dessin, on dérive notre propre widget et on redéfinit son affichage, sa gestion d’événement…

// headers

class Painter : public Wt::WPaintedWidget {
    protected:
        Controller & _controller;
        Wt::WPen _pen;
        Wt::WPainterPath _currentPath;         // local path (being drawn by the user)
        std::vector<Wt::WPainterPath> _paths;  // common paths (sent by the server)

    public:
        Painter(Controller & controller, int width, int height);

        // add a path (sent by the server)
        void addPath(const Wt::WPainterPath & path);

    private:
        // main display function
        void paintEvent(Wt::WPaintDevice * paintDevice) override;

        // callback functions for handling mouse events
        void mouseDown(const Wt::WMouseEvent & e);
        void mouseUp(const Wt::WMouseEvent &);
        void mouseDrag(const Wt::WMouseEvent & e);
};

// implementation

Painter::Painter(Controller & controller, int width, int height) : 
    Wt::WPaintedWidget(),
    _controller(controller),
    _paths(controller.getPaths())
{
    // initialize the widget
    resize(width, height);
    Wt::WCssDecorationStyle deco;
    deco.setBorder(Wt::WBorder(Wt::BorderStyle::Solid));
    setDecorationStyle(deco);
    // initialize the pen
    _pen.setCapStyle(Wt::PenCapStyle::Round);
    _pen.setJoinStyle(Wt::PenJoinStyle::Round);
    _pen.setWidth(4);
    // connect callback functions (for handling mouse events)
    mouseDragged().connect(this, &Painter::mouseDrag);
    mouseWentDown().connect(this, &Painter::mouseDown);
    mouseWentUp().connect(this, &Painter::mouseUp);
}

void Painter::addPath(const Wt::WPainterPath & path) {
    // add a path (sent by the server)
    _paths.push_back(path);
    // update widget
    update();
}

void Painter::paintEvent(Wt::WPaintDevice * paintDevice) {
    Wt::WPainter painter(paintDevice);
    painter.setPen(_pen);
    // draw common paths (sent by the server)
    for (const auto & p : _paths)
        painter.drawPath(p);
    // draw the local path (being drawn by the user)
    painter.drawPath(_currentPath);
}

void Painter::mouseDown(const Wt::WMouseEvent & e) {
    // begin drawing a path: remember the initial position
    Wt::Coordinates c = e.widget();
    _currentPath = Wt::WPainterPath(Wt::WPointF(c.x, c.y));
}

void Painter::mouseDrag(const Wt::WMouseEvent & e) {
    // mouse move: add the new position into the current path
    Wt::Coordinates c = e.widget();
    _currentPath.lineTo(c.x, c.y);
    // update widget
    update();
}

void Painter::mouseUp(const Wt::WMouseEvent &) {
    // send the path to the server, which will send it to the clients
    _controller.addPath(_currentPath);
    // reset current path
    _currentPath = Wt::WPainterPath();
}

Conclusion

À travers cette petite application de dessin collaboratif, nous avons vu qu’il est possible de développer des applications web « fullstack », où un même code peut s’exécuter à la fois côté client et côté serveur. Pour cela, il existe différents types de frameworks, notamment « isomorphiques » et « basés widgets ».

Les applications isomorphiques sont une évolution assez naturelle des applications clientes mono-page couplées à des serveurs d’API web. Il s’agit principalement de réutiliser le code client côté serveur, pour générer la première vue de l’application avant le téléchargement complet de l’application client. Tout ceci repose sur des technologies web classiques et est assez simple à mettre en place. De plus, les frameworks proposent généralement une architecture de code classique (MVC, Flux…) qui permet de développer rapidement des applications.

Les frameworks basés widgets suivent une approche différente mais également intéressante : porter l’architecture des interfaces graphiques utilisateur au monde du web. Ces architectures sont familières aux développeurs d’applications natives et sont très adaptées aux langages orientés objets. Sans être complètement masqué, l’aspect réseau est en grande partie géré par le framework, ce qui facilite le développement.

Enfin concernant les langages, si JavaScript a l’avantage d’être compréhensible directement par les navigateurs, d’autres langages sont également utilisables, via une étape de transpilation vers JavaScript. On notera que les langages compilés permettent de détecter certaines erreurs plus rapidement et que les langages fonctionnels (avec fonctions pures, données immutables…) réduisent les erreurs potentielles.