Twitterクローンを作ります #37 パフォーマンス改善

あとはメッセージ機能とプロフィール周りを実装すればほぼ完成なのですが、ホーム画面の表示がかなり遅くなっているのが気になっているので先に修正しておきましょう。

![スクリーンショット 2022-03-29 13.37.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/a20baca1-0a16-6711-be75-e302ecd7d422.png)

47件のツイートの取得で1.91秒はかかり過ぎです。

プロファイラを組み込んでみてみましょう。

```python:server/main.py

import logging

from bson.objectid import ObjectId
from flask import Flask
from flask.json import JSONEncoder

app = Flask(__name__)
app.secret_key = "DUMMY"
app.config["SESSION_COOKIE_NAME"] = "__session"
app.logger.setLevel(logging.INFO)

# プロファイラ
if os.environ["PROFILE"]:
    from werkzeug.middleware.profiler import ProfilerMiddleware
    app.config["PROFILE"] = True
    app.wsgi_app = ProfilerMiddleware(app.wsgi_app, sort_by=['tottime'], restrictions=[20])

class CustomJSONEncoder(JSONEncoder):
    def default(self, obj):
        if isinstance(obj, ObjectId):
            return str(obj)
        return super().default(obj)


app.json_encoder = CustomJSONEncoder

from apis import login, notifications, posts, users

```

本番環境では不要なので "PROFILE" という環境変数に値が指定されているときだけ動くようにしました。
この状態で実行すると、HTTPのアクセスごとに時間の掛かった処理を表示してくれます。

```
PATH: '/api/posts'
         89312 function calls (88096 primitive calls) in 1.892 seconds

   Ordered by: internal time
   List reduced from 517 to 20 due to restriction <20>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      294    1.679    0.006    1.679    0.006 {method 'read' of '_ssl._SSLSocket' objects}
      147    0.010    0.000    0.012    0.000 {built-in method pymongo._cmessage._op_msg}
      147    0.010    0.000    0.010    0.000 {method 'write' of '_ssl._SSLSocket' objects}
      147    0.008    0.000    0.011    0.000 {built-in method bson._cbson.decode_all}
      294    0.007    0.000    1.692    0.006 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/network.py:277(_receive_data_on_socket)
     1176    0.007    0.000    0.012    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/periodic_executor.py:54(open)
      147    0.005    0.000    1.776    0.012 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/server.py:75(run_operation)
        1    0.005    0.005    0.005    0.005 {built-in method zlib.decompress}
      294    0.004    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/topology_description.py:212(logical_session_timeout_minutes)
      147    0.004    0.000    0.008    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/pool.py:1527(return_socket)
     1321    0.003    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/bson/son.py:55(__setitem__)
      294    0.003    0.000    1.684    0.006 /usr/local/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/ssl.py:1230(recv_into)
      147    0.003    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/client_session.py:911(_apply_to)
      147    0.003    0.000    1.860    0.013 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/cursor.py:982(__send_message)
      150    0.003    0.000    1.870    0.012 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/cursor.py:1073(_refresh)
      147    0.003    0.000    1.698    0.012 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/network.py:209(receive_message)
      147    0.003    0.000    0.005    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/cursor.py:131(__init__)
      147    0.003    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/topology_description.py:249(_apply_local_threshold)
      147    0.002    0.000    0.016    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/message.py:314(as_command)
      147    0.002    0.000    0.006    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/pymongo/pool.py:1430(_get_socket)


--------------------------------------------------------------------------------

127.0.0.1 - - [29/Mar/2022 13:37:11] "GET /api/posts HTTP/1.1" 200 -

```

`{method 'read' of '_ssl._SSLSocket' objects}` が何度も呼ばれているので遅いようですね...

mongooseのpopulateに対応するような処理を実現したくて find_one をひたすらしてきましたが、実は `$lookup` というものがあるようです。ドキュメントをちゃんと読んでから実装しないとやはりダメですね...

https://www.mongodb.com/docs/manual/reference/operator/aggregation/lookup/

ということで、APIを修正します。

```python:server/apis/posts.py
...
@app.route("/api/posts", methods=["GET"])
@login_required()
def list_posts():
    user = User._collection.find_one({"_id": ObjectId(session["user"]["_id"])})
    results = [post for post in Post.list_posts(user)]
    return jsonify(results)
...
```

```python:server/models/post.py
...

    @staticmethod
    def _liking_and_retweeting(user_id):
        return [{
            "$lookup": {
                "from": "likes",
                "let": { "post_id": "$_id" },
                "pipeline": [
                    {
                        "$match": {
                            "$expr": {
                                "$and": [
                                    { "$eq": ["$post", "$$post_id"] },
                                    { "$eq": ["$liked_by", user_id] },
                                ]
                            }
                        }
                    },
                    {
                        "$project": {"_id": True}
                    }
                ],
                "as": "liking",
            }
        },
        {
            "$set": {
                "liking": {"$toBool": {"$size": "$liking"}}
            }
        },
        {
            "$lookup": {
                "from": "retweets",
                "let": { "post_id": "$_id" },
                "pipeline": [
                    {
                        "$match": {
                            "$expr": {
                                "$and": [
                                    { "$eq": ["$post", "$$post_id"] },
                                    { "$eq": ["$retweeted_by", user_id] },
                                ]
                            }
                        }
                    },
                    {
                        "$project": {"_id": True}
                    }
                ],
                "as": "retweeting",
            }
        },
        {
            "$set": {
                "retweeting": {"$toBool": {"$size": "$retweeting"}}
            }
        }]

    @classmethod
    def _load_posts(cls, condition, user, sort):
        user_id = user["_id"]

        pipelines = [
            condition,

            # 投稿者
            {
                "$lookup": {
                    "from": "users",
                    "localField": "posted_by",
                    "foreignField": "_id",
                    "as": "posted_by"
                }
            },
            {
                "$unwind": { "path": "$posted_by" }
            },

            # リツイート
            {
                "$lookup": {
                    "from": "posts",
                    "localField": "retweeted_post",
                    "foreignField": "_id",
                    "as": "retweeted_post",
                    "pipeline": [
                        {
                            "$lookup": {
                                "from": "users",
                                "localField": "posted_by",
                                "foreignField": "_id",
                                "as": "posted_by"
                            }
                        },
                        {
                            "$unwind": {
                                "path": "$posted_by",
                                "preserveNullAndEmptyArrays": True
                            }
                        },
                        *cls._liking_and_retweeting(user_id)
                    ]
                }
            }, 
            {
                "$unwind": { 
                    "path": "$retweeted_post", 
                    "preserveNullAndEmptyArrays": True 
                }
            },

            # 返信
            {
                "$lookup": {
                    "from": "posts",
                    "localField": "reply_to",
                    "foreignField": "_id",
                    "as": "reply_to",
                    "pipeline": [
                        {
                            "$lookup": {
                                "from": "users",
                                "localField": "posted_by",
                                "foreignField": "_id",
                                "as": "posted_by"
                            }
                        },
                        {
                            "$unwind": {
                                "path": "$posted_by",
                                "preserveNullAndEmptyArrays": True
                            }
                        },
                        # 表示に利用しないので返信には liking, retweeting は不要
                    ]
                }
            }, 
            {
                "$unwind": { 
                    "path": "$reply_to", 
                    "preserveNullAndEmptyArrays": True 
                }
            },

            *cls._liking_and_retweeting(user_id)
        ]

        if sort:
            pipelines.append(sort)

        return cls._collection.aggregate(pipelines)

    
    @classmethod
    def list_posts(cls, user):
        ids = [i for i in user["following"]]
        ids.append(user["_id"])
        condition = {
            "$match": {
                "posted_by": {
                    "$in": ids
                }
            }
        }
        sort = {
            "$sort": {
                "created_at": pymongo.DESCENDING
            }
        }
        return cls._load_posts(condition, user, sort)

```

かなりエグい感じになったのでかんたんに説明します。

```python
            # 投稿者
            {
                "$lookup": {
                    "from": "users",
                    "localField": "posted_by",
                    "foreignField": "_id",
                    "as": "posted_by"
                }
            },
            {
                "$unwind": { "path": "$posted_by" }
            },
```

この部分は posts が posted_by フィールドに保持しているユーザーIDを用いて、 users テーブルのデータを紐付けています。
`from`が紐付けるテーブル、`localField`が posts 側のフィールド名、`foreignField` が users 側のフィールド名です。

`as`は例えば `"as": "new_user_field"`のようにすれば、結果が `{"posted_by": ObjectId("1111"), "new_user_field": { "_id": ObjectId("1111"), "username": "xxx", ...}, ... }`のようにnew_user_fieldというフィールドにユーザー情報を紐付けられますがもともとの実装と合わせてposted_byを上書きする形で紐付けています。

`$unwind`は`$lookup`の結果が配列になるので先頭要素のみをとりだしてオブジェクトとしてアクセスできるようにしています。

実行時間をみてみましょう。

![スクリーンショット 2022-03-29 16.33.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2536525/4849e92f-56d9-34e2-9987-b493e5839ec4.png)

```
PATH: '/api/posts'
         7798 function calls (7782 primitive calls) in 0.098 seconds

   Ordered by: internal time
   List reduced from 540 to 20 due to restriction <20>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        6    0.062    0.010    0.062    0.010 {method 'read' of '_ssl._SSLSocket' objects}
        2    0.022    0.011    0.023    0.011 {built-in method bson._cbson.decode_all}
        2    0.002    0.001    0.008    0.004 /usr/local/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/json/encoder.py:204(iterencode)
      150    0.001    0.000    0.001    0.000 /usr/local/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/email/utils.py:117(_format_timetuple_and_zone)
      557    0.001    0.000    0.006    0.000 /Users/apollo/private/toitta/server/main.py:17(default)
      150    0.001    0.000    0.001    0.000 {method 'timetuple' of 'datetime.datetime' objects}
      150    0.001    0.000    0.001    0.000 {method 'replace' of 'datetime.datetime' objects}
      407    0.000    0.000    0.001    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/bson/objectid.py:248(__str__)
      150    0.000    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/werkzeug/http.py:965(http_date)
      413    0.000    0.000    0.000    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/bson/objectid.py:60(__init__)
        2    0.000    0.000    0.000    0.000 {built-in method pymongo._cmessage._op_msg}
      150    0.000    0.000    0.004    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/flask/json/__init__.py:42(default)
      150    0.000    0.000    0.002    0.000 /usr/local/Cellar/python@3.9/3.9.6/Frameworks/Python.framework/Versions/3.9/lib/python3.9/email/utils.py:155(format_datetime)
      150    0.000    0.000    0.001    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/werkzeug/_internal.py:312(_dt_as_utc)
     1586    0.000    0.000    0.000    0.000 {built-in method builtins.isinstance}
      416    0.000    0.000    0.000    0.000 {method 'decode' of 'bytes' objects}
       30    0.000    0.000    0.000    0.000 {built-in method builtins.getattr}
      407    0.000    0.000    0.000    0.000 {built-in method binascii.hexlify}
        1    0.000    0.000    0.000    0.000 /Users/apollo/private/toitta/server/server-env/lib/python3.9/site-packages/flask/sessions.py:185(get_cookie_domain)
        2    0.000    0.000    0.000    0.000 {method 'write' of '_ssl._SSLSocket' objects}


--------------------------------------------------------------------------------

127.0.0.1 - - [29/Mar/2022 16:21:41] "GET /api/posts HTTP/1.1" 200 -


```

DBへの問い合わせをまとめて実行できるようになり、1.91秒掛かっていた `GET /posts` が 117ms に短縮できました。

プロフィール画面の"いいね"はソートがちょっと面倒なので今回は放置しますが、"ツイート"と"ツイートと返信"、あと"検索"も同じ形にすぐ修正できそうなのでこれらも対応しておきます。