DjangoでGCPを活用した実践的な開発 Part2 [ 3.x対応 ]
サービス開発における実践的なモデル設計
前回はライブラリとsettings.pyの中身に触れて、Djangoの開発を効率良く行う方法について触れました。今回からはGitコードのアプリケーションを動かしながら進めていきましょう。手元に無い方は以下からクローンしてください。
まだ環境を準備していない方は、以下のコマンドで起動、データ確認用の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のソースの中に使えるクラス、関数がたくさんあるので、それらを活用して、ほんの少しメソッドや初期値を書き換えることで無駄なコードを減らそうといった内容です。興味が湧いたら是非読んでみてください。