ListViewの使い方をマスターしよう

ListViewを使いこなすクラスベースビューをマスターしよう

↓↓↓はじめて講座を受ける方はこちら↓↓↓
開発環境を作ろう

わいへい
わいへい

やったーーーー!!

Djangoのアプリができたぞ〜〜〜〜!!!!

日記アプリのトップページ
ウサギ
ウサギ

チュートリアルアプリかな?

わいへい
わいへい

どうだ! いいだろ!

この日記アプリさえあれば、いちいちQiitaだのZennだのに書かなくても大丈夫。

わいへい
わいへい

どんどん機能追加していって、

ゆくゆくはQiitaを超えるプラットフォーマーになる!

ウサギ
ウサギ

うん、根拠なき自信って大切だよな。。。

クラスベースビュー使った?

ウサギ
ウサギ

ところでこの画面、クラスベースビューで作ったのか?

わいへい
わいへい

クラスベースビュー? なんだそれ??

ウサギ
ウサギ

クラスベースビューは、少ない記述でCRUDの機能を作れる、汎用クラスだ

ウサギ
ウサギ

今のお前のviews.pyを見てみよう

from django.shortcuts import render

# Create your views here.
from .models import Post

# Create your views here.
def index(request):
  return render(request, 'index.html', {
    'posts': Post.objects.all(),
  })
ウサギ
ウサギ

これは関数ベースビューというやつだ

ウサギ
ウサギ

関数ベースビューは、自由に記述できる代わりに

ウサギ
ウサギ

実装者によって書き方が違ったり、いろんな画面で同じような処理を繰り返し書いたり

ウサギ
ウサギ

どこに何を書いてもいいが故に、冗長になりがちだ

ウサギ
ウサギ

クラスベースビューを使うと、CRUDでよく使う機能を簡単に実装できる

ウサギ
ウサギ

例えばXXの機能であればこう書く、みたいなお作法が決まっているので、

ウサギ
ウサギ

オレオレ実装になりにくい

ウサギ
ウサギ

大人数で開発したときに、実装スタイルが統一されるってわけ

わいへい
わいへい

え〜、めんどくさいな。関数ベースビューにゴリゴリ書いてもよくない??

ウサギ
ウサギ

うむ。確かにめんどくさい場面はあるが

ウサギ
ウサギ

似たような一覧画面

ウサギ
ウサギ

似たような編集画面

ウサギ
ウサギ

似たような新規作成画面

ウサギ
ウサギ

こんなもんをコピペで作りまくるのはナンセンスだし

ウサギ
ウサギ

実装者によっては書き方がしっちゃかめっちゃかになる

ウサギ
ウサギ

そんなとき、画面実装に一定の秩序を与えてくれるのがクラスベースビューだ

ウサギ
ウサギ

簡単ではないが、やってみようぜ

わいへい
わいへい

簡単じゃないんだ。。。

完成形の確認

ウサギ
ウサギ

今回は一覧の実装ということで、ListViewというクラスベースビューを使ってみる

ウサギ
ウサギ

ListViewを使って機能を加え、デザインもいい感じにしたのが下記だ

ListViewを使った日記一覧
わいへい
わいへい

え。。。めっちゃいい感じになってない?

ウサギ
ウサギ

最初がクソだからいい感じに見えるだけだ

わいへい
わいへい

悪口言われた??

ウサギ
ウサギ

下記の機能を実装した

  • カテゴリーと表示件数をFormで絞り込む
  • ログイン済みの場合各日記に編集リンクをつける
  • ページネーション

関数ベースビューでこの機能をまともに作ろうとすると、めちゃくちゃめんどくさい。

ListViewを使うとどれだけ分かりやすく、簡単に書けるかやってみるぞ!

ベースアプリの準備

リポジトリのクローン

ウサギ
ウサギ

まずは日記アプリのリポジトリをクローンする

GitHub - yheihei/base_diary: ウサでも分かる中級Django講座の教材
ウサでも分かる中級Django講座の教材. Contribute to yheihei/base_diary development by creating an account on GitHub.
$ git clone https://github.com/yheihei/base_diary.git
$ cd base_diary

マイグレーションと初期データ投入

ウサギ
ウサギ

次にマイグレーションとloaddataによる初期データの投入だ

$ python manage.py migrate
$ python manage.py loaddata initial.json

この状態でhttp://127.0.0.1:8000/を開くと。。。

わいへい
わいへい

よし、日記一覧が表示されたね

日記の記事を追加したり、編集したりする場合はhttp://127.0.0.1:8000/admin/diary/post/に入って行う。

ログインできるユーザー名とパスワードは下記だ。

UserName: yhei
Password: password
日記編集画面

完成版ソースだけ見る場合

ウサギ
ウサギ

ちなみに完成形を書いたブランチも用意しておいた。

答えだけ先に見たいやつは下記コマンドでブランチをチェックアウトしてくれ

GitHub - yheihei/base_diary at feature/#13
ウサでも分かる中級Django講座の教材. Contribute to yheihei/base_diary development by creating an account on GitHub.
# 完成版ブランチを見たい場合はこちら
$ git fetch && git checkout feature/#13

ベースアプリの解説

ウサギ
ウサギ

もう一度この日記アプリの画面表示を見てみよう。

トップページにアクセスすると、こんな画面が出てくる

クソ日記アプリ

仕様をまとめるとこんな感じだ。

  • トップページにアクセスすると、日記一覧が表示される
  • 各日記の表示項目は
    • タイトル
    • 投稿者
    • 本文
    • 投稿日
    • カテゴリー

モデル定義

ウサギ
ウサギ

モデルはこんな感じだな。

from django.db import models
from django.db.models.deletion import CASCADE

from django.contrib.auth.models import AbstractUser


# Create your models here.
class User(AbstractUser):
  pass

class Post(models.Model):
  id = models.AutoField(primary_key=True)
  user = models.ForeignKey(User, on_delete=CASCADE)
  title = models.CharField(max_length=2048)
  body = models.TextField()
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)
  categories = models.ManyToManyField(
    'Category',
    blank=True,
    related_name="posts",
  )

class Category(models.Model):
  name = models.CharField(max_length=1024)
  slug = models.CharField(max_length=1024)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  def __str__(self) -> str:
    return f'{self.name}'
ウサギ
ウサギ

Postモデル(投稿)と、Userモデル(投稿者)が1対1で紐つく。

ウサギ
ウサギ

さらに、PostモデルとCategoryモデルが多対多で紐ついてる。

Viewとテンプレートの定義

ウサギ
ウサギ

views.pyはこんな感じ

from django.shortcuts import render

# Create your views here.
from .models import Post

# Create your views here.
def index(request):
  return render(request, 'index.html', {
    'posts': Post.objects.all(),
  })
ウサギ
ウサギ

取得する記事をPost.objects.all()で取得して、

index.htmlのテンプレート側に渡し

<html>
  <head>
    <title>とあるエンジニアの日記帳</title>
  </head>
  <body>
    {% load static %}
    <h1>とあるエンジニアの日記帳</h1>
    {% for post in posts %}
      <div>
        <h2>{{ post.title }}</h2>
        <div>投稿 : {{ post.user }}</div>
        <div>{{ post.body |safe }}</div>
        <div>{{ post.updated_at }}</div>
        <div>カテゴリー : 
        {% for category in post.categories.all %}
          <span>{{ category }},</span>
        {% endfor %}
        </div>
      </div>
    {% empty %}
      <p>No Diaries.</p>
    {% endfor %}
  </body>
</html>
ウサギ
ウサギ

トップページのテンプレートで表示するってだけの単純な構造だ

クラスベースビューで機能を作ってみる

一覧表示を簡単に作る

まず最初に、一覧表示を作ってみる。

diary/views.py に下記を追記する。

from django.views.generic import ListView


class PostListView(ListView):
  model = Post

ListViewクラスを継承したPostListViewクラスを作る。

model変数に、Postモデルを指定するだけだ。

これだけで、Postモデルに紐ついたレコードを全て持ってきてくれる。

次にdiary/urls.pyを下記のようにし、トップページを開いたら、PostListViewを呼び出すようにする。

from django.urls import path
from . import views
from diary.views import PostListView

app_name = "diary"
urlpatterns = [
  # path("", views.index, name="index"),  # コメントアウト
  path("", PostListView.as_view(), name="index"),  # 追加
]

最後に、PostListViewで表示するテンプレートを作る。

ListViewクラスを継承すると、紐つくテンプレートは下記の規則で自動的に決まる。

class PostListView(ListView):
  model = Post
  # テンプレートは templates/アプリ名/クラス名(末尾のViewを除く).html が選ばれる
  # この場合は templates/diary/post_list.html

ということで、templates/diary/post_list.htmlを作って、下記のように記述する。

<html>
  <head>
    <title>とあるエンジニアの日記帳</title>
  </head>
  <body>
    {% load static %}
    <h1>とあるエンジニアの日記帳</h1>
    {% for post in object_list %}
      <div>
        <h2>{{ post.title }}</h2>
        <div>投稿 : {{ post.user }}</div>
        <div>{{ post.body |safe }}</div>
        <div>{{ post.updated_at }}</div>
        <div>カテゴリー : 
        {% for category in post.categories.all %}
          <span>{{ category }},</span>
        {% endfor %}
        </div>
      </div>
    {% empty %}
      <p>No Diaries.</p>
    {% endfor %}
  </body>
</html>

デフォルトのtemplates/index.htmlとほぼ同じだが、下記だけが違う。

    <h1>とあるエンジニアの日記帳</h1>
    {% for post in object_list %}

for文でcontextから記事一覧を取得するところだ。

ListViewで指定したモデルのレコードがobject_listという変数に自動的に入ってくる。

このobject_listというのは、context_object_nameで指定することで任意の変数名に変更可能だ。

class PostListView(ListView):
  model = Post
  context_object_name = 'posts'  # object_listからpostsという変数に変える
    <h1>とあるエンジニアの日記帳</h1>
    {% comment %} PostListViewで指定した変数で取得 {% endcomment %}
    {% for post in posts %}

こちらの方が可読性は高くなる

特に理由がなければ、context_object_nameを指定するほうが良いだろう。

この状態で、アプリを開いてみると。。。

わいへい
わいへい

これだけでちゃんと表示された。。。

投稿の取得方法を変更する

ただこれだけで実装終わるわけないじゃん?

普通の開発ってもっと面倒なこといっぱいあるよな??

こっからは実践的な機能を作ってみて、それも全部ListViewでできるぜ?? 便利だぜ?? っていうのを説明していくぞ。

作成日の新しいもの順で並び替える

まずは、日記を新しいもの順で並べ替えてみよう。

モデルからレコードを取得する際に、下記のようにクエリを指定すればいい。

class PostListView(ListView):
  ...
  ...
  # querysetで取得するものの詳細を決める
  queryset = Post.objects.order_by(
    '-created_at'  # 作成日の新しいものから並べる
  )

投稿の取得方法をさらに改善する(N+1問題の解消)

あとは、このアプリ、潜在的な問題をはらんでいる。

わいへい
わいへい

えっ!!

テンプレートの下記を見てくれ。

    {% for post in posts %}
      <div>
        ...
        {% comment %} ここでuserテーブルの情報を取得するSQL発行 {% endcomment %}
        <div>投稿 : {{ post.user }}</div>
        ...
        <div>カテゴリー : 
        {% for category in post.categories.all %}
          {% comment %} ここでcategoryテーブルの情報を取得するSQL発行 {% endcomment %}
          <span>{{ category }},</span>
        {% endfor %}

コメント部分に注目してくれ。

それぞれ forループの中で userテーブルへのアクセス、categoryのforループの中でcategoryテーブルへのアクセスが発生する。

極端な例を挙げる。

記事が1000件あったとして、その1000件に100件ずつカテゴリーが付いているとしたら。

呼ばれるSQL数は

    {% for post in posts %}
      # ★記事1000件分ループ
      <div>
        ...
        # ★ここで1件SQLが呼ばれる
        <div>投稿 : {{ post.user }}</div>
        ...
        {% for category in post.categories.all %}
          # ★ここで100件SQLが呼ばれる
          {% comment %} ここでcategoryテーブルの情報を取得するSQL発行 {% endcomment %}
          <span>{{ category }},</span>
        {% endfor %}

1000×1(ユーザー) + 1000×100(カテゴリー) = 101000 SQL呼ばれることになる。

するとどうなると思う?

わいへい
わいへい

一覧表示がめっちゃ遅くなる。。。??

その通り。いわゆるN+1問題ってやつだ。

これを防ぐには、Postを取得するクエリであらかじめテーブルをJOINする必要がある。

こんな感じだ。

class PostListView(ListView):
  model = Post
  context_object_name = 'posts'
  # querysetで取得するものの詳細を決める
  queryset = Post.objects.order_by(
    '-created_at'  # 作成日の新しいものから並べる
  ).select_related(
    'user',  # あらかじめuserテーブルをjoinしておく
  ).prefetch_related(
    'categories',  # あらかじめcategoryテーブルをjoinしておく
  )

ListViewを使っていても、クエリ指定は自由にできるため、問題なく対応できる。

contextを追加する

次に、下記の仕様を実現する。

・ログイン済みの場合各日記に編集リンクをつける

XX一覧とか言っても、たいていの場合モデルのレコードだけ出して終わるのは稀だ。

付加情報をつける必要が出てくる。

つーことでモデルの情報に応じた付加情報をつける方法を学ぶ。

やり方は簡単で、ListViewのget_context_dataという関数をオーバーライドして設定する。

from django.urls import reverse


class PostListView(ListView):
  ...
  context_object_name = 'posts'
  # querysetで取得するものの詳細を決める
  queryset = Post.objects.order_by(
    '-created_at'  # 作成日の新しいものから並べる
  ).select_related(
    'user',  # あらかじめuserテーブルをjoinしておく
  ).prefetch_related(
    'categories',  # あらかじめcategoryテーブルをjoinしておく
  )
  ...
  def get_context_data(self, **kwargs):
    # 継承元(ListView)のget_context_dataを呼んで、contextを取得
    context = super().get_context_data(**kwargs)
    # ログインしていたら、postに編集用のURLを付与する
    if self.request.user.is_authenticated:
      posts = context['posts']  # このpostsにはquerysetで指定したPostのデータが既に入っている
      for post in posts:
        # adminの編集リンクをpostのidから作成する
        post.edit_url = reverse(f'admin:{post._meta.app_label}_{post._meta.model_name}_change', args=[post.id] )
      context['posts'] = posts
    return context  # 最後に編集したcontextをreturnしてやる

querysetで指定したものを取得した後で、contextを編集できる。

これで、context[‘posts’]内のpostには、edit_urlという日記の編集リンクが付いた。

これをテンプレート側のpostの部分で表示してやる。

    {% for post in posts %}
      <div>
        ...
        ...
        <div>カテゴリー : 
        {% for category in post.categories.all %}
          <span>{{ category }},</span>
        {% endfor %}
        </div>
        {% comment %} 編集リンクを追加 {% endcomment %}
        {% if post.edit_url %}
          <a href="{{ post.edit_url }}">編集</a>
        {% endif %}
      </div>
    {% empty %}

トップページを表示してみると。。。

編集ページに遷移する
わいへい
わいへい

編集リンクが現れた!!

ページネーションを実装する

次は、ページネーションを実装するぞ。

ListViewにはページネーションの仕組みが最初から入っている。

少ないコード量でページング処理ができるようになる。

まずは、1ページあたり、2件の日記に絞り込んでみる。

diary/views.pyに下記を追加するだけだ。

class PostListView(ListView):
  ...
  ...
  # ページネーション
  paginate_by = 2

これだけで、記事が全件表示ではなく2件表示に変更される。

querysetをいじらなくても勝手にやってくれるのがポイントだ。

paginate_by=2を設定しただけで2件表示になる

さらに、今何ページかの表示、次のページへ遷移するリンクを画面に表示してみるぞ。

テンプレートの末尾に下記を入れるだけだ。

    ...
    {% empty %}
      <p>No Diaries.</p>
    {% endfor %}
    <ul class="pagination">
      {% comment %} 前へ {% endcomment %}
      {% if page_obj.has_previous %}
        <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">前へ</a></li>
      {% endif %}

      {% comment %} 間のページ {% endcomment %}
      {% for i in page_obj.paginator.page_range %}
        {% if page_obj.number == i %}
          <li class="page-item active"><a class="page-link">{{ i }}</a></li>
        {% else %}
          <li class="page-item"><a class="page-link" href="?page={{ i }}">{{ i }}</a></li>
        {% endif %}
      {% endfor %}

      {% comment %} 次へ {% endcomment %}
      {% if page_obj.has_next %}
        <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">次へ</a></li>
      {% endif %}
    </ul>
  </body>
</html>

page_objというオブジェクトに、ページネーション関連の情報が入ってくる。

{% if page_obj.has_previous %}

で、現在のページの前のページがあるか取得できる。

page_obj.previous_page_number

で、前のページの番号を取得。

{% for i in page_obj.paginator.page_range %}

で、間のページ数のリストが取得できる。ここをfor文で回せば、間のページ数のリンクが作れる。

{% if page_obj.has_next %}

で、現在のページの次のページがあるかどうか。

page_obj.next_page_number

で次のページのページ番号が取れる。

実際に遷移できるか確認してみると。。。

ページネーション機能
わいへい
わいへい

ページネーションできてる〜〜〜!!

http://127.0.0.1:8000/?page=1のように、pageというクエリストリングをつけてページ番号を指定しているが、

下記のようにpage_kwargを指定すれば、任意のクエリストリングに変更できる。これも覚えておいてくれ。

class PostListView(ListView):
  ...
  # ページネーション
  paginate_by = 2
  page_kwarg = 'page'  # 未指定でも良い。デフォルトは'page'

さらに。

1ページに表示する件数も変更できるようにしてみる。

↑表示件数の絞り込み部分のベースとなるやつだ。

通常、下記のpaginate_byの値で表示件数は固定されてしまうが、

class PostListView(ListView):
  ...
  # ページネーション
  paginate_by = 2

これを動的に変更する仕組みがある。

例えば、URLにper_page=数値というクエリストリングがついたときに、per_pageで指定された値に、paginate_byを変えてみる。

get_paginate_byメソッドをオーバーライドすることで可能だ。

  def get_paginate_by(self, queryset):
    '''
    per_pageをクエリによって動的に変える
    Notes
    -----
    https://github.com/django/django/blob/stable/3.2.x/django/views/generic/list.py
    '''
    # 継承元のget_paginate_byを読んで今のpaginate_byを取得
    # なくても良いが、継承元の関数は呼ぶのが定石。継承元で必要な処理をおこなっている場合がある
    paginate_by = super().get_paginate_by(queryset)
    # per_pageをリクエストのURLから取得
    if self.request.GET.get('per_page'):
      # paginate_byを指定された値に変更
      paginate_by = int(self.request.GET.get('per_page'))
    # 変更したpaginate_byを返却する
    return paginate_by

get_paginate_by中でpaginate_byを変更してやればOKだ。

http://127.0.0.1:8000/?per_page=1 にアクセスしてみると。。。

わいへい
わいへい

1ページ1件になった!!

動的な検索を行う

次はカテゴリーで日記を絞り込めるようにしてみるぞ。

category=スラグ名の形式でクエリストリングがついたときに、そのスラグのカテゴリーが付与された日記だけを持ってくる実装だ。

# スラグがdiaryのカテ