Node.js: creare un sito web di base

Short link

In questo articolo vedremo come creare un sito web utilizzando Node.js e il database predefinito di MongoDB.

Specifiche

Il database test contiene la collezione restaurants con più di 25000 record.

Il nostro sito mostrerà i dati relativi ai ristoranti come segue:

  1. La homepage presenta sei ristoranti.
  2. I visitatori potranno cercare un ristorante specifico utilizzando il motore di ricerca del sito.
  3. Ogni ristorante avrà una propria pagina con i suoi dati, un'immagine in evidenza, una mappa di Google, un modulo che permetterà ai visitatori di votare l'attuale ristorante e sei ristoranti correlati mostrati come marcatori in un'altra mappa di Google. Infine, i visitatori potranno prenotare un tavolo utilizzando un modulo specifico che invierà una e-mail con i relativi dati di prenotazione.
  4. Il nostro sito avrà una Top Ten con i migliori ristoranti classificati.

Il database

La collezione predefinita non prevede l'immagine in evidenza. Dobbiamo quindi aggiungerla:

const rand = items => {
  return items[Math.floor(Math.random()*items.length)];
}

const images = ['1.jpg', '2.jpg', '3.jpg'/*...*/];

db.restaurants.find().forEach(r => {
    let img = rand(images);
    r.image = img;
    db.restaurants.save(r);
});

Ora la nostra struttura è la seguente:

{
 address: Object,
 borough: String,
 cuisine: String,
 grades: Array,
 name: String,
 restaurant_id: String,
 image: String
 }

Esempio:

{
  "address" : {
    "building" : "469",
    "coord" : [
        -73.961704,
        40.662942
    ],
    "street" : "Flatbush Avenue",
    "zipcode" : "11225"
},
"borough" : "Brooklyn",
"cuisine" : "Hamburgers",
"grades" : [
    {
        "date" : ISODate("2014-12-30T00:00:00Z"),
        "grade" : "A",
        "score" : 8
    },
    {
        "date" : ISODate("2014-07-01T00:00:00Z"),
        "grade" : "B",
        "score" : 23
    },
    {
        "date" : ISODate("2013-04-30T00:00:00Z"),
        "grade" : "A",
        "score" : 12
    },
    {
        "date" : ISODate("2012-05-08T00:00:00Z"),
        "grade" : "A",
        "score" : 12
    }
],
"name" : "Wendy'S",
"restaurant_id" : "30112340",
"image" : "pexels-photo-26981.jpg"
}

address contiene dati geospaziali nell'array coord (la longitudine viene prima).

grades è un array di oggetti che rappresentano le relative votazioni.

restaurant_id sarà usato nei permalink. Possiamo anche creare uno slug volendo:

db.restaurants.find().forEach(r => {
   let name = r.name.toLowerCase();
   let slug = name.replace(/[^a-z0-9]+/g, '');
   r.slug = slug;
   db.restaurants.save(r);
});

Quindi avremo:

{
  //...
"name" : "Wendy'S",
  "restaurant_id" : "30112340",
"image" : "pexels-photo-26981.jpg",
"slug": "wendys"
}

Ora possiamo iniziare a interrogare il nostro database per implementare tre delle caratteristiche menzionate in precedenza, ossia i ristoranti correlati, la Top Ten e il motore di ricerca del sito.

I ristoranti correlati vengono implementati come segue:

db.restaurants.find({cuisine: 'Hamburgers', borough: 'Brooklyn', _id: { $not: { $eq: '5953d2b67eddda6789601f93' }}}).limit(6);

Abbiamo ottenuto sei ristoranti con lo stesso tipo di specialità e nello stesso quartiere di quello da Wendy tranne ovviamente quello di riferimento.

La Top Ten invece si ottiene con un'aggregazione ordinata dell'array grades:

db.restaurants.aggregate([
        { $unwind : '$grades' },
        { $group : {
            _id : { restaurant_id: "$restaurant_id", name: "$name" },
            'scores' : { $sum : '$grades.score' }
        } },
        { $sort : { 'scores': -1 } },
        { $limit : 10 }
    ]);

Otterremo:

{ "_id" : { "restaurant_id" : "41602559", "name" : "Red Chopstick" }, "scores" : 254 }
{ "_id" : { "restaurant_id" : "41164678", "name" : "Nios Restaurant" }, "scores" : 227 }
{ "_id" : { "restaurant_id" : "40366157", "name" : "Nanni Restaurant" }, "scores" : 225 }
{ "_id" : { "restaurant_id" : "41459809", "name" : "Amici 36" }, "scores" : 215 }
{ "_id" : { "restaurant_id" : "41660581", "name" : "Cheikh Umar Futiyu Restaurant" }, "scores" : 212 }
{ "_id" : { "restaurant_id" : "41239374", "name" : "East Market Restaurant" }, "scores" : 209 }
{ "_id" : { "restaurant_id" : "41434866", "name" : "Bella Vita" }, "scores" : 205 }
{ "_id" : { "restaurant_id" : "41233430", "name" : "Korean Bbq Restaurant" }, "scores" : 204 }
{ "_id" : { "restaurant_id" : "40372466", "name" : "Murals On 54/Randolphs'S" }, "scores" : 202 }
{ "_id" : { "restaurant_id" : "40704853", "name" : "B.B. Kings" }, "scores" : 199 }

Infine useremo $regex nel nostro motore di ricerca:

db.restaurants.find({
    name: { "$regex": 'query', "$options": "i" }
}).limit(6);

Node.js

Installeremo questi moduli:

  • ExpressJS
  • Mongoose
  • Body Parser
  • Express Handlebars per usare Handlebars come templating engine
  • Nodemailer per inviare le e-mail
  • Validator per validare i form

package.json:

{
"name": "Restaurants",
"version": "1.0.0",
"private": true,
"description": "Sample app",
"author": "Name <user@site.com>",
"dependencies": {
"body-parser": "^1.13.2",
"express": "^4.13.1",
"express-handlebars": "^3.0.0",
"express-url-breadcrumb": "0.0.8",
"mongoose": "4.9.9",
"nodemailer": "^4.0.1",
"serve-favicon": "^2.4.2",
"validator": "^8.0.0"
},
"license": "MIT"
}

Quindi:

npm install

Struttura delle directory

  • /lib - classi, file di configurazione ed helpers di Handlebars
  • /models - schemi di Mongoose.
  • /public - asset per il frontend.
  • /views - template di Handlebars

Il file config.js

Questo file contiene le impostazioni condivise nella nostra app:

'use strict';
module.exports = {
apiKey: 'Google Maps API key',
imagesPath: '/public/images/',
adminEmail: 'your email',
mail: {
    service: 'Gmail',
    auth: {
        user: 'user@gmail.com',
        pass: 'password'
    }
}
};

apiKey verrà usata dalle Google Maps, imagesPath è il percorso assoluto alla directory delle immagini dei ristoranti, adminEmail e mail verranno usati da Nodemailer per inviare le e-mail.

Il file helpers.js

Handlebars ci permette di specificare degli helper che non sono altro che funzioni che accettano uno o più argomenti. Ad esempio se volessimo ottenere l'URL dell'immagine di un singolo ristorante, potremmo scrivere:

'use strict';

const config = require('./config');

module.exports = {
    the_image: restaurant => {
  return config.imagesPath + restaurant.image;
    }
};

Quindi aggiungiamo gli helper ad Handlebars globalmente:

// app.js
'use strict';
const express = require('express');
const exphbs = require('express-handlebars');
const helpers = require('./lib/helpers');
const app = express();

app.engine('.hbs', exphbs({
    extname: '.hbs',
    defaultLayout: 'main',
    helpers: helpers
}));
app.set('view engine', '.hbs');

Quindi usiamo the_image nei template:

<!-- views/restaurants.hbs -->
<ul id="restaurants">
{{#each restaurants}}
    <li style="background-image: url({{{the_image this}}});">
        <a href="/restaurants/{{this.restaurant_id}}">
            <h3>{{this.name}}</h3>
            <p>{{this.address.street}} {{this.address.building}}, {{this.borough}}</p>
        </a>
    </li>
{{/each}}
</ul>

La classe Mail

Per inviare e-mail abbiamo creato una classe basata sul modulo Nodemailer:

'use strict';
const config = require('./config');
const nodemailer = require('nodemailer');
class Mail {
constructor(options) {
    this.mailer = nodemailer;
    this.settings = config.mail;
    this.options = options;
}

send() {
    if(this.mailer && this.options) {
        let self = this;
        let transporter = self.mailer.createTransport(self.settings);

        if(transporter !== null) {
            return new Promise((resolve, reject) =>{
                transporter.sendMail(self.options, (error, info) =>{
                    if(error) {
                        reject(Error('Failed'));
                    } else {
                        resolve('OK');
                    }
                });
            });
        }
    }
}
}
module.exports = Mail;

La classe Restaurant

Questa classe implementa i ristoranti correlati e la Top Ten:

'use strict';
class Restaurant {
static getRelated(collection, restaurant, relation) {
    let query = {};
    query = relation;
    query._id = { $not: { $eq: restaurant._id }};
    return collection.find(query).limit(6);
}

static getTop(collection) {
    let query = [
        { $unwind : '$grades' },
        { $group : {
            _id : { restaurant_id: "$restaurant_id", name: "$name" },
            'scores' : { $sum : '$grades.score' }
        } },
        { $sort : { 'scores': -1 } },
        { $limit : 10 }
    ];
    return collection.aggregate(query);
}
}
module.exports = Restaurant;

Validazione

Stiamo utilizzando il modulo Validator per convalidare l'input dell'utente. La prima validazione che si verifica nella nostra applicazione è un semplice controllo dell'effettivo ID del ristorante passato alla route del singolo ristorante:

app.get('/restaurants/:id', breadcrumb(), (req, res) => {
    if(validator.isNumeric(req.params.id)) {
      // OK
    } else {
      res.sendStatus(404);
    }
});

Quindi validiamo il voto degli utenti:

app.post('/vote', (req, res) => {
    let id = req.body.id;
    let vote = req.body.vote;
    let grade = req.body.grade;
    let now = new Date();

    let valid = true;
    let grades = 'A,B,C,D,E,F'.split('');

    if(!validator.isMongoId(id)) {
       valid = false;
    }
  if(!validator.isNumeric(vote)) {
   valid = false;
  }
 if(!validator.isInt(vote, {min: 0, max: 100})) {
   valid = false;
 }
 if(grades.indexOf(grade) === -1) {
   valid = false;
 }
 if(!valid) {
   res.sendStatus(403);
 } else {
    // OK
 }
 });

La procedura di cui sopra è interamente basata su un flag booleano che indica se i dati inseriti corrispondono ai criteri di convalida o meno. Ma possiamo anche restituire un più complesso output JSON utilizzando array di oggetti come mostrato di seguito:

app.post('/book', (req, res) => {
    let first = req.body.firstname;
    let last = req.body.lastname;
    let email = req.body.email;
    let persons = req.body.persons;
    let date = req.body.datehour;

    let errors = [];

    if(validator.isEmpty(first)) {
      errors.push({
          attr: 'firstname',
          msg: 'Required field'
      });
    }
    if(validator.isEmpty(last)) {
      errors.push({
          attr: 'lastname',
          msg: 'Required field'
      });
    }
    if(!validator.isEmail(email)) {
      errors.push({
          attr: 'email',
          msg: 'Invalid e-mail address'
      });
    }
    if(!validator.isInt(persons, {min: 1, max: 10})) {
      errors.push({
          attr: 'persons',
          msg: 'Invalid number of persons'
      });
    }
    if(!/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/.test(date)) {
      errors.push({
          attr: 'datehour',
          msg: 'Invalid date and hour'
      });
    }

    if(errors.length > 0) {
        res.json({errors: errors});
    } else {
        // OK
    }
});

La proprietà attr fa riferimento all'attributo name degli elementi del form di prenotazione. In questo modo lato client possiamo operare più facilmente.

Conclusione

In questo articolo abbiamo descritto la struttura semplice di un sito web di esempio. Questa struttura può essere ulteriormente estesa aggiungendo per esempio una sezione di backend in cui potremmo eseguire operazioni CRUD sul database.

Questo sito potrebbe essere trasformato anche in un'applicazione AngularJS o Angular 2 rimuovendo le route presenti e implementando un'API RESTful.

Codice completo

Node.js MongoDB sample app

L'autore

Gabriele Romanato, sviluppatore web full stack specializzato in siti, applicativi web ed e-commerce con Node.js e PHP.