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 cadriciels pour implémenter la partie serveur. En revanche, pour la partie client, seuls les langages HTML, CSS et JavaScript sont géré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 cadriciel « fullstack ».
N. D. M. : Cette dépêche détaille le développement d’une application Web permettant de faire du dessin collaboratif. Les codes client et serveur sont en JavaScript dans la première partie, puis en Haskell « isomorphique » dans la seconde et, enfin, en C++ « basé widgets ».
Sommaire
Dans cet article, on considère un cadriciel C++ « basé widget » (Wt) et un cadriciel 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 relâché), 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 cadriciels 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
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 canevas 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 utilisateur 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 relâche :
<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 canevas 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, etc.).
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, etc.).
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 canevas :
-- 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 cadriciels Web basés widgets, comme Wt, reprennent logiquement la même architecture que les cadriciels natifs, comme Qt ou GTK. Le développeur écrit un programme classique où l’interface graphique est définie via des widgets ; le cadriciels se charge de construire l’interface dans le navigateur client et de gérer les connexions réseau. 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 fonction lambda 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 multiple fils d’exécution (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éseau, 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énements…
// 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 cadriciels, notamment « isomorphiques » et « basés widgets ».
Les applications isomorphiques sont une évolution assez naturelle des applications clientes mono‐pages 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 cadriciels proposent généralement une architecture de code classique (MVC, Flux…) qui permet de développer rapidement des applications.
Les cadriciels 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 cadriciel, 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.
# Imprécision dans le texte au sujet de Qt
Posté par fabrices . Évalué à 2.
Il faut lire comme Qt Widgets, qui est l'approche historique de Qt. Aujourd’hui il faudrait considérer Qt QML, qui permet de scinder le cœur applicatif, l'interface graphique et l'UX design.
D’ailleurs le premier exemple de Wt est daté de 2008 https://www.webtoolkit.eu/wt/src/hello
Wt est donc calqué sur une ancienne approche de faire des interfaces graphiques ( du point de vue Qt ).
Ensuite quid des performances du DOM / CSS, séparation du code, de l'UX design, C++ moderne versus JavaScript moderne ???
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.