Djangoでの開発中にN+1問題を発見して根絶やしにするための方法

N+1問題という人類の敵に立ち向かうための方法について書きます。

## 開発環境

以下の環境を想定します。

* Django2.0以上
* python3系

## N+1問題とは

ここでは詳しく解説しませんので、排除すべき人類の敵と認識してもらえればとりあえず大丈夫です。

## 根絶やし方法

以下のサイクルを回していくことでできると思います。
テストコード書きつつ開発->テストでN+1問題を検知->見つけ出す->絶やす

### 用意するもの

以下の二つです

* [nplusone](https://github.com/jmcarp/nplusone) N+1問題を検出してくれるライブラリです
* [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) Djangoのアプリに様々なデバッグ情報を表示できるツールバーを用意してくれます

また[以前書いた記事](https://qiita.com/d9magai/items/8c4ebb032a6c4829b0e0)の`model`やら`serializer`の大部分をそのまま流用して書いています。横着者ですみません。

### 準備編

用意するもの2つをインストールします。`pip install nplusone django-debug-toolbar`
したらば`app/settings.py`に追記したり`app/urls.py`を編集したりしてこれらライブラリを使えるようにしたり、ツールバーを有効にします。

```app/settings.py

INSTALLED_APPS += [
    'nplusone.ext.django',
    'debug_toolbar',
]

MIDDLEWARE += [
    'nplusone.ext.django.NPlusOneMiddleware',
    'debug_toolbar.middleware.DebugToolbarMiddleware',
]

DEBUG_TOOLBAR_PANELS = [
    'debug_toolbar.panels.versions.VersionsPanel',
    'debug_toolbar.panels.timer.TimerPanel',
    'debug_toolbar.panels.settings.SettingsPanel',
    'debug_toolbar.panels.headers.HeadersPanel',
    'debug_toolbar.panels.request.RequestPanel',
    'debug_toolbar.panels.sql.SQLPanel',
    'debug_toolbar.panels.staticfiles.StaticFilesPanel',
    'debug_toolbar.panels.templates.TemplatesPanel',
    'debug_toolbar.panels.cache.CachePanel',
    'debug_toolbar.panels.signals.SignalsPanel',
    'debug_toolbar.panels.logging.LoggingPanel',
    'debug_toolbar.panels.redirects.RedirectsPanel',
]

if DEBUG:
    def show_toolbar(request):
        return True

    DEBUG_TOOLBAR_CONFIG = {
        "SHOW_TOOLBAR_CALLBACK" : show_toolbar,
    }
```

```app/urls.py
from .views import SightseeingSpotViewSet
from django.conf.urls import include
from django.conf.urls import url
from django.urls import path
from rest_framework import routers
from django.conf import settings

router = routers.DefaultRouter()
router.register(r'^sightseeingspots', SightseeingSpotViewSet)
urlpatterns = [
    url(r'^api/', include(router.urls)),
]

if settings.DEBUG:
    import debug_toolbar
    urlpatterns = [
        path('__debug__/', include(debug_toolbar.urls)),
    ] + urlpatterns
```

簡単に解説しますと、`DEBUG`が`True`の場合にデバッグツールバーを表示する設定と、あとはN+1問題を検知できるようにするための準備をしてます。
それと、テストコードに与える初期データとして[このJSON](https://gist.github.com/d9magai/876cd8347d330c1f94f4f2e02d0034fc)を`app/fixtures.json`という名前で使います。

### テストコード編

さて、以前に書いた伏見稲荷大社がなんとかという記事にて観光名所に関するAPIを作ったはずです。というわけで`/api/sightseeingspots/`で観光地の一覧が取得できるAPIのテストを`app/test.py`に書いてみます。
`settings.NPLUSONE_RAISE`に`True`を設定しているのでN+1問題が発生したら例外がスローされます。

```app/test.py
from django.conf import settings
from django.test import TestCase
import json


class SightseeingSpotsTest(TestCase):

    fixtures = [
        'app/fixtures.json',
    ]

    def setUp(self):
        settings.NPLUSONE_RAISE = True

    def test_api_sightseeingspots(self):
        response = self.client.get('/api/sightseeingspots/')
        self.assertEqual(json.loads(response.content), [
            {
                'id': 1,
                'name': '伏見稲荷大社',
                'postcode': '612-0882',
                'prefecture': '京都府',
                'address': '京都市伏見区深草藪之内町68'
            },
            {
                'id': 2,
                'name': '広島平和記念資料館',
                'postcode': '730-0811',
                'prefecture': '広島県',
                'address': '広島市中区中島町1-2'
            },
            {
                'id': 3,
                'name': '厳島神社',
                'postcode': '739-0541',
                'prefecture': '広島県',
                'address': '廿日市市宮島町1-1'
            },
            {
                'id': 4,
                'name': '東大寺',
                'postcode': '630-8211',
                'prefecture': '奈良県',
                'address': '奈良市雑司町406-1'
            },
            {
                'id': 5,
                'name': '新宿御苑',
                'postcode': '160-0014',
                'prefecture': '東京都',
                'address': '新宿区内藤町11'
            }
        ])
```

書いたら実行してみます。

```bash
$ python manage.py test
```

すると、`nplusone.core.exceptions.NPlusOneError`という例外がスローされてテストが失敗したと思います。ここからが本番です。

### いたぞ！N+1問題だ！○○せ！

さて、`nplusone`のおかげでN+1問題があるということは分かりました。これを無くしていくわけですが、方法としては色々考えられそうです。よく訓練されたDjango開発者ならコードを眺めただけで「ここだな」みたいなことができるかもしれませんが私はその境地に達していないので何かしら手がかりが欲しいです。

そこで私の場合は`django-debug-toolbar`を活用します。これのSQLタブから実行されたSQLを覗くことができます。早速`/api/sightseeingspots/`を開いて見てみます。
![img.png](https://qiita-image-store.s3.amazonaws.com/0/24415/454286d7-de96-4f78-f90e-bb5ef0737f19.png)

これを見ると1回全件取得した後にN回SQLが実行されているのが分かります(ここではN=5)。どうやらここをなんとかすれば良さそうです。

### 解決編

要するにJOINして`prefecture`も全件取得するときに持ってくれば良いわけです。それをDjangoのコードではどう書くのかというと`select_related()`を使います。`app/views.py`を以下のように編集します。

```app/views.py
from .models import SightseeingSpot
from .serializers import SightseeingSpotSerializer
from rest_framework import viewsets


class SightseeingSpotViewSet(viewsets.ModelViewSet):
    queryset = SightseeingSpot.objects.all()
    serializer_class = SightseeingSpotSerializer

    def get_queryset(self):
        return self.queryset.select_related()
```

では答え合わせします。`python manage.py test`でテストを実行して例外がスローされずに無事通過したら、正解と判断します。たぶん通ると思います。

## おわり

というわけでこのやり方でDjangoで開発する際にN+1問題を発見して根絶やしにしていけると思います。N+1問題は見つけ次第○○しましょう。では○○○○、○○○○○。また○○○○○○！