投票機能

13

翻訳の進捗

本章で学ぶこと:

  • ユーザーが投稿に投票することができるシステムを構築します。
  • "best"ページ上に投票によってランク付けされた投稿を一覧表示。
  • 一般的なSpacebarsヘルパーの書き方を学びます。
  • データセキュリティについて少し詳細について。
  • MongoDBの中でいくつかの興味深いパフォーマンスに関する考慮事項。
  • 私たちのサイトは だんだんと一般向けになってきました。 今、本サイトは、最高のリンクを見つけ、より人気になっているリンクを取得するのは厄介です。 記事を並び変えるためのなんらかのランキングシステムが必要です。

    私たちは、カルマ、ポイントの時間ベースの崩壊、 そして他の多くのものを持つ複雑なランキングシステムを構築することができます。 (そのほとんどは、Telescope、Microscopeの兄に実装されています) しかしこのアプリでは、シンプルにして、投票数で投稿を格付けることにします。

    ユーザーに 投稿に投票する方法を提供することから始めていきましょう。

    データモデル

    私たちは、私たちがユーザーにupvoteボタンを表示するだけでなく、 2回投票しているかどうかを知ることができる各投稿毎のupvotersのリストを格納します。

    データのプライバシー & パブリケーション

    私たちは、すべのユーザーに投票者のリストを公開します。 これでブラウザーコンソールで自動的にそのデータを公的にアクセスできるようにします。

    これはある種のデータプライバシー問題ですが、 これはコレクションを動かす方法に起因しています。 たとえば、私たちはユーザーに誰が投稿に投票したのかわかるようにしたいでしょうか? この場合、その情報を公的に利用することは、実際のところ全く影響も及ぼしませんが、 この問題を少なくとも認識することは重要です。

    また、それが簡単に数字を取得するために、 投稿にupvotersの合計数を非正規化します。 投稿にupvotersvotesを2つの属性を追加しましょう。 私たちのfixturesファイルにそれらを追加することから始めましょう。:

    // 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: new Date(now - 7 * 3600 * 1000),
        commentsCount: 2,
        upvoters: [],
        votes: 0
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: tom._id,
        author: tom.profile.name,
        submitted: new Date(now - 5 * 3600 * 1000),
        body: 'Interesting project Sacha, can I get involved?'
      });
    
      Comments.insert({
        postId: telescopeId,
        userId: sacha._id,
        author: sacha.profile.name,
        submitted: new Date(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: new Date(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: new Date(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: new Date(now - i * 3600 * 1000 + 1),
          commentsCount: 0,
          upvoters: [],
          votes: 0
        });
      }
    }
    
    server/fixtures.js

    これまで通り、アプリを停止して、meteor resetを実行して、新しいユーザーアカウントを作ります。 それから、投稿が作成された時に2つのプロパティが初期化されるようにします。

    //...
    
    var postWithSameLink = Posts.findOne({url: postAttributes.url});
    if (postWithSameLink) {
      return {
        postExists: true,
        _id: postWithSameLink._id
      }
    }
    
    var user = Meteor.user();
    var post = _.extend(postAttributes, {
      userId: user._id,
      author: user.username,
      submitted: new Date(),
      commentsCount: 0,
      upvoters: [],
      votes: 0
    });
    
    var postId = Posts.insert(post);
    
    return {
      _id: postId
    };
    
    //...
    
    collections/posts.js

    投稿テンプレート

    最初に、投稿セルにupvoteボタンを追加し、投稿のメタデータとして投稿数を表示します:

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn btn-default"></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 btn-default">Discuss</a>
      </div>
    </template>
    
    client/templates/posts/post_item.html
    The upvote button
    The upvote button

    次に、 ユーザーがボタンをクリックしたら、サーバーの upvoteメソッドを呼び出します。:

    //...
    
    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        check(this.userId, String);
        check(postId, String);
    
        var post = Posts.findOne(postId);
        if (!post)
          throw new Meteor.Error('invalid', 'Post not found');
    
        if (_.include(post.upvoters, this.userId))
          throw new Meteor.Error('invalid', 'Already upvoted this post');
    
        Posts.update(post._id, {
          $addToSet: {upvoters: this.userId},
          $inc: {votes: 1}
        });
      }
    });
    
    //...
    
    lib/collections/posts.js

    コミット 13-1

    Added basic upvoting algorithm.

    このメソッドは非常に簡単です。 私たちは、ユーザが、ポストが実際に存在していることを記録されていることを確認するために、 いくつかの防御的なチェックを行います。 その後、ユーザーがすでにポストに投票していないことを再度確認し、いない場合、 投票の合計スコアをインクリメントし、upvotersのセットにユーザーを追加します。

    この最後のステップは、興味深い特別なMongoの演算子を使用しました。 他にも学ぶべき演算子はありますが、これら二つは非常に有用です。 $addToSetは、それがすでに存在していない場合に限り配列プロパティに項目を追加し、 $incは、単純に整数フィールドをインクリメントします。

    ユーザーインターフェースの微調整

    ユーザーがログインしていないか、既にポストをupvotedしている場合、彼らは投票することができません。 UIでこれを反映するために、条件付きでupvoteボタンにdisabledCSSクラスを追加するために ヘルパーを使用します。

    <template name="postItem">
      <div class="post">
        <a href="#" class="upvote btn btn-default {{upvotedClass}}"></a>
        <div class="post-content">
          //...
      </div>
    </template>
    
    client/templates/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/templates/posts/post_item.js

    .upvoteから.upvotableへクラスを変更しているので、クリックイベントハンドラを変更することを忘れないでください。

    Greying out upvote buttons.
    Greying out upvote buttons.

    コミット 13-2

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

    次に、あなたが “1 votes”と、単一の投票がラベル付けされていることに気づくでしょう。 適切にこれらのラベルのプロパティを調整するには時間がかかるかもしれません。 複数化は、複雑なプロセスになる可能性がありますが、今のところは、かなり単純な方法でこれを行います。 我々はどこにでも使用することができ、一般的なSpacebarsヘルパーを作ります。

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

    以前に作成したヘルパーは、適用対象のテンプレートに縛られてきたました。 しかしUI.registerHelperを使用することによって、 任意のテンプレート内で使用することができるグローバルヘルパーを作成しました:

    <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/templates/posts/post_item.html
    Perfecting Proper Pluralization (now say that 10 times)
    Perfecting Proper Pluralization (now say that 10 times)

    コミット 13-3

    Added pluralize helper to format text better.

    "1 vote"と見えることを確認してください。

    よりスマートな投票アルゴリズム

    私たちのupvotingコードは格好良いですが、まだ良くすることができます。 upvote Methodでは、Mongoへの2つの呼び出しを行います。: 一つ目は投稿を取得するため、二つ目は更新するためです。

    二つの問題があります。

    第一に、それは二度データベースに行く分多少非効率的です。 しかし、もっと重要なのは、競合状態が導入されています。次のアルゴリズムを追ってください:

    1. データベースから投稿を取得する。
    2. ユーザーが投票したかどうかを確認する。
    3. 投票していない場合は、ユーザーによる投票を行う。

    同じユーザがステップ1と3の間で再びポストに投票した場合はどうなりますか? 現在のコードでは、二度同じポストに投票することができるという可能性があります。

    ありがたいことに、Mongoは賢くなり、手順1〜3を組み合わせて、 単一のMongoのコマンドにすることが可能です。:

    //...
    
    Meteor.methods({
      post: function(postAttributes) {
        //...
      },
    
      upvote: function(postId) {
        check(this.userId, String);
        check(postId, String);
    
        var affected = Posts.update({
          _id: postId,
          upvoters: {$ne: this.userId}
        }, {
          $addToSet: {upvoters: this.userId},
          $inc: {votes: 1}
        });
    
        if (! affected)
          throw new Meteor.Error('invalid', "You weren't able to upvote that post");
      }
    });
    
    //...
    
    collections/posts.js

    コミット 13-4

    Better upvoting algorithm.

    これは「すべての投稿をidで見つけ、このユーザはまだに投票していなければこの方法でそれらを更新する」 という処理です。 ユーザーはまだ投票していない場合、当然ですがidを持つ投稿を見つけるでしょう。 一方ユーザーが投票した場合、問合せは書類と一致しなくなり、その結果、何も起こりません。

    Latency Compensation

    例えば、あなたがチートや投票のその数を微調整することにより、リストの一番上にあなたの記事のいずれかを送信しようとしたとしましょう:

    > Posts.update(postId, {$set: {votes: 10000}});
    
    Browser console

    (ここで、 postIdはあなたの記事の1つのidです)

    システムに対する遊びとしての図々しい試みは私たちのdeny()コールバックによってキャッチされます。 ( collections/posts.jsです。覚えてます?)、そして、すぐに打ち消されます。

    しかし、慎重に見ればあなたはこの行為でlatency compensationを目撃することができるかもしれません。 それは、それは一瞬で進みますが、投稿は元の位置に戻る前に、一時的にリストの一番上にジャンプします。

    何が起こったのか?ローカルのPostsコレクションでは、updateは何事もなく適用されます。 これは瞬時に起こるのですが、投稿がリストの一番上にジャンプします。 一方、サーバー上で、updateが拒否されていました。 なので、ちょっと後(自分のマシン上でMeteorを実行している場合は、ミリ秒単位です。)に、 サーバは、エラーが返され、ローカルコレクションに元に戻すように指示しました。

    最終結果:サーバが応答するのを待っている間、ユーザーインターフェースの助けにはなりますが、 ローカルコレクションを信頼することはできません。 とすぐに、サーバが戻ってくると変更を拒否したように、 ユーザインタフェースは、それを反映するように適応させれます。

    フロントページの投稿ランキング

    今のところ、投票数に基づいた、投稿ごとにスコアがあるので、 最も良い投稿のリストを表示しましょう。 そうするために、私たちはpostコレクションの、2つのサブスクリプションを管理する方法を見て、 postListテンプレートをさらに少しだけ一般的にします。

    始めるにあたり、異なるソート順の二つのサブスクリプションを持ちたいと思います。 ここでのトリックは、両方のサブスクリプションが、 同じpostsパブリケーションに異なる引数でサブスクライブということです!

    また、ページネーションのための二つの新しいルートnewPostsbestPostsを作成しそれぞれ /new/bestというURLでアクセス可能にします。 (もちろんページング用は/new/5/best/5となります)

    これを行うために、我々はPostsListController拡張し、2つの別個のNewPostsListControllerBestPostsListControllerを作成します。 これは、homenewPostsルートの両方に、 単一の継承されたNewPostsListControllerを与えることによって、 まったく同じルートオプションを再利用できるようになります。 そして、Iron Router がいかに柔軟なかを説明できたと思います。

    それでは、{submitted: -1}PostsListControllerのソートプロパティthis.sortを上書き しましょう。NewPostsListController and BestPostsListControllerを置き換えましょう:

    So let’s replace the {submitted: -1} sort property in PostsListController by this.sort, which will be provided by NewPostsListController and BestPostsListController:

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

    注意点として、一つ以上のルートを持ち、パスはどちらの場合では異なるであろうことから、nextPathロジックをPostsListControllerから出して、 NewPostsControllerBestPostsControllerに入れています。

    加えて、votesによってソートする時、タイムスタンプや _idによって後続のソートを指定し、 順序が完全に指定されていることを確認しました。

    Additionally, when we sort by votes, we have a subsequent sorts by submitted timestamp and then _id to ensure that the ordering is completely specified.

    新しいコントローラでは、安全に、以前のpostsListルートを取り除くことができます。 ただ、次のコードを削除します:

     Router.route('/:postsLimit?', {
      name: 'postsList'
     })
    
    lib/router.js

    ヘッダー内にリンクも追加しましょう:

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
              <span class="sr-only">Toggle navigation</span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
          </div>
          <div class="collapse navbar-collapse" id="navigation">
            <ul class="nav navbar-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 navbar-nav navbar-right">
              {{> loginButtons}}
            </ul>
          </div>
        </div>
      </nav>
    </template>
    
    client/templates/includes/header.html

    最後に、投稿削除のイベントハンドラを更新する必要があります:

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

    これがすべて終わると、私たちは ベスト投稿リストを得ます:

    Ranking by points
    Ranking by points

    コミット 13-5

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

    A Better Header

    これで2つのリストページがあるので、 どちらのリストを現在見ているのか知ることは難しいかもしれません。 そのため、もっとわかりやすくするために、ヘッダーを再検討してみましょう。 ナビゲーション項目上で.activeクラスを設定するために 現在のパスと1つ以上の名前付きルートを使用してheader.jsマネージャとヘルパーを作成します:

    複数の名前付きルートをサポートしたい理由は、 私たちのhomenewPostsルートの両方(URLはそれぞれ// new) に同じテンプレートを使いたいからです。 両方のケースで<LI>タグをアクティブにするので、 activeRouteClassは十分にスマートでなければならないことを意味します。

    <template name="header">
      <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
          <div class="navbar-header">
            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navigation">
              <span class="sr-only">Toggle navigation</span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
              <span class="icon-bar"></span>
            </button>
            <a class="navbar-brand" href="{{pathFor 'home'}}">Microscope</a>
          </div>
          <div class="collapse navbar-collapse" id="navigation">
            <ul class="nav navbar-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 navbar-nav navbar-right">
              {{> loginButtons}}
            </ul>
          </div>
        </div>
      </nav>
    </template>
    
    client/templates/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.getName() === name
        });
    
        return active && 'active';
      }
    });
    
    client/templates/includes/header.js
    Showing the active page
    Showing the active page

    ヘルパーの引数

    私たちは、今までこのパターンを使用していませんでした。 他のSpacebarsのタグのように、テンプレートヘルパータグは引数を取ることができます。

    あなたはもちろん、あなたの関数に特定の名前付き引数を渡すことができつつ、 不特定多数の匿名のパラメータを渡し、関数内でargumentsオブジェクトを呼び出すことで、 それらを取得することができます。

    この最後のケースでは、 あなたはおそらく正規のJavaScript配列にargumentsオブジェクトを変換したいと思うでしょうし、 それでSpacebarsによって最後に追加されたハッシュを取り除くためにpop()を呼び出しました。

    各ナビゲーションアイテムで、activeRouteClassヘルパーはルート名のリストを取り、 その後の経路のいずれかがテスト(対応するURLが現在のパスに等しいかどうか) に合格するかどうかを確認するために、 Underscoreのany()ヘルパーを使用しています。

    もし現在のパスにルートがマッチするなら、any()trueを返します。 最後に、boolean && stringというJavaScriptパターンの利点についてですが、これは false && myStringであればfalseを返しますが、true && myStringであれば myStringを返します。

    コミット 13-6

    Added active classes to the header.

    これでユーザーはリアルタイムで投稿に投票することができるので、 ランキングが変化する度に項目ががページ上で上下することがわかります。 この変化時にアニメーションが滑らかできれば、素晴らしいことではないでしょうか?