python / Django
作成日:2020/3/6 更新日:2020/3/7

DjangoでGCPを活用した実践的な開発 Part2 [ 3.x対応 ]

Djangoを使った実践的な開発について、今回はモデルとビューの実装を継続的に開発する上で無駄なく、再利用しやすいように実装する方法について、筆者が用意したGoogle Cloud Platformに最適化したDjangoのGitコードを使いながら、ユースケースを挙げて解説しています。

サービス開発における実践的なモデル設計

前回はライブラリとsettings.pyの中身に触れて、Djangoの開発を効率良く行う方法について触れました。今回からはGitコードのアプリケーションを動かしながら進めていきましょう。手元に無い方は以下からクローンしてください。

ksh3/django-startproject
Contribute to ksh3/django-startproject development by creating an account on GitHub.

まだ環境を準備していない方は、以下のコマンドで起動、データ確認用のSUを作成しましょう。

cd django-startproject
docker-compose up -d
docker ps
__ksh__@macbook-pro django-startproject % docker ps
CONTAINER ID        IMAGE                  COMMAND                  CREATED             STATUS              PORTS                    NAMES
04b7427e19d5        postgres:11.6-alpine   "docker-entrypoint.s…"   45 hours ago        Up 45 hours         0.0.0.0:5432->5432/tcp   django-startproject_postgres_1

# 起動していない方は5432ポートがなんらかの理由で使えなかったので、5432で起動している別のサービスがあれば停止して再度実行してください

# pythonの仮想環境を起動する前に環境変数を用意しましょう

# .env
-----------------------------------
DEBUG=1
DATABASE_NAME=docker
DATABASE_USER=docker
DATABASE_PASSWORD=docker
DATABASE_HOST=127.0.0.1
GS_PROJECT_ID={あなたのGCPプロジェクトID}
GS_BUCKET_NAME={あなたのGCSバケット名}
-----------------------------------

#環境変数が用意できたら仮想環境を起動
__ksh__@macbook-pro django-startproject % pipenv shell

(django-startproject) __ksh__@macbook-pro django-startproject % ./manage.py createsuperuser
Username: admin
Email address: admin@example.com
Password: 
Password (again): 
This password is too short. It must contain at least 8 characters.
This password is too common.
Bypass password validation and create user anyway? [y/N]: y
Superuser created successfully.

# 最後にアプリケーションを起動
(django-startproject) __ksh__@macbook-pro django-startproject % ./manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
March 06, 2020 - 15:19:45
Django version 3.0.4, using settings 'src.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

open http://127.0.0.1:8000/

このように表示されていれば起動しています。エラー画面が出ている場合はPostgreSQLか起動自体が失敗しているので、ログを確認してみましょう。

メソッドの実装はDjangoのshellを使って、IPythonで検証しながらすすめる

manage.pyにはshellというコマンドがありまして、通常プロジェクトのPythonパスを解決した状態のインタラクティブモードになります。
ただし、プロジェクトの仮想環境にIPythonのライブラリが入っているとIPythonに変わります。これがとても便利で、以下のようにファイルに変更があったら自動で読み込みし直すフラグをセットしてあげれば、モデルのメソッドの確認をすぐにできます。

(django-startproject) __ksh__@macbook-pro django-startproject % ./manage.py shell
Python 3.7.6 (default, Jan 21 2020, 07:57:30) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.13.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: %load_ext autoreload                                                                                                                                                                                   

In [2]: %autoreload 2       

ユーザークラスは抽象ユーザークラスを継承しておく

すでにモデルに抽象ユーザークラスを継承したユーザーのモデルがコーディングされていると思いますが、後々、ユーザーをカスタムしたい場合、マイグレーションで面倒になってしまうので、最初からなにもフィールドの変更、追加が無くても継承させておいたほうがよいです。
ただ、継承させてしまうとそれが影響して、管理画面のユーザーデータのパスワード部分がBcryptのハッシュ値が表示されて、パスワード変更できなくなってしまいます。
ですので、以下のようにadmin.pyを編集しましょう。

from django.contrib.auth.admin import UserAdmin
from django.utils.translation import gettext_lazy as _

from .models import User


@admin.register(User)
class CustomUserAdmin(UserAdmin, admin.ModelAdmin):
    search_fields = ('last_name', 'first_name')
    fieldsets = (
        (None, {'fields': ('username', 'password')}),
        (_('Personal info'),
            {'fields':
                (
                    ('last_name', 'first_name'),
                    'email',
                )
             }
         ),
        (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser',
                                       'groups', 'user_permissions')}),
        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
    )
    list_display = (
        'id',
        'username',
        'email',
        'is_staff',
        'is_active',
        'last_name',
        'first_name',
    )
    list_filter = (
        'last_login',
        'is_superuser',
        'is_staff',
        'is_active',
    )
    raw_id_fields = ('groups', 'user_permissions')
    date_hierarchy = 'date_joined'

モデルのいろんなメソッド、疑似プロパティを使い分ける

モデルのメソッド、疑似プロパティについて、長くDjangoを実務で使っていて自然と落ち着いたルールみたいなものを紹介させていただきます。これは良い悪いというのではく、規則を持つことが重要なので、参考程度に読まれてください。

from __future__ import annotations

import hashlib
import logging

from django.contrib.auth.models import AbstractUser
from django.core import validators
from django.db import models
from django.db.models.query import QuerySet
from django.utils.translation import gettext_lazy as _

logger = logging.getLogger(__name__)


class User(AbstractUser):

    class Meta:
        verbose_name = _('User')
        verbose_name_plural = _('Users')
        ordering = ['-id']

    class Gender(models.TextChoices):
        MALE = 'male', _('Male')
        FEMALE = 'female', _('Female')
        UNANSERED = 'unansered', _('Unansered')

    nickname = models.CharField(
        _("Nickname"), max_length=255, blank=True,
        validators=[
            validators.MinLengthValidator(4), validators.MaxValueValidator(255)
        ]
    )
    gender = models.CharField(
        max_length=9,
        choices=Gender.choices,
        default=Gender.UNANSERED,
        validators=[
            validators.MinLengthValidator(4), validators.MaxValueValidator(9)
        ]
    )
    birthday = models.DateField(
        _('Birthday'), blank=True, null=True,
        validators=[
            validators.MinLengthValidator(4), validators.MaxValueValidator(255)
        ])

    # プロパティのあとは__str__メソッドを定義
    def __str__(self) -> str:
        # 型に関わらず、最初から文字列にフォーマットにしておくと便利
        return f"{self.username}"

    # 次にメソッド群
    def count_letters(self) -> int:
        return len(self.nickname)

    # 疑似プロパティメソッド群
    @property
    def is_subscriber(self) -> bool:
        return False

    # クラスメソッド群
    @classmethod
    def active_users(cls) -> QuerySet[User]:
        return cls.objects.filter(is_active=True)

    # スタティックメソッド群
    @staticmethod
    def generate_uid(salt: str) -> str:
        return hashlib.sha512(salt.encode()).hexdigest()

__str__メソッドは文字列を返すことを期待しているので、モデルの表示したいプロパティが文字列でないとTypeErrorの例外を投げます。また、ほとんどのクライアントは管理画面に表示されるオブジェクトを出力している箇所で、プロパティを複合して表示したいなどの要望を伝えてきます。ですので最初からfstringで書いておいたほうが良いです。

疑似プロパティメソッドとメソッドの違いは呼び出す時に()が省略される(Callableではない)ことです。こうしないといけないといったルールはありませんが、私はモデルのプロパティを複合して算出するようなプロパティとして使うイメージで使っています。

e.g.
from django.utils import timezone

@property
def display_name(self) -> str:
    return f"{self.nickname}({self.get_gender_display()})"

@property
    def is_teenager(self) -> bool:
        return (timezone.datetime.today() - self.birthday) // \
            timezone.timedelta(days=365.2425) < 20

それではモデルの実装について進めていきましょう。モデルを実装するときはPython3.7移行であれば型付けを推奨します。最近のエディターは優秀で型を書いていれば警告を出してくれますし、型を意識することで潜在的なバグを紛れ込ませない設計ができます。

クラスメソッドはモデルをインスタンスがない状態かつ、そのモデルクラス自体を必要とするメソッドで実装します。逆に次のスタティックメソッドはモデル自体を必要としない場合に使っています。

なんだかモデルを必要しないのに、そのモデルを必要としないメソッドがあるっていうのはあまりないのでピンとこないかもしれませんが、例のようにそのモデルの要素は使わないけど、そのモデルの役割に関連する特定の処理を行うようなイメージで使っています。次は型について説明します。

モデルのメソッド、疑似プロパティには型をつけよう

Python3.7から型ヒントが導入されてとても便利になりました。割と最近ですし、直接的にはDjangoに関係しないのですが、型を付けることでバグが入りにくいし、もっと良いことは引数、返り値の型が分からないときに、ライブラリのコードを読んで、型、クラスを探すことで、どんどんコードが上手に書けるようになります。
緻密すぎる型の定義をする必要はないと考えていますが、少なくともライブラリのクラス、関数などを知ることでより熟練していきますし、またOSSのコードは良いお手本なので是非、良い実装は真似して盗む癖をつけてください。

モデルのフィールドにValidatorsを入れよう

djangoではModelFormというモデルに注入するとフォームを作れる便利な機能があり、このモデルのバリデータリストを基に自動でバリデーションが行われるので、ここを緻密にしておくことでデータベースエラーも回避できます。また、多くのバリデーションスイーツが用意されていますし、もし該当するものがなければRegexValidatorやBaseBavalidatorを継承して独自のバリデーションを作っておくことで、将来、同じバリデーションを作る必要がなくなり、開発がより速くできるようになっていきます。

# django.core.validators.py

# 省略

@deconstructible
class RegexValidator:
    regex = ''
    message = _('Enter a valid value.')
    code = 'invalid'
    inverse_match = False
    flags = 0

    def __init__(self, regex=None, message=None, code=None, inverse_match=None, flags=None):
        if regex is not None:
            self.regex = regex
        if message is not None:
            self.message = message
        if code is not None:
            self.code = code
        if inverse_match is not None:
            self.inverse_match = inverse_match
        if flags is not None:
            self.flags = flags
        if self.flags and not isinstance(self.regex, str):
            raise TypeError("If the flags are set, regex must be a regular expression string.")

        self.regex = _lazy_re_compile(self.regex, self.flags)

    def __call__(self, value):
        """
        Validate that the input contains (or does *not* contain, if
        inverse_match is True) a match for the regular expression.
        """
        regex_matches = self.regex.search(str(value))
        invalid_input = regex_matches if self.inverse_match else not regex_matches
        if invalid_input:
            raise ValidationError(self.message, code=self.code)

    def __eq__(self, other):
        return (
            isinstance(other, RegexValidator) and
            self.regex.pattern == other.regex.pattern and
            self.regex.flags == other.regex.flags and
            (self.message == other.message) and
            (self.code == other.code) and
            (self.inverse_match == other.inverse_match)


@deconstructible
class BaseValidator:
    message = _('Ensure this value is %(limit_value)s (it is %(show_value)s).')
    code = 'limit_value'

    def __init__(self, limit_value, message=None):
        self.limit_value = limit_value
        if message:
            self.message = message

    def __call__(self, value):
        cleaned = self.clean(value)
        limit_value = self.limit_value() if callable(self.limit_value) else self.limit_value
        params = {'limit_value': limit_value, 'show_value': cleaned, 'value': value}
        if self.compare(cleaned, limit_value):
            raise ValidationError(self.message, code=self.code, params=params)

    def __eq__(self, other):
        return (
            isinstance(other, self.__class__) and
            self.limit_value == other.limit_value and
            self.message == other.message and
            self.code == other.code
        )

    def compare(self, a, b):
        return a is not b

    def clean(self, x):
        return x

Django3.xからchoicesにEnumを継承した実装が追加

# str,intの選択が追加されて便利に
class Role(models.Model):
    class Meta:
        verbose_name = _('Role')
        verbose_name_plural = _('Roles')
        ordering = ['-id']

    class Type(models.TextChoices):
        TEACHER = 'teacher', _('Teacher')
        STUDENT = 'student', _('Student')

    class Level(models.IntegerChoices):
        STANDARD = 1, _('Standard')
        ADVANCED = 2, _('Advanced')

    user = models.OneToOneField(
        "app.User", verbose_name=_("Role"), on_delete=models.CASCADE,
        primary_key=True, to_field='id'
    )
    type = models.CharField(
        _("Type"),
        max_length=7,
        choices=Type.choices,
        default=Type.STUDENT,
        validators=[
            validators.MinLengthValidator(7), validators.MaxValueValidator(7)
        ]
    )
    level = models.IntegerField(
        _("Level"), choices=Level.choices, default=Level.STANDARD)


# 3以前はEnumで独自で実装していた
from django.db.models import CharField

from django.utils.six import add_metaclass
from django.utils.six import text_type
from collections import OrderedDict


class EnumMetaClass(type):
    @classmethod
    def __prepare__(self, name, bases):
        return OrderedDict()

    def __new__(self, name, bases, classdict):
        members = []
        keys = {}
        choices = OrderedDict()
        for key, value in classdict.items():
            if key.startswith("__"):
                continue
            members.append(key)
            if isinstance(value, tuple):
                value, alias = value
                keys[alias] = key
            else:
                alias = None
            keys[alias or key] = key
            choices[alias or key] = value

        for k, v in keys.items():
            classdict[v] = k

        classdict["__choices__"] = choices
        classdict["__members__"] = members
        classdict["choices"] = tuple(
            (text_type(k), text_type(v))
            for k, v in reversed(choices.items())
        )

        return type.__new__(self, name, bases, classdict)


@add_metaclass(EnumMetaClass)
class Enum(object):
    pass


class EnumField(CharField):
    def __init__(self, enum, *args, **kwargs):
        self.enum = enum
        choices = enum.choices
        defaults = {
            "choices": choices,
            "max_length": max(len(k) for k, v in choices)
        }
        defaults.update(kwargs)
        super(EnumField, self).__init__(*args, **defaults)

    def deconstruct(self):
        name, path, args, kwargs = super(EnumField, self).deconstruct()
        kwargs["enum"] = self.enum
        del kwargs["choices"]
        return name, path, args, kwargs

to_fieldとprimary_keyをセットして、PKの値を参照モデルと合わせる

# 参照先のIDと同じ値が主キーになる
user = models.OneToOneField(
    "app.User", verbose_name=_("Role"), on_delete=models.CASCADE,
    primary_key=True, to_field='id'
)

Viewまで書こうと思っていましたが、存分長くなってしまったのと、書いてるうちにあ、あれもと思いつてくるので、モデルのみの説明にして、後日思いつくたびにこの記事に追記していくことにしました。次回はビュー側の実装で、要約するとDjangoのソースの中に使えるクラス、関数がたくさんあるので、それらを活用して、ほんの少しメソッドや初期値を書き換えることで無駄なコードを減らそうといった内容です。興味が湧いたら是非読んでみてください。

関連記事

python / Django
作成日:2020/3/5 更新日:2020/3/6
DjangoでGCPを活用した実践的な開発 Part1 [ 3.x対応 ]
Djangpの経験が8年ほどになる筆者が、Djangoを使った実践的な開発について、筆者が用意したGoogle Cloud Platformに最適化したDjangoのGitコードを使いながら、ユースケースを挙げて解説しています。

ポリシー

この記事のすべてまたは一部の複製は、著作権者の同意なしでは禁止されています。 引用については著者名と記事のURLが表示されている場合に限り認められます。