Głosowanie

13

procent przetłumaczone

W tym rozdziale dowiesz się:

  • jak zbudować system głosowania na posty użytkowników.
  • jak oceniać nasze posty na stronie "najlepszych" postów.
  • jak zaimplementować helper (funcję pomocniczą) szablonu handlebars.
  • o podstawach bezpieczeństwa danych w Meteorze.
  • o kilku interesujących kwestiach dotyczących wydajności MongoDB.
  • Poniewaź nasza strona staje się coraz bardziej popularna, znajdowanie najlepszych linków stanie się szybko coraz bardziej skomplikowane. Z tego względu potrzebujemy zbudować system oceniania postów.

    Moglibyśmy zbudować skomplikowany system oceniania używając karmy, zanikającej w miarę upływu czasu punktacji i wielu różnych zasad (większość z nich została zaimplementowana w Telescope](http://telesc.pe), większym bracie Microscope). Dla potrzeb naszej aplikacji użyjemy jednak czegoś prostszego i będziemy oceniać posty przez liczbę głosów jakie otrzymały.

    Zacznijmy od umożliwieniu użytkownikom głosowania na posty.

    Model Danych

    Będziemy zapisywali listę głosów na każdego posta, co umożliwi decyzję pokazania przycisku do głosowania użytkownikom jak również uniemożliwi pokazanie przycisku dwa razy tej samej osobie.

    Prywatność Danych i Publikacji

    Zamierzamy publikować listę głosów dla wszystkich użytkowników, co również umożliwi automatyczny dostęp do danych przez konsolę przeglądarki.

    Jest to pewien rodzaju problem prywatności danych, który może pojawić się przez sposób, w jaki działają kolekcje. Na przykład, czy chcemy aby ludzie wiedzieli kto głosował na ich posty? W naszym przypadku upublicznienie tej informacji nie będzie miało żadnych konsekwencji, ale jest ważne aby zdawać sobie z tego sprawę.

    Również zauważ, że gdybyśmy chcieli ograniczyć pewną część tej informacji, musielibyśmy się upewnić, że klient nie może zmieniać z pól fields naszej publikacji, zarówno przez usunięcie tej własności po stronie serwera jak i przez przekazywanie całego obiektu z opcjami z serwera do klienta.

    Zdenormalizujemy również całkowitą liczbę głosów na posta aby ułatwić odczytywanie tej liczby. Dodamy więc dwa atrybuty do posta: ‘głosujący’ (upvoters) i 'głosy’ (votes). Zacznijmy przez dodanie ich do pliku z danymi początkowymi (fixtures).

    // dane początkowe
    if (Posts.find().count() === 0) {
      var now = new Date().getTime();
    
      // utwórz dwóch użytkowników
      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

    Jak zwykle zatrzymaj aplikację, uruchom meteor reset, zrestartuj swoją aplikację i utwórz nowe konto użytkownika. Następnie upewnijmy się, że powyższe dwie opcje są zainicjalizowane gdy posty są tworzone.

    //...
    
    // upewnij się, że nie ma postów z takim samym odnośnikiem
    if (postAttributes.url && postWithSameLink) {
      throw new Meteor.Error(302, 
        'This link has already been posted', 
        postWithSameLink._id);
    }
    
    // wybierz klucze z whitelisty
    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

    Budowa szablonów wykorzystywanych do głosowania

    Najpierw dodajmy przycisk umożliwiający głosowanie na posta:

    <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
    The upvote button
    The upvote button

    Następnie, zawołajmy metodę serwera upvote, gdy użytkownik kliknie na przycisk:

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

    Na końcu, wrócimy do pliku collections/posts.js i następnie dodajmy funkcję po stronie serwera, która doda głos do danego posta.

    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

    Zatwierdź 13-1

    Added basic upvoting algorithm.

    Ta funkcja jest całkiem prosta. Sprawdzamy prewencyjnie, czy użytkownik jest zalogowany i czy post naprawdę istnieje. Następnie upewniamy się, że użytkownik jeszcze nie zagłosował na posta, i inkrementujemy całkowitą liczbę postów i dodajemy użytkownika do listy użytkowników, którzy zagłosowali.

    Ostatni krok jest interesujący, ponieważ użyliśmy kilka specjalnych operatorów Mongo. Jest o wiele więcej do poznania, ale dwa powyżej są bardzo pomocne: $addToSet dodaje element do tablicy jeżeli jeszcze w niej nie istnieje a #inc inkrementuje liczbę całkowitą.

    Poprawki interfejsu użytkownika

    Jeżeli użytkownik nie jest zalogowany, lub jeżeli już zagłosował na posta, nie będzie mógł zagłosować. Aby odzwierciedlić to w interfejsie użytkownika, użyjemy metody pomocniczej szablonu (helpera) aby warunkowo dodać wyłączoną klasę CSS do przycisku umożliwiającego głosowanie.

    <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

    Zmieniamy klasę z .upvote do .upvotable, więc nie zapomnij również zmienić również funcję obsługującą klikanie na element.

    ściemnianie przycisków do głosowania.
    ściemnianie przycisków do głosowania.

    Zatwierdź 13-2

    Ściemnienie przycisku do głosowania gdy nie zalogowany / …

    Następnie, możesz zauważyć, że posty z jednym głosem są oznaczone jako “1 votes” (jeden głos) więc spędźmy trochę czasu na poprawnej odmianie tych etykiet. Odmiana w liczbie mnogiej może być skomplikowana, ale na teraz zróbmy to w sposóp uproszczony. Zaimplementujemy ogólną funkcję pomocniczą szablonu (helper) , który będzie można użyć w dowolnym miejscu:

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

    Funkcje pomocnicze szablonów, które stworzyliśmy poprzednio, były związane z managerem i szablonem do którego miały zastosowanie. Przez użycie Handlebars.registerHelper utworzyliśmy globalny helper, który może być używany z dowolnego szablonu:

    <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
    Dopracowanie liczby mnogiej do perfekcji (ang. Perfecting Proper Pluralization, potrenuj angielski i powiedz to 10 razy)
    Dopracowanie liczby mnogiej do perfekcji (ang. Perfecting Proper Pluralization, potrenuj angielski i powiedz to 10 razy)

    Zatwierdź 13-3

    Dodano helper do stosowania liczby mnogiej aby poprawić f…

    Powinniśmy teraz zobaczyć “1 vote” (1 głos). Jako ćwiczenie zmień helpera aby stosował język polski.

    Mądrzejszy algorytm głosowania

    Nasz kod odpowiedzialny za głosowanie wygląda dobrze, ale może być jeszcze lepszy. W metodzie upvote dwukrotnie odpytujemy bazę Mongo: pierwszy raz, aby pobrać post i drugi aby go uaktualnić.

    Prowadzi to do dwóch problemów. Pierwszy jest taki, że jest stosunkowo nieefektywne odpytywać bazę dwukrotnie. Drugi, ważniejszy, że może dojść do wyścigu wykonania kodu (ang. race condition). Prześledź następujący algorytm:

    1. Pobierz posty z bazy danych.
    2. Sprawdź, czy użytkownik już zagłosował.
    3. Jeżeli nie, zagłosuj za pomocą użytkownika.

    Co się stanie, jeżeli ten sam użytkownik zagłosuje na ten sam post podczas wykonywania tego algortymu? W obecnej postaci umożliwia to zagłosowanie na ten sam post dwukrotnie. Na szczęście Mongo pozwala na połączenie kroków 1-3 w pojedyńczą komendę:

    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        var user = Meteor.user();
        // upewnij się, że użytkownik jest zalogowany
        if (!user)
          throw new Meteor.Error(401, "Musisz się zalogować, aby móc głosować");
    
        Posts.update({
          _id: postId, 
          upvoters: {$ne: user._id}
        }, {
          $addToSet: {upvoters: user._id},
          $inc: {votes: 1}
        });
      }
    });
    
    collections/posts.js

    Zatwierdź 13-4

    Ulepszony algorytm głosowania.

    Powyżej wykonujemy polecenie “znajdź wszystkie posty z danym id na które ten użytkownik jeszcze nie zagłosował i uaktualnij je w ten sposób”. Jeżeli użytkownik jeszcze nie zagłosował, wyszukane zostaną posty z danym id. Z drugiej strony, jeżeli użykownił zagłosował, wtedy zapytanie nie znajdzie żadnych dokumentów i w konsekwencji nic się nie wydarzy.

    Jedynym minusem jest to, że nie możemy powiedzieć użytkownikowi, czy już zagłosował na dany post (ponieważ pozbyliśmy się zapytania do bazy danych, które to sprawdzało). Ale mimo wszystko powinien to zauważyć patrząc na wyłączony przycisk do głosowania w interfejsie użytkownika.

    Kompensacja lagów

    Przyjmijmy, że starałeś się oszukiwać i wysłać jeden z twoich postów na samą górę listy, przez bezpośrednią zmianę liczby głosów:

    > Posts.update(postId, {$set: {votes: 10000}});
    
    Konsola przeglądarki

    (Gdzie postId jest identyfikatorem jednego z twoich postów)

    Ta bezczelna próba oszukania systemu zostałaby przechwycona przez nasz callback deny() (w collections/posts.js, pamiętasz?) i natychmiast zanegowana.

    Ale jeżeli przyjrzysz się temu dokładnie, mógłbyś zauważyć kompensację lagów w akcji. Może to nastąpić tylko na bardzo krótki moment, ale post na chwilę skoczy na pierwszą pozycję listy i wróci na swoją bieżącą pozycję.

    Co się stało? W twojej lokalnej kolekcji Posts, update zostało zawołane bez żadnego problemu. Dzieje się to natychmiastowo i post szybko przemieszcza się na górę listy. W międzyczasie po stronie serwera update jest zanegowane, więc chwilę później (kilka milisekund, jeżeli serwer Meteora jest uruchomiony lokalnie), serwer zwraca błąd nakazując kolekcji na przywrócenie do poprzedniej postaci.

    Wynik końcowy: podczas czekania na odpowiedź serwera, interfejs użytkownika nie może zrobić nic jak tylko zaufać lokalnej kolekcji. Gdy tylko serwer zwraca odpowiedź błędu i neguje modyfikację, interfejs użytkownika dostosowuje się do tego.

    Ocenianie postów na frontowej stronie

    Teraz skoro mamy ranking dla każdego posta bazujący na liczbie głosów, wyświetlmy listę najlepszych postów. Aby to osiągnąć, zobaczymy jak zarządzać dwiema osobnymi subskrypcjami kolekcji postów i uogólnić szablon postsList.

    Aby rozpocząć, chcemy mieć dwie subskrypcje, każdą dla innego porządku sortowania. Trick polega na tym, że obie subskrypcje zasubskrybują tą samą publikację posts z różnymi parametrami!

    Stworzymy także dwie nowe trasy routingu (ang. routes) newPosts oraz bestPosts, dostępne odpowiednio jako /new i /best (jak również /new/5 i /best/5 aby podzielić listę na kilka stron).

    Aby tego dokonać rozszerzymy nasz PostListController w dwa osobne kontrolery NewPostsListsController oraz BestPostsListController. Pozwoli to na ponowne użycie tych samych parametrów routowania zarówno dla home i newPosts, przez udostępnienie kontrolera NewPostsListCOntroller z którego można dziedziczyć. Dodatkowo jest to dobry przykład pokazujący jak elastyczny jest Iron Router.

    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().fetch().length === 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

    Zauważ, że mamy teraz więcej niż jedną trasę, wyjęliśmy logikę nextPath z kontrolera PostListController w NewPostsListController i BestPostsListController, ponieważ scieżka routingu będzie się różniła w obu przypadkach.

    Dodatkowo, gdy sortujemy po liczbie głosów, mamy drugie sortowanie po czasie publikacji postów aby upewnić się, że sortowanie jest poprawne.

    Dodamy także linki w nagłówku strony:

    <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/include/header.html

    Mając tą implementację, możemy uzyskać listę najlepszych postów:

    Ranking po liczbie punktów
    Ranking po liczbie punktów

    Zatwierdź 13-5

    Dodano trasy dla list postów i stron do ich wyświetlania.

    Lepszy nagłówek

    Teraz skoro mamy dwie strony z listą postów, ciężko wiedzieć którą listę w tej chwili przeglądasz. Zmieńmy więc nagłówek strony, żeby ta informacja była bardziej oczywista. Stworzymy menadżera header.js oraz funkcję pomocniczą, która używając aktualną ścieżkę routingu i jedną lub więcej tras ustawi aktywną klasę na naszych elementach nawigacyjnych:

    Przyczyną, dla której chcemy obsługiwać wielokrotne nazwy tras jest to, że zarówno trasy home jako i newPosts (które odpowiadają URL odpowiednio / i /new) wskazują na ten sam szablon. Oznacza to, że nasz klasa activeRouteClass powinna być na tyle sprytna, aby uaktywnić tag <li> w obu przypadkach.

    <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().route.name === name
        });
    
        return active && 'active';
      }
    });
    
    client/views/includes/header.js
    Pokazywanie aktywnej strony
    Pokazywanie aktywnej strony

    Parametry helpera

    Aż do teraz nie używaliśmy tego konkretnego wzorca, ale zarówno jak każdy inny tag Handlebars, helper szablonu może także mieć parametry.

    I podczas, gdy oczywiście możesz przekazywać kontretne nazwane parametry do swojej funkcji, możesz także przekazać nieokreśloną liczbę anonimowych parametrów i mieć do nich dostęp w ciele funkcji za pomocą obiektu arguments.

    W tym ostatnim przypadku, prawdopodobnie będziesz chciał przekształcić obiekt arguments w zwykłą tablicę JavaScript i następnie wywołać pop() aby usunąć hash dodany na końcu przez Handlebars.

    Dla każdego elementu nawigacji, helper activeRouteClass przyjmuje listę nazw tras i następnie używa helpera any() biblioteki Underscore aby sprawdzić, czy jakakolwiek trasa przechodzi test (tj. czy odpowiadający jej URL jest równy aktualnej ścieżce).

    Jeżeli jakiekolwiek trasy są dopasowane do aktualnej ścieżki, any() zwróci true. Na samym końcu wykorzytamy wzorzec JavaScript o nazwie boolean && string, gdzie false && myString zwraca false ale true && myString zwraca myString.

    Zatwierdź 13-6

    Dodane aktywne klasy do nagłówka strony.

    Teraz gdy użytkownicy mogą głosować na posty w czasie rzeczywistym, zobaczysz, że elementy skaczą w górę i w dół strony podczas zmiany rankingu. Czy nie było by miło, gdyby istniał sposób na wygładzenie tego za pomocą dobrze dopasowanych animacji?