Votazioni

13

Percentuale Tradotta

In questo capitolo imparerai:

  • Implementare un sistema di votazione dei post.
  • Ordinare i post in base al voto su una pagina dei post "migliori".
  • Imparare a scrivere un helper Spacebars generico.
  • Approfondimento sulla sicurezza dei dati in Meteor.
  • Alcune interessanti considerazioni riguardanti le prestazioni di MongoDB.
  • Adesso che il sito sta guadagnando in popolarità, trovare i link migliori sta cominciando a diventando complicato. È necessario un qualche sistema ordinamento per i post.

    Si potrebbe implementare un complesso sistema di ordinamento basato su karma, decadimento del punteggio a tempo, e svariati altri parametri (la maggior parte dei quali implementati in (Telescope)[http://telesc.pe/], il fratello maggiore di Microscope). Nella nostra applicazione invece si punta alla semplicità, per cui i post saranno ordinati in base al numero di voti ricevuti.

    Per cominciare, verrà fornita agli utenti la possibilità di votare un post.

    Modello dei Dati

    Per determinare se visualizzare agli utenti il pulsante di upvote o meno, per ciascun post verrà salvata la lista degli utenti che lo hanno votato, che consente anche di evitare che un utente voti più di una volta.

    Riservatezza dei Dati & Pubblicazioni

    Le liste dei votanti saranno disponibili a tutti gli utenti, il che renderà i dati automaticamente e pubblicamente consultabili tramite la console del browser.

    Questo è il tipico problema di riservatezza dei dati causato dal modo in cui le collezioni funzionano. Ad esempio, si vuole consentire agli utenti la ricerca degli utenti che hanno votato i loro post? Nel caso in questione rendere tali informazioni pubbliche non ha reali conseguenze, ma è importante almeno avere consapevolezza del problema.

    Da notare che se si volesse limitare l'accesso ad alcune di queste informazioni, ci si dovrebbe assicurare che lato client non venga consentito smanettare con le opzioni dei campi fields della pubblicazione, rimuovendo la proprietà lato server, oppure evitando di passare per intero le opzioni dal client al server.

    Inoltre, il totale dei voti per ciascun post sarà denormalizzato, al fine di ottenere in maniera semplice tale contatore. Verranno quindi aggiunti due attributi ai post, upvoters e votes. Per cominciare, i suddetti campi verranno aggiunti ai dati di esempio:

    // Fixture data
    if (Posts.find().count() === 0) {
      var now = new Date().getTime();
    
      // create two users
      var tomId = Meteor.users.insert({
        profile: { name: 'Tom Coleman' }
      });
      var tom = Meteor.users.findOne(tomId);
      var sachaId = Meteor.users.insert({
        profile: { name: 'Sacha Greif' }
      });
      var sacha = Meteor.users.findOne(sachaId);
    
      var telescopeId = Posts.insert({
        title: 'Introducing Telescope',
        userId: sacha._id,
        author: sacha.profile.name,
        url: 'http://sachagreif.com/introducing-telescope/',
        submitted: now - 7 * 3600 * 1000,
        commentsCount: 2,
        upvoters: [], votes: 0
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: tom._id,
        author: tom.profile.name,
        submitted: now - 5 * 3600 * 1000,
        body: 'Interesting project Sacha, can I get involved?'
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: sacha._id,
        author: sacha.profile.name,
        submitted: now - 3 * 3600 * 1000,
        body: 'You sure can Tom!'
      });
    
      Posts.insert({
        title: 'Meteor',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://meteor.com',
        submitted: now - 10 * 3600 * 1000,
        commentsCount: 0,
        upvoters: [], votes: 0
      });
    
      Posts.insert({
        title: 'The Meteor Book',
        userId: tom._id,
        author: tom.profile.name,
        url: 'http://themeteorbook.com',
        submitted: now - 12 * 3600 * 1000,
        commentsCount: 0,
        upvoters: [], votes: 0
      });
    
      for (var i = 0; i < 10; i++) {
        Posts.insert({
          title: 'Test post #' + i,
          author: sacha.profile.name,
          userId: sacha._id,
          url: 'http://google.com/?q=test-' + i,
          submitted: now - i * 3600 * 1000,
          commentsCount: 0,
          upvoters: [], votes: 0
        });
      }
    }
    
    server/fixtures.js

    Al solito, fermare l'applicazione, eseguire meteor reset, riavviare l'app, quindi creare un nuovo account. È opportuno assicurarsi che i due campi vengano inizializzati al momento della creazione dei post:

    //...
    
    // check that there are no previous posts with the same link
    if (postAttributes.url && postWithSameLink) {
      throw new Meteor.Error(302,
        'This link has already been posted',
        postWithSameLink._id);
    }
    
    // pick out the whitelisted keys
    var post = _.extend(_.pick(postAttributes, 'url', 'title', 'message'), {
      userId: user._id,
      author: user.username,
      submitted: new Date().getTime(),
      commentsCount: 0,
      upvoters: [],
      votes: 0
    });
    
    var postId = Posts.insert(post);
    
    return postId;
    
    //...
    
    collections/posts.js

    Implementazione dei template per le votazioni

    Per prima cosa, verrà aggiunto un pulsante al template del post:

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn"></a>
        <div class="post-content">
          <h3><a href="{{url}}">{{title}}</a><span>{{domain}}</span></h3>
          <p>
            {{votes}} Votes,
            submitted by {{author}},
            <a href="{{pathFor 'postPage'}}">{{commentsCount}} comments</a>
            {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
          </p>
        </div>
        <a href="{{pathFor 'postPage'}}" class="discuss btn">Discuss</a>
      </div>
    </template>
    
    client/views/posts/post_item.html
    Il pulsante per votare
    Il pulsante per votare

    Verrà quindi chiamato un metodo lato server per eseguire l'upvote quando l'utente clicca sul pulsante:

    //...
    
    Template.postItem.events({
      'click .upvote': function(e) {
        e.preventDefault();
        Meteor.call('upvote', this._id);
      }
    });
    
    client/views/posts/post_item.js

    Infine, verrà aggiunto un metodo lato server in collections/posts.js che eseguirà la registrazione del voto:

    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        var user = Meteor.user();
        // ensure the user is logged in
        if (!user)
          throw new Meteor.Error(401, "You need to login to upvote");
    
        var post = Posts.findOne(postId);
        if (!post)
          throw new Meteor.Error(422, 'Post not found');
    
        if (_.include(post.upvoters, user._id))
          throw new Meteor.Error(422, 'Already upvoted this post');
    
        Posts.update(post._id, {
          $addToSet: {upvoters: user._id},
          $inc: {votes: 1}
        });
      }
    });
    
    collections/posts.js

    Commit 13-1

    Added basic upvoting algorithm.

    Questo methodo è sufficientemente autoesplicativo. Si eseguono alcuni controlli preventivi per assicurarsi che l'utente sia loggato e che il post esista. Quindi si controlla che l'utente non abbia già espresso il suo voto per il post, quindi si incrementa il contatore dei voti e si aggiunge l'utente alla lista dei votanti.

    Questa ultima operazione si rivela interessante, in quanto vengono utilizzato due operatori speciali di Mongo. Ce ne sono molti altri da imparare, ma questi due sono estremamente utili: $addToSet aggiunge un elemento ad un array se non già incluso, mentre $inc semplicemente incrementa il valore di un campo di tipo intero.

    Ritocchi all'Interfaccia Utente

    Se l'utente non è loggato, oppure ha già votato, non deve essere in grado di votare nuovamente. Per evidenziare questo caso sull'interfaccia, verrà utilizzata una funzione di supporto che aggiunge, ove necessario, la classe CSS disabled al pulsante per la votazione.

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn {{upvotedClass}}"></a>
        <div class="post-content">
          //...
      </div>
    </template>
    
    client/views/posts/post_item.html
    Template.postItem.helpers({
      ownPost: function() {
        //...
      },
      domain: function() {
        //...
      },
      upvotedClass: function() {
        var userId = Meteor.userId();
        if (userId && !_.include(this.upvoters, userId)) {
          return 'btn-primary upvotable';
        } else {
          return 'disabled';
        }
      }
    });
    
    Template.postItem.events({
      'click .upvotable': function(e) {
        e.preventDefault();
        Meteor.call('upvote', this._id);
      }
    });
    
    client/views/posts/post_item.js

    La classe viene cambiata da .upvote a .upvotable, quindi è opportuno non tralasciare di modificare il gestore dell'evento click.

    Disabilitazione del pulsante per la votazione.
    Disabilitazione del pulsante per la votazione.

    Commit 13-2

    Grey out upvote link when not logged in / already voted.

    Ora, si può notare che i post con un singolo voto sono etichettati come “1 votes”, vediamo quindi come formattare il plurale (in Inglese) in modo appropriato. Mettere in forma plurale può essere un processo complicato, ma per adesso verrà affrontato in modo semplicistico. Si implementerà una funzione Spacebars generica, che può essere riutilizzata altrove.

    UI.registerHelper('pluralize', function(n, thing) {
      // fairly stupid pluralizer
      if (n === 1) {
        return '1 ' + thing;
      } else {
        return n + ' ' + thing + 's';
      }
    });
    
    client/helpers/Spacebars.js

    Le funzioni che sono state create sono legate ai relativi manager e template. Ma utilizzando Spacebars.registerHelper viene creata una funzione di supporto globale che può essere utilizzata in qualunque template:

    <template name="postItem">
    //...
    <p>
      {{pluralize votes "Vote"}},
      submitted by {{author}},
      <a href="{{pathFor 'postPage'}}">{{pluralize commentsCount "comment"}}</a>
      {{#if ownPost}}<a href="{{pathFor 'postEdit'}}">Edit</a>{{/if}}
    </p>
    //...
    </template>
    
    client/views/posts/post_item.html
    Perfezionamento della forma plurale
    Perfezionamento della forma plurale

    Commit 13-3

    Added pluralize helper to format text better.

    Adesso si dovrebbe vedere 1 vote.

    Miglioramento dell'Algoritmo di Votazione

    Il codice che implementa la votazione dei post sembra sufficientemente buono, ma può essere ulteriormente migliorato. Nel metodo upvote vengono eseguite 2 chiamate a Mongo: la prima per ottenere il post, l'altra per modificarlo.

    Ci sono due problemi in questa scelta implementativa. Per prima cosa, è inefficiente accedere al database per 2 volte. Ma ben più importante, introduce una corsa critica. L'algoritmo implementato è il seguente:

    1. Legge il post dal database
    2. Verifica se l'utente ha votato
    3. In caso negativo, esegue il voto

    Cosa succede se lo stesso utente vota nuovamente lo stesso post durante i passi 1 e 3? Questa versione consente all'utente di votare 2 volte per lo stesso post. Fortunatamente, Mongo fornisce un metodo più efficace combinando i passi 1-3 in un singolo comando:

    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        var user = Meteor.user();
        // ensure the user is logged in
        if (!user)
          throw new Meteor.Error(401, "You need to login to upvote");
    
        Posts.update({
          _id: postId,
          upvoters: {$ne: user._id}
        }, {
          $addToSet: {upvoters: user._id},
          $inc: {votes: 1}
        });
      }
    });
    
    collections/posts.js

    Commit 13-4

    Better upvoting algorithm.

    Il codice si traduce in: “trova tutti i post con questo id per cui l'utente non ha ancora votato, e aggiornali in questo modo”. Se l'utente non ha ancora votato, il post con quell’id sarà ovviamente trovato. D'altro canto, se l'utente ha già votato, allora la ricerca non produrrà alcun risultato, e di conseguenza non succederà niente.

    Il solo problema adesso adesso è che non si può informare l'utente riguardo la votazione già eseguita per il post (poiché è stata eliminata la chiamata al database che eseguiva la verifica). Ad ogni modo, l'utente dovrebbe esserne a conoscenza dato che il pulsante “upvote” nell'interfaccia utente è disabilitato.

    Compensazione della Latenza

    Supponiamo che si provi a barare spostando uno dei propri post in cima alla lista, modificando manualmente il numero dei voti:

    > Posts.update(postId, {$set: {votes: 10000}});
    
    Console del browser

    (Dove postId è l'id di uno dei propri post).

    Questo tentativo di ingannare il sistema verrebbe intercettato dal callback deny() (in collections/posts.js) ed immediatamente annullato.

    Ma esaminando attentamente, è possibile che si veda in azione la compensazione di latenza. Può durare un instante, ma il post sarà temporaneamente spostato in cima alla lista prima di essere rispedito alla sua posizione originaria.

    Cosa è successo? Nella collezione locale Posts, l’update è stato eseguito senza problemi. Ciò accade istantaneamente, per cui il post salta in cima alla lista. Al contempo, sul server l’update viene negato. Quindi qualche istante dopo (misurato in millisecondi se Meteor è in esecuzione sul proprio computer), il server restituisce l'errore, istruendo la collezione locale a ripristinare la modifica.

    Risultato finale: durante l'attesa di una risposta da parte del server, l'interfaccia utente non può far altro che considerare attendibile la collezione locale. Non appena il server nega la modifica, l'interfaccia utente si adatta di conseguenza.

    Classifica dei post

    Ora che ciascun post ha un punteggio dipendente dal numero dei voti, si può visualizzare una lista dei migliori post. Per far ciò, si vedrà come sia possibile gestire due sottoscrizioni separate operanti sulla collezione dei post, e rendere maggiormente generico il template postsList.

    Per cominciare, sono necessarie due sottoscrizioni, una per ciascun ordinamento. Il trucco sta nel fatto che entrambe sranno sottoscritte alla stessa pubblicazione posts, solo con parametri differenti!

    Verranno inoltre create due nuove route chiamate newPosts e bestPosts, accedibili tramite le URL rispettivamente /new e /best (ovviamente insieme a /new/5 e /best/5 per la paginazione).

    Per realizzare quanto detto, verrà esteso PostsListController in due distinti controller NewPostsListController e BestPostsListController. Questo consentirà il riuso delle medesime opzioni di routing sia per la route home che per newPosts, a partire da un singolo NewPostsListController da cui ereditare. Tutti ciò dimostra quanto Iron Router possa essere flessibile.

    PostsListController = RouteController.extend({
      template: 'postsList',
      increment: 5,
      limit: function() {
        return parseInt(this.params.postsLimit) || this.increment;
      },
      findOptions: function() {
        return {sort: this.sort, limit: this.limit()};
      },
      waitOn: function() {
        return Meteor.subscribe('posts', this.findOptions());
      },
      posts: function() {
        return Posts.find({}, this.findOptions());
      },
      data: function() {
        var hasMore = this.posts().count() === this.limit();
        return {
          posts: this.posts(),
          nextPath: hasMore ? this.nextPath() : null
        };
      }
    });
    
    NewPostsListController = PostsListController.extend({
      sort: {submitted: -1, _id: -1},
      nextPath: function() {
        return Router.routes.newPosts.path({postsLimit: this.limit() + this.increment})
      }
    });
    
    BestPostsListController = PostsListController.extend({
      sort: {votes: -1, submitted: -1, _id: -1},
      nextPath: function() {
        return Router.routes.bestPosts.path({postsLimit: this.limit() + this.increment})
      }
    });
    
    Router.map(function() {
      this.route('home', {
        path: '/',
        controller: NewPostsListController
      });
    
      this.route('newPosts', {
        path: '/new/:postsLimit?',
        controller: NewPostsListController
      });
    
      this.route('bestPosts', {
        path: '/best/:postsLimit?',
        controller: BestPostsListController
      });
      // ..
    });
    
    lib/router.js

    Da notare che avendo adesso più di una route, la logica dietro nextPath è stata spostata da PostsListController a NewPostsListController BestPostsListController, poiché il percorso sarà diverso in entrambi i casi.

    Inoltre, quando si ordina per votes si ha un parametro di ordinamento aggiuntivo per timestamp di submit, per assicurare che l'ordinamento sia corretto.

    Dopo aver introdotto i nuovi controller, possiamo ora rimuovere senza problemi la route postsList precedentemente definita. Cancelliamo qunidi il seguente codice:

     this.route('postsList', {
      path: '/:postsLimit?',
      controller: PostsListController
     })
    
    lib/router.js

    Aggiungeremo anche i collegamenti nell'intestazione:

    <template name="header">
      <header class="navbar">
        <div class="navbar-inner">
          <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </a>
          <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
          <div class="nav-collapse collapse">
            <ul class="nav">
              <li>
                <a href="{{pathFor 'newPosts'}}">New</a>
              </li>
              <li>
                <a href="{{pathFor 'bestPosts'}}">Best</a>
              </li>
              {{#if currentUser}}
                <li>
                  <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
                </li>
                <li class="dropdown">
                  {{> notifications}}
                </li>
              {{/if}}
            </ul>
            <ul class="nav pull-right">
              <li>{{> loginButtons}}</li>
            </ul>
          </div>
        </div>
      </header>
    </template>
    
    client/views/includes/header.html

    Dobbiamo anche aggiornare il gestore dell'evento di cancellazione dei post:

      'click .delete': function(e) {
        e.preventDefault();
    
        if (confirm("Delete this post?")) {
          var currentPostId = this._id;
          Posts.remove(currentPostId);
          Router.go('home');
        }
      }
    
    client/views/posts_edit.js

    Fatto ciò, si ottiene la lista dei migliori post:

    Ordinamento per punteggio
    Ordinamento per punteggio

    Commit 13-5

    Added routes for post lists, and pages to display them.

    Migliorare l'Header

    Avendo due pagine che elencano i post, non è chiaro quale delle due liste è correntemente visualizzata. È necessario quindi modificare l'header per rendere la cosa esplicita. Viene creato un gestore header.js e una funzione di supporto che usa il percorso corrente e una o più route per aggiungere una classe attiva alle voci di nagivatione.

    La ragione per cui si vuole supportare più route è che sia home che newPosts (a cui corrispondono le URL rispettivamente / e /new) puntano allo stesso template. Quindi activeRouteClass deve essere sufficientemente intelligente da rendere il tag <li>attivo in entrambi i casi.

    <template name="header">
      <header class="navbar">
        <div class="navbar-inner">
          <a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
            <span class="icon-bar"></span>
          </a>
          <a class="brand" href="{{pathFor 'home'}}">Microscope</a>
          <div class="nav-collapse collapse">
            <ul class="nav">
              <li class="{{activeRouteClass 'home' 'newPosts'}}">
                <a href="{{pathFor 'newPosts'}}">New</a>
              </li>
              <li class="{{activeRouteClass 'bestPosts'}}">
                <a href="{{pathFor 'bestPosts'}}">Best</a>
              </li>
              {{#if currentUser}}
                <li class="{{activeRouteClass 'postSubmit'}}">
                  <a href="{{pathFor 'postSubmit'}}">Submit Post</a>
                </li>
                <li class="dropdown">
                  {{> notifications}}
                </li>
              {{/if}}
            </ul>
            <ul class="nav pull-right">
              <li>{{> loginButtons}}</li>
            </ul>
          </div>
        </div>
      </header>
    </template>
    
    client/views/includes/header.html
    Template.header.helpers({
      activeRouteClass: function(/* route names */) {
        var args = Array.prototype.slice.call(arguments, 0);
        args.pop();
    
        var active = _.any(args, function(name) {
          return Router.current() && Router.current().route.name === name
        });
    
        return active && 'active';
      }
    });
    
    client/views/includes/header.js
    Evidenziazione della pagina attiva
    Evidenziazione della pagina attiva

    Parametri dell'Helper

    Non è stato usato finora questo specifico pattern, ma come ogni tag Spacebars, i tag del template helper possono ricevere parametri.

    E se da un lato è possibile passare parametri con nome alla funzione, è anche possibile passare un numero non specificato di parametri anonimi, acceduti tramite l'oggetto arguments dall'interno della funzione.

    In quest'ultimo caso, può risultare conveniente convertire l'oggetto arguments in un array JavaScript, e quindi utilizzare il metodo pop() al fine di liberarsi dell'hash aggiunto da Spacebars.

    Per ciascuna voce di navigazione, l'helper activeRouteClass accetta una lista di nomi di route, e quindi usa la funzione any() di Underscore per verificare se il route passa il test (ovvero se la URL corrispondente coincide con il percorso corrente).

    Se una qualunque delle route coincide con il path corrente, any() restituisce true. Per concludere, si utilizza il pattern Javascript boolean && string, dove false && myString restituisce false, ma true && myString restituisce invece myString.

    Commit 13-6

    Added active classes to the header.

    Ora che gli utenti possono votare post in tempo reale, è possibile vedere i post muoversi su e giù nella home page in risposta a cambiamenti nel loro punteggio. Non sarebbe meglio se esistesse il modo di rendere il tutto più fluido con alcune animazioni?