Sommario
< Home
Stampa

Web services con NodeJs ed Express

In questa lezione andremo a realizzare una mini applicazione in cui verrà realizzato, oltre al client, sia il Web Server che i Web Service necessari a far funzionare l’applicazione lato client. Questo tipo di programmazione viene chiamato “full stack” perchè interviene sia nella parte client, detta di frontend (perchè è vicina all’utente), sia nella parte server, detta di backend, lontana quindi dall’interazione con l’utente.

Nella dispensa precedente si era mostrato un esempio di server web utilizzando il modulo http, ad esempio:

let http = require("http");
http.createServer(function (request, response) {
   response.writeHead(200, {'Content-Type': 'text/plain'});
   response.end('Hello World!\n');
}).listen(80);

In realtà, sebbene sia possibile scrivere una applicazione web server con il modulo http, la soluzione che utilizza la maggior parte dei programmatori è quella di usare la libreria Express.js. Questo perchè Express fornisce molte funzioni aggiuntive che semplificano il lavoro del programmatore e non lo costringono a “reinventare” la ruota.

Useremo Express.js per scrivere il nostro primo Web Service.

Un po’ di ripasso 

Si ricorda che un Web Server è un server che espone servizi tramite internet ed il protocollo http, ricevendo http request e rispondendo con http response.

Una Web Application è una applicazione lato server che sta dietro al Web Server e che riceve richieste di oggetti web (es. html, css, immagini, ecc.), elabora la richiesta eseguendo l’applicazione in un determinato linguaggio (es. PHP, Java, Javascript, ecc.) e restituisce la risposta al Web server che a sua volta la invia al client come http response. Lo schema può essere quindi generalizzato con questo diagramma.

Un Web Service invece è una applicazione lato server che sta dietro al Web Server e che riceve dati o richieste di dati, elabora la richiesta ed invia al Web server una risposta, che a sua volta viene inviata al client come http response. Qui lo schema generale, simile eppure diverso nel tipo di dato richiesto e restituito.

Il Web service offre servizi e trasporta dati, è quindi uno strumento pensato per architetture SOA

Nel nostro esempio realizzeremo sia il Web Server, che il Web Service, in una unica applicazione.

Una TODO list

L’applicazione che ci accompagnerà in questo esempio si chiama “TODO list” ed è una semplice applicazione che consente di creare task, cancellare task e marcare task come completati. Questa applicazione, se realizzata nel solo browser, consente di gestire liste di attività, ma soffre di diversi limiti:

1) Sebbene sia possibile salvare i task e ricaricarli in sessioni successive, è possibile farlo solo da un computer.

2) Non è possibile condividere la lista delle cose da fare con altri computer e persone.

Se invece si realizza un Web Service pubblico allora si risolvono entrambi i problemi. Il Web Service si occuperà di gestire la lista (centralizzata) dei task, e quindi di aggiungere, completare ed eliminare i task. I client si occuperanno dell’interfaccia utente e richiederanno al web service quattro operazioni: 1) aggiunta, 2) lista, 3) modifica, 4) eliminazione. Client e Server si scambieranno solo dati.

Cominciamo a descrivere la prima funzione, cioè l’aggiunta di una nuova Todo.

per semplicità omettiamo il codice non necessario alla trattazione, l’html conterrà un input text, un button, ed una lista (es. un UL list) con le Todo e i relativi pulsanti di completamento-eliminazione specifici per ogni Todo.

Codice lato client

... // definizione di variabili DOM, tra cui un bottone che chiamiamo "insertButton"

let todos = []; // lista dei task

const render = () => {

   ... // codice che genera l'html da todos

}

A

questo punto scriviamo due metodi: send(), che invia la nuova todo al server, e load(), che ricarica la lista. E’ opportuno tenere separate le due azioni, perchè come abbiamo detto l’informazione è gestita lato server, e sarà il server quindi a dare un id alla nuova Todo, e ad aggiungerla alla lista. 

const send = (todo) => {

   return new Promise((resolve, reject) => {

      fetch("/todo/add", {

         method: 'POST',

         headers: {

            "Content-Type": "application/json"

         },

         body: JSON.stringify(todo)

      })

      .then((response) => response.json())

      .then((json) => {

         resolve(json); // risposta del server all'aggiunta

      })

   })

}

const load = () => {

   return new Promise((resolve, reject) => {

      fetch("/todo")

      .then((response) => response.json())

      .then((json) => {

         resolve(json); // risposta del server con la lista

      })

   })

}

A questo punto diamo vita ad insertButton (cioè il pulsante con cui aggiungiamo la Todo).

insertButton.onclick = () => {

   const todo = {          

      name: todoInput.value,

      completed: false

   }      

   send({todo: todo}) // 1. invia la nuova Todo

    .then(() => load()) // 2. caricala nuova lista

    .then((json) => { 

      todos = json.todos;

      todoInput.value = "";

      render();  // 3. render della nuova lista

   });

}

Come si può vedere aggiunta e ricaricamento sono due operazioni distinte e necessariamente asincrone: bisogna aspettare prima che il caricamento vada a buon fine (funzione send) e poi ricaricare tutta la lista (funzione load). Non è il client quindi a gestire la lista, ma il server.

Completiamo il client con le istruzioni per caricare all’avvio la lista delle todo:

load().then((json) => {

   todos = json.todos;

   render();

});

Codice lato server: Express

Express è un modulo (installabile via npm) che permette di creare un componente applicazione server, che svolge il ruolo di middleware. Come noto il middleware consente quindi di creare una API di un Web service in modo semplice e intuitivo. Occorre includere anche un plugin di express, bodyparser, che consente di eseguire automaticamente l’istruzione JSON.parse() dei Json inviati dal client e che permette di decodificare le URL delle chiamate (quando contengono caratteri speciali come spazi, virgolette, ecc.) semplificando la gestione dei contenuti inviati dal client (non bisogna scrivere codice, ci pensa il plugin).

Vediamo ora il codice che permette quindi di attivare quanto sopra.

const express = require("express");
const app = express();
const bodyParser = require('body-parser');
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
   extended: true
}));

Con Express possiamo inoltre rendere disponibile sullo stesso Web Server anche la parte applicativa lato client, ovvero i files html, css e Javascript che abbiamo appena creato nel paragrafo precedente. Quindi la nostra applicazione sarà sia un sito web (statico), che eroga html, css, e js statici, sia un web service (SOA) che fornisce invece servizi HTTP dinamici.

In gergo questo tipo di siti viene anche definito “full stack” in quanto in un unico progetto applicativo si realizza una applicazione distribuita che contiene sia la parte client (una web application HTML-JS-CSS) che la parte server (un web application server SOA scritto in NodeJs). La forza di questa soluzione è che pur essendo di fatto due applicazioni che lavorano insieme, il linguaggio di programmazione è lo stesso, ovvero Javascript.

La parte client è erogata dal server in modalità statica, e per questo usiamo la funzionalità “static” di express qui sotto indicata:

const path = require('path');
app.use("/", express.static(path.join(__dirname, "public")));

Express.static è una funzione di Express che consente di esporre una cartella di progetto sul Web Server. In pratica l’obiettivo è quello di fare in modo che tutti i file presenti nella cartella del progetto:

/path/to/myproject/public

diventino raggiungibili alla URL:

http://host:port/public

(dove host è l’indirizzo del server web,e port la porta). La variabile “__dirname” è una variabile globale di Node che indica la cartella corrente di progetto (ovvero /path/to/myproject), ed è un valore noto solo a runtime.

In altri termini, se l’utente scrive sul browser (ipotizziamo che il server sia su localhost alla porta 80):

http://localhost/public/index.html

il server cercherà il file 

/path/to/myproject/public/index.html

e lo invierà al client. Se non lo trova, allora invierà un 404 (not found).

Successivamente scriviamo i due servizi richiesti:

const todos = [];

app.post("/todo/add", (req, res) => {

   const todo = req.body.todo;

   todo.id = "" + new Date().getTime();

   todos.push(todo);

   res.json({result: "Ok"});

});

app.get("/todo", (req, res) => {

   res.json({todos: todos});

});

Vediamo singolarmente le nuove istruzioni:

app.post("/todo/add", (req, res) => {

...

});

Questa istruzione crea alla URL http://localhost/todo/add un webservice di tipo POST e lo collega ad una funzione che riceve come parametri le variabili req e res che rappresentano la Http Request e la Http Response. 

L’oggetto req ha due proprietà:

– headers (qui non usata): contiene tutti gli headers della richiesta;

– body: contiene il body, notare che il body (inviato come JSON) è già stato processato e quindi è un oggetto Javascript.

Notare inoltre che la funzione aggiunge un id univoco alla nuova Todo, e la aggiunge alla lista di Todo. 

L’oggetto res invece ha un metodo json(object) che inserisce nel body della risposta il JSON dell’oggetto passato come parametro.

app.get("/todo", (req, res) => {

   res.json({todos: todos});

});

Questa istruzione svolge un compito analogo, ma questa volta abilita alla url http://localhost/todo/app un un servizio con metodo get che invia in formato JSON l’elenco delle todo.

In conclusione, con queste istruzioni abbiamo creato una applicazione Express che svolge queste attività:

1) Mappa la url “/” sui contenuti (html, css, js, ecc.)

2) Mappa la url "/todo/add" al servizio POST di aggiunta di una nuova TODO 

3) Mappa la url "/app " al servizio GET che restituisce tutte le TODO

Come ultima azione attiviamo il Web server, che includerà l’oggetto Express:

const server = http.createServer(app);

server.listen(80, () => {

  console.log("- server running");

});

L’istruzione http.createServer(app) crea un server http dall’app Express e successivamente il metodo listen permette di attivare il server sulla porta richiesta (es. 80) e siccome è una operazione asincrona, eseguire poi una callback.

Prima di proseguire, testare l’applicazione e verificare che funzioni tutto.

Miglioriamo l’applicazione

Questa applicazione permette di aggiungere nuove todo, e contestualmente ad ogni aggiunta, anche di aggiornare la lista. Non permette tuttavia di marcare le todo come completate o di cancellarle.

Introduciamo quindi queste due funzionalità.

Lato client, implementiamo prima di tutto la funzione complete todo.

const completeTodo = (todo) => {

   return new Promise((resolve, reject) => {

      fetch("/todo/complete", {

         method: 'PUT',

         headers: {

            "Content-Type": "application/json"

         },

         body: JSON.stringify(todo)

      })

      .then((response) => response.json())

      .then((json) => {

         resolve(json);

      })

   })

}

Questa funzione riceve come parametro la todo che deve essere completata (lato client va eseguita quindi in risposta al pulsante associato alla singola todo). 

In questa chiamata viene introdotto un nuovo metodo PUT specificatamente pensato per modificare un oggetto esistente lato server. Non si tratta di un vincolo formale, infatti potremmo usare ancora POST. Tuttavia è uno standard di fatto che POST implica un servizio in cui inviamo un oggetto nuovo al server, mentre con PUT inviamo un oggetto esistente e modificato al server. Questo ci serve per mantenere una analogia con le istruzioni INSERT e UPDATE di SQL.

Lato server aggiungiamo quindi il nuovo servizio:

app.put("/todo/complete", (req, res) => {

   const todo = req.body;

   try {

      todos = todos.map((element) => {

         if (element.id === todo.id) {

            element.completed = true;

         }

         return element;

      })

   } catch (e) {

      console.log(e);

   }

   res.json({result: "Ok"});

});

Il servizio non aggiunge nulla di concettualmente nuovo riguardo a quanto abbiamo già visto. La PUT, come la POST, permette di inserire il body nella http request.

Per cancellare vediamo il codice:

const deleteTodo = (id) => {

   return new Promise((resolve, reject) => {

      fetch("/todo/"+id, {

         method: 'DELETE',

         headers: {

            "Content-Type": "application/json"

         },

      })

      .then((response) => response.json())

      .then((json) => {

         resolve(json);

      })

   })

}

In questo caso occorre osservare che la URL è generata dinamicamente, da una componente fissa ed una data da una variabile. In pratica la URL identifica la risorsa che si vuole eliminare. Con la DELETE non è previsto un body.

Vediamo il codice lato server.

app.delete("/todo/:id", (req, res) => {

   todos = todos.filter((element) => element.id !== req.params.id);

   res.json({result: "Ok"});  

})

Il server quindi accetta qualsiasi url con la struttura “/todo/:id” e va ad inserire nella variabile req.params.id il valore inserito. E’ importante osservare che si può usare qualsiasi nome di variabile (es. “/todo/:data“) purchè sia mappato allo stesso modo (quindi req.params.data). E’ inoltre possibile usare più valori, ad esempio: /todo/:valore1/:valore2che diventano quindi req.params.valore1 e req.params.valore2

Questo tipo di notazione può essere usato anche con GET per richiedere risorse in modo parametrico.

Si ricorda che la URL ha una limitazione in lunghezza e quindi per richiede complesse bisogna usare il body, e quindi un metodo POST.

Refresh automatici: il problema del polling

La nostra applicazione ha ancora un difetto: il client legge la lista dal server solo dopo aver fatto una qualche operazione di scrittura (aggiunta, modifica o cancellazione). Ma questa applicazione supporta più client in contemporanea e quindi potrebbe accadere che mentre l’utente sul client1 aggiunge una todo, l’utente sul client2 non se ne accorga mai, se non effettua modifiche.

Dobbiamo quindi forzare una sincronizzazione costante col server. Questo problema non è così banale da risolvere. 

Le soluzioni sono di due tipi:

1) Ogni volta che viene modificata la lista il server informa i client che la lista è stata aggiornata, e poi i client scaricano la nuova lista. 

2) Il client periodicamente scarica la lista, operazione che nel gergo sistemistico viene chiamata “polling“.

La prima soluzione non è fattibile usando il protocollo HTTP. Infatti http è un protocollo che prevede sempre una request che deve essere effettuata dal client. Un server non ha modo da parte sua di informare i client che c’è stato un aggiornamento. Per risolvere questa situazione è quindi necessario utilizzare altre soluzioni tecnologiche, come il protocollo Web RTC, altrimenti noto come WebSockets.

La seconda soluzione, che adotteremo qui, consiste invece nell’uso del polling, che tradotto in codice consiste in un setInterval che forza una fetch periodica dei dati.

setInterval(() => {

   load().then((json) => {

      todos = json.todos;

      todoInput.value = "";

      render();

   });

}, 30000);

Bisogna però essere consapevoli dei limiti di questa soluzione: setInterval è una operazione costosa computazionalmente, e nei sistemi mobili, anche dal punto di vista energetico e di batteria e quindi è quella che viene definita una soluzione progettuale debole.

Inoltre anche il polling non è esente da problematiche di refresh. Un refresh troppo frequente consuma CPU (e batterie), un refresh troppo lento diminuisce l’affidabilità del client.

In un sistema reale la soluzione migliore resta quindi sempre un sistema in cui le notifiche vengono dal server, soluzione che vedremo più avanti con le Websockets.

Web Service REST e CRUD

In questa dispensa è stato introdotto il codice base per poter realizzare le quattro operazioni fondamentali di un Web Service:

– caricamento dati – GET (in Sql SELECT)

– inserimento dati – POST (in Sql INSERT)

– modifica dati – PUT (in Sql UPDATE)

– eliminazione dati – DELETE (In Sql DELETE)

Queste quattro operazioni, dette anche CRUD (Create Read Update Delete) sono alla base della gestione di un flusso dati collegato ad una risorsa remota, e sono alla base della tecnologia di Web Service nota come REST.

Altro aspetto da osservare è che questo codice manca di un salvataggio dati persistente: il server non salva, nemmeno su disco, i dati che gestisce. Inoltre manca del tutto della gestione errori: se qualche dato inviato è sbagliato, incongruente o nullo, il server non da errore o si blocca senza avvertire l’utente. 

Infine un’ultima considerazione sul debugging. Sebbene concettualmente la TODO list sia una unica applicazione, di fatto ne vengono realizzate due, una lato client ed una lato server. Occorre quindi gestire due cicli di sviluppo, quello lato server e quello lato client, con annesse quindi due sessioni distinte di debug. Questo problema è insito nelle applicazioni distribuite, dove alcune operazioni sono replicate su tutti i sistemi, con annessa duplicazione (detta anche overhead) di spazio e tempo di calcolo, col rischio di non comprendere sempre dove si trovi un errore. Occorrono quindi strategie di progettazione che minimizzino gli overhead ed allo stesso tempo consentano un debug realmente efficace.