Twitterクローンを作ります #14 いいね機能(1)

# DB

ベースにしたudemyの講座では、いいね機能を実現するのに、users.likes にユーザーがいいねした投稿のIDを配列で持ち、posts.liked に投稿をいいねしたユーザーのIDを配列で持つ、という設計でした。

シンプルである、という意味ではいいのですがデータが増えると破綻しそうです。

いいねに関して実現したい機能は以下のようになると思います。

- あるユーザーがいいねした投稿を、いいねした日時があたらしい順に表示する
- ある投稿をいいねしたユーザーを、いいねした日時があたらしい順に表示する
- タイムラインに表示する投稿それぞれにいいねされた件数を表示する。
- タイムラインに表示する投稿のうち、ログインユーザーがいいねした投稿はボタンをハイライトする。

これを実現するために、users.likes, posts.likedではなく、likesというテーブルを導入しましょう。

```python:server/models/like.py
from bson.objectid import ObjectId
from database import db

from datetime import datetime


class Like:
    _collection = db['likes']

    def __init__(self,
                post: str,
                posted_at: datetime,
                liked_by: str,
                liked_at: datetime = None):
        self.post = post
        self.posted_at = posted_at
        self.liked_by = liked_by
        if not liked_at:
            liked_at = datetime.now()
        self.liked_at = liked_at

    def create(self):
        data = vars(self)
        data['post'] = ObjectId(data['post'])
        data['liked_by'] = ObjectId(data['liked_by'])
        return self._collection.insert_one(data)
```

このテーブルを用いて、先程のそれぞれを解決します。

- あるユーザーがいいねした投稿を、いいねした日時があたらしい順に表示する
→ (liked_by, liked_at) のインデックスで検索

- ある投稿をいいねしたユーザーを、いいねした日時があたらしい順に表示する
→ (post, liked_at) のインデックスで検索

- タイムラインに表示する投稿それぞれにいいねされた件数を表示する。
→ posts.like_count に いいね件数を保持しておく

- タイムラインに表示する投稿のうち、ログインユーザーがいいねした投稿はボタンをハイライトする。
→ (liked_by, posted_at) のインデックスで検索

ということで、likesには3つの複合インデックスが必要になります。
あと、ある人が同一の投稿を何回もいいねできるのはおかしいのでユニーク制約もつけておきます。

(複合ではなく liked_by と post にそれぞれはっておけば十分では？という気が若干しますが一旦複合でいってみましょう)

```
$ python

>>> from models.like import Like
>>> import pymongo
>>> Like._collection.create_index([('liked_by', pymongo.ASCENDING), ('liked_at', pymongo.DESCENDING)], name='liked_by__liked_at')
>>> Like._collection.create_index([('post', pymongo.ASCENDING), ('liked_at', pymongo.DESCENDING)], name='post__liked_at')
>>> Like._collection.create_index([('liked_by', pymongo.ASCENDING), ('posted_at', pymongo.DESCENDING)], name='liked_by__posted_at')
>>> Like._collection.create_index([('post', pymongo.ASCENDING), ('liked_by', pymongo.DESCENDING)], name='post__liked_by', unique=True)

>>> Like._collection.index_information()
{'_id_': {'v': 2, 'key': [('_id', 1)]}, 'liked_by__liked_at': {'v': 2, 'key': [('liked_by', 1), ('liked_at', -1)]}, 'post__liked_at': {'v': 2, 'key': [('post', 1), ('liked_at', -1)]}, 'liked_by__posted_at': {'v': 2, 'key': [('liked_by', 1), ('posted_at', -1)]}, 'post__liked_by': {'v': 2, 'key': [('post', 1), ('liked_by', -1)], 'unique': True}}
```

インデックスが設定できました。

# APIの実装

```python:server/apis/posts.py
from flask import jsonify, request, session
from bson.objectid import ObjectId
import pymongo
from models.like import Like
from models.post import Post
from models.user import User
from main import app
from apis import login_required

...


@app.route('/api/posts', methods=['GET'])
@login_required()
def list_posts():
    results = []
    for post in Post._collection.find(sort=[('created_at', pymongo.DESCENDING)]):
        results.append(_populate(post))
    return jsonify(results)


@app.route('/api/posts/<post_id>/like', methods=['POST'])
@login_required()
def like_post(post_id):
    post = Post._collection.find_one({'_id': ObjectId(post_id)})
    if not post:
        return jsonify({'error': { 'message': '投稿が存在しません' }}), 404
    user_id = session['user']['_id']
    like = Like._collection.find_one({'post': ObjectId(post_id), 'liked_by': ObjectId(user_id)})
    if like:
        return jsonify(_populate(post))
    else:
        like = Like(post=post_id,
                    posted_at=post['posted_at'],
                    liked_by=user_id)
        like.create()
        like_count = Like._collection.find({'post': ObjectId(post_id)}).count()
        post = Post._collection.find_one_and_update(
            {'_id': ObjectId(post_id)}, 
            {'$set': {'like_count': like_count}},
            return_document=pymongo.ReturnDocument.AFTER)
        return jsonify(_populate(post))


def _populate(post):
    post['_id'] = str(post['_id'])
    posted_by = User._collection.find_one({'_id': post['posted_by']})
    if posted_by:
        posted_by['_id'] = str(posted_by['_id'])
        post['posted_by'] = posted_by
    return post
```

いいね と いいね取り消し は別APIに割り当てようと思います。
トランザクションをかけないとLikeは追加したけれどlike_countが更新されないということが起こり得ますがあまり厳密さが求められるものでも無いと思うのでここはそのままにしておきます。

# 画面

まずはアクションを定義します。

```javascript:client/src/store/index.js
import { createStore } from 'vuex'
import router from '@/router';

import axios from 'axios';
import UIkit from 'uikit';

export default createStore({
...
  actions: {
...
    },
    likePost (_, post) {
      axios.post(`/api/posts/${post._id}/like`).then(resp => {
        console.log(resp.data);
      });
    }
  },
  modules: {
  }
})
```

アクションを呼び出す処理も記載します。

```vue:client/src/components/PostPanel.vue
<template>
    <div class="uk-margin-right">
        <div class="uk-flex uk-flex-top">
            <img :src="url" class="uk-border-circle avatar">
            <div class="uk-flex uk-flex-left uk-flex-column uk-margin-left uk-flex-1">
                <div class="uk-flex uk-flex-middle uk-flex-row">
                    <span class="uk-text-bold">{{ post.posted_by.display_name }}</span>
                    <span class="uk-margin-small-left uk-text-muted">@{{ post.posted_by.username }}</span>
                    <span class="uk-margin-small-left uk-text-muted">{{ post.created_at }}</span>
                    <span class="uk-flex-1"></span>
                    <span uk-icon="icon: close; ratio: 0.8"></span>
                </div>
                <div class="uk-flex uk-margin-small-top">
                    {{ post.content }}
                </div>
                <div class="uk-flex uk-flex-between uk-margin-small-top">
                    <span class="uk-flex-1" uk-icon="comment"></span>
                    <span class="uk-flex-1" uk-icon="bolt"></span>
                    <span class="uk-flex-1" uk-icon="heart" @click="onLikeClick"></span>
                </div>
            </div>
        </div>
        <hr>
    </div>
</template>

<script>
import { imageUrl } from '@/functions/avatar.js';
import { useStore } from 'vuex';

export default {
    props: {
        post: Object
    },
    setup(props) {
        const store = useStore();

        const url = imageUrl(props.post.posted_by);

        const onLikeClick = () => {
            store.dispatch('likePost', props.post);
        };
        return {
            url,
            onLikeClick
        };
    },
}
</script>
...
```

これでいいねすることができるようになりました。