Web fullstack en C++ et en Haskell, application de dessin collaboratif
Voir aussi : article linuxFR - code source
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 :
- à la connection d’un client, le serveur envoie un message “stoh all paths” contenant tous les chemins du dessin actuel;
- lorsqu’un client est connecté, il peut envoyer un chemin au serveur, via un message “htos new path”;
- lorsqu’un client envoie un chemin, le serveur réagit en stockant le nouveau chemin et en le rediffusant à tous les clients, via un message “stoh new path”.
"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
.use("/", express.static("./static"));
app
// client connection
.on("connection", function(socket){
io
// when a new connection is accepted, send all paths
.emit("stoh all paths", paths);
socket
// when a client sends a new path
.on("htos new path", function(path){
socket// store the new path
.push(path);
paths// send the new path to all clients
.emit("stoh new path", path);
io;
});
})
.listen(port, function () {
httpconsole.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"
="border:1px solid black"> </canvas>
style
<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) {
.fillStyle = 'black';
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx
}
function drawPath(canvas, path) {
if (path.length >= 2) {
const ctx = canvas_draw.getContext("2d");
setDrawingStyle(ctx);
.beginPath();
ctx, ...ps] = path;
[p0.moveTo(p0.x, p0.y);
ctx.map(p => ctx.lineTo(p.x, p.y));
ps.stroke();
ctx
}
}</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
.on("stoh all paths", function (paths) {
socket.forEach(path => drawPath(canvas_draw, path));
paths;
})
// when the server sends a new path
.on("stoh new path", function (path) {
socketdrawPath(canvas_draw, path);
;
})
let current_path = [];
// when the user begins to draw a path
.onmousedown = function(evt0) {
canvas_drawconst ctx = canvas_draw.getContext("2d");
setDrawingStyle(ctx);
let p0 = getXY(canvas_draw, evt0);
= [p0];
current_path
// set mousemove callback
.onmousemove = function(evt1) {
canvas_drawconst p1 = getXY(canvas_draw, evt1);
.push(p1);
current_path.beginPath();
ctx.moveTo(p0.x, p0.y);
ctx.lineTo(p1.x, p1.y);
ctx.stroke();
ctx= p1;
p0 ;
};
}
// when the user finishes to draw a path
.onmouseup = function(evt) {
canvas_draw// unset mousemove callback
.onmousemove = {};
canvas_draw// send the path to the server
.emit("htos new path", current_path);
socket;
}</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
= Model [] [] (0,0) False
initialModel
-- view
homeView :: Model -> View Action
= div_
homeView _
[]"isopaint_miso" ]
[ p_ [] [ text
, canvas_ "canvas_draw" , width_ "400" , height_ "300"
[ id_ "border" "1px solid black")
, style_ (singleton MouseDown -- when mouse down, generate a MouseDown action
, onMouseDown MouseUp -- when mouse up, generate a MouseUp action
, onMouseUp
]
[]
]
-- 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
NoOp m = noEff m
updateModel
-- mouse down: begin drawing a path
MouseDown m = noEff m { currentPath_ = [], drawing_ = True }
updateModel
-- mouse move: get position and ask to update the model using a SetXy action
MouseMove (x,y)) m = m <#
updateModel (if drawing_ m
then do
<- jsRectLeft
left <- jsRectTop
top 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
SetXy xy) m =
updateModel (= xy : currentPath_ m } <# pure RedrawCanvas
m { currentPath_
-- mouse up: finish drawing the current path (send the path to the server)
MouseUp (Model a c xy _) = Model a [] xy False <# pure (SendXhr c)
updateModel
-- send a path to the server
SendXhr path) m = m <# (xhrPath path >> pure NoOp)
updateModel (
-- register to Server-Sent Event, for receiving new paths from other clients
RecvSse Nothing) m = noEff m
updateModel (RecvSse (Just path)) m =
updateModel (= path : allPaths_ m } <# pure RedrawCanvas
m { allPaths_
-- clear the canvas and redraw the paths
RedrawCanvas m = m <# do
updateModel <- jsWidth
w <- jsHeight
h <- jsCtx
ctx 0 0 w h ctx
clearRect LineCapRound ctx
lineCap mapM_ (drawPath ctx) $ allPaths_ m
$ currentPath_ m
drawPath ctx pure NoOp
-- initialize paths: ask all paths to the server then update using a SetPaths action
InitAllPaths m = m <# do SetPaths <$> xhrAllPaths
updateModel
-- update paths then ask to redraw the canvas
SetPaths paths) m = m { allPaths_ = paths } <# pure RedrawCanvas updateModel (
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 ()
= void $ xhrByteString $ Request POST "/xhrPath" Nothing hdr False dat
xhrPath path where hdr = [("Content-type", "application/json")]
= StringData $ toMisoString $ encode path
dat
-- ask for the paths ("/xhrPath" endpoint)
xhrAllPaths :: IO [Path]
= fromMaybe [] . decodeStrict . fromJust . contents <$>
xhrAllPaths Request GET "/api" Nothing [] False NoData)
xhrByteString (
-- handle a Server-Sent Event by generating a RecvSse action
ssePath :: SSE Path -> Action
SSEMessage path) = RecvSse (Just path)
ssePath (= RecvSse Nothing
ssePath _
drawPath :: Context -> Path -> IO ()
=
drawPath ctx points length points >= 2) $ do
when (let ((x0,y0):ps) = points
4 ctx
lineWidth
beginPath ctx
moveTo x0 y0 ctxmapM_ (\(x,y) -> lineTo x y ctx) ps
stroke ctx
import javascript unsafe "$r = canvas_draw.getContext('2d');"
foreign jsCtx :: IO Context
import javascript unsafe "$r = canvas_draw.clientWidth;"
foreign jsWidth :: IO Double
import javascript unsafe "$r = canvas_draw.clientHeight;"
foreign jsHeight :: IO Double
import javascript unsafe "$r = canvas_draw.getBoundingClientRect().left;"
foreign jsRectLeft :: IO Int
import javascript unsafe "$r = canvas_draw.getBoundingClientRect().top;"
foreign jsRectTop :: IO Int
Enfin, la fonction principale de l’application client regroupe ces éléments selon l’architecture demandée par Miso.
main :: IO ()
= miso $ const App
main = InitAllPaths
{ initialAction = initialModel
, model = updateModel
, update = homeView
, view = defaultEvents
, events = [
, subs "/ssePath" ssePath, -- register Server-Sent Events to the ssePath function
sseSub MouseMove -- register mouseSub events to the MouseMove action
mouseSub
]= Nothing
, mountPoint }
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 ()
= do
main <- newIORef [] -- list of drawn paths
pathsRef <- newChan -- Server-Sent Event handler
chan 3000 $ logStdout (serverApp chan pathsRef) -- run serverApp on port 3000
run
-- 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
= serve (Proxy @ServerApi)
serverApp chan pathsRef "static" -- serve the "/static" endpoint (using the "static" folder)
( serveDirectoryFileServer :<|> 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
= do
handleXhrPath chan pathsRef path $ do
liftIO -> path:paths)
modifyIORef' pathsRef (\ paths ServerEvent Nothing Nothing [lazyByteString $ encode path])
writeChan chan (pure NoContent
-- when a client requests "/api", send all paths
handleApi :: IORef [Path] -> Handler [Path]
= liftIO (readIORef pathsRef)
handleApi pathsRef
-- when a client requests "/", render and send the home view
handleClientRoute :: Handler (HtmlPage (View Action))
= pure $ HtmlPage $ homeView initialModel handleClientRoute
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
= L.toHtml
toHtmlRaw
-- main function, for rendering a view to a HTML page
HtmlPage x) = L.doctypehtml_ $ do
toHtml ($ do
L.head_ "utf-8"]
L.meta_ [L.charset_
L.with mempty)
(L.script_ "static/all.js", L.async_ mempty, L.defer_ mempty]
[L.src_ -- render the view and include it in the HTML page L.body_ (L.toHtml x)
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
::WServer server(argc, argv, WTHTTP_CONFIGURATION);
Wt
// 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);
};
.addEntryPoint(Wt::EntryPointType::Application, mkApp, "/");
server
.run();
serverreturn 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);
[app] = app->instance()->sessionId();
_connections}
// unregister client app
void removeClient(AppDrawing * app) {
std::unique_lock<std::mutex> lock(_mutex);
.erase(app);
_connections}
// 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);
.push_back(path);
_pathsfor (auto & conn : _connections) {
auto updateFunc = std::bind(&AppDrawing::addPath, conn.first, path);
::WServer::instance()->post(conn.second, updateFunc);
Wt}
}
};
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:
(const Wt::WEnvironment & env, Controller & controller);
AppDrawing~AppDrawing();
// add a path (sent by the server)
void addPath(const Wt::WPainterPath & path);
};
// implementation
::AppDrawing(const Wt::WEnvironment & env, Controller & controller) :
AppDrawing::WApplication(env), _controller(controller)
Wt{
// build the interface
()->addWidget(std::make_unique<Wt::WText>("isopaint_wt "));
root()->addWidget(std::make_unique<Wt::WBreak>());
root= root()->addWidget(std::make_unique<Painter>(controller, 400, 300));
_painter // register the client in the controller
.addClient(this);
_controller// enable updates from the server
(true);
enableUpdates}
::~AppDrawing() {
AppDrawing// unregister the client
.removeClient(this);
_controller}
void AppDrawing::addPath(const Wt::WPainterPath & path) {
// add a path sent by the server
->addPath(path);
_painter// 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 ::WPen _pen;
Wt::WPainterPath _currentPath; // local path (being drawn by the user)
Wtstd::vector<Wt::WPainterPath> _paths; // common paths (sent by the server)
public:
(Controller & controller, int width, int height);
Painter
// 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(Controller & controller, int width, int height) :
Painter::WPaintedWidget(),
Wt(controller),
_controller(controller.getPaths())
_paths{
// initialize the widget
(width, height);
resize::WCssDecorationStyle deco;
Wt.setBorder(Wt::WBorder(Wt::BorderStyle::Solid));
deco(deco);
setDecorationStyle// initialize the pen
.setCapStyle(Wt::PenCapStyle::Round);
_pen.setJoinStyle(Wt::PenJoinStyle::Round);
_pen.setWidth(4);
_pen// connect callback functions (for handling mouse events)
().connect(this, &Painter::mouseDrag);
mouseDragged().connect(this, &Painter::mouseDown);
mouseWentDown().connect(this, &Painter::mouseUp);
mouseWentUp}
void Painter::addPath(const Wt::WPainterPath & path) {
// add a path (sent by the server)
.push_back(path);
_paths// update widget
();
update}
void Painter::paintEvent(Wt::WPaintDevice * paintDevice) {
::WPainter painter(paintDevice);
Wt.setPen(_pen);
painter// draw common paths (sent by the server)
for (const auto & p : _paths)
.drawPath(p);
painter// draw the local path (being drawn by the user)
.drawPath(_currentPath);
painter}
void Painter::mouseDown(const Wt::WMouseEvent & e) {
// begin drawing a path: remember the initial position
::Coordinates c = e.widget();
Wt= Wt::WPainterPath(Wt::WPointF(c.x, c.y));
_currentPath }
void Painter::mouseDrag(const Wt::WMouseEvent & e) {
// mouse move: add the new position into the current path
::Coordinates c = e.widget();
Wt.lineTo(c.x, c.y);
_currentPath// update widget
();
update}
void Painter::mouseUp(const Wt::WMouseEvent &) {
// send the path to the server, which will send it to the clients
.addPath(_currentPath);
_controller// reset current path
= Wt::WPainterPath();
_currentPath }
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.