現場で使っているDRFのアーキテクチャ
新卒年度最後の月なので、所属するプロジェクトで考案/実践したDRFのアーキテクチャを記しておきます。
Django rest framework
読者の想定
・DRFを使った中規模程度の開発経験がある
・DRFを使っていて、どこに何を書くかで悩んでいる
本章の流れ
- はじめに
- 目的とゴール
- アーキテクチャ図
- ディレクトリ構成
- View
- Serializer
- ModelEx
- 最後に
- 番外編
はじめに
・ 自分自身、採用するべきアーキテクチャは、サービスの内容やチームの技術レベルによって相対的に決まるものだと思っています
・ ここで書くことは、自分のプロジェクトでこうしているというだけなので、悪しからず。。
目的とゴール
かつて、プロジェクトで起きていた問題として、
・ビジネスロジックが散り散りになっている
・処理の共通化がなされていないし、構造的にしにくい
・単体テストが存在しないし、構造的に作りにくい
がありました、これを解決したくてスタートしています。
ので、アーキテクチャ導入後のゴールは
・ビジネスロジックの集約がなされている
・処理の使い回しが容易である
・テスタブルである
としています。
本章の要約
前提
- 複数のテーブル更新があっても、フロントに叩かせるapiは一つです。要は、ガチガチrestfulにはしません。
- 参照(LIST, RETRIEVE)と更新(CREATE, UPDATE)で設計分けています。
結論
- View及びSerializerにビジネスロジックを書かない。Model群に集約する
- fat Modelは、ModelExクラス(Modelごとのサービスクラスのようなもの)で解決する
3.(urlとview)、(ModelとModelEx)は互いに1:1でrestfulに振る舞わせ、その間で柔軟に繋げ合う - Modelに紐づかないビジネスロジックは、DRFディレクトリーの外に書く
- 外部公開しても問題がないようなロジックは、DRFディレクトリーの外に書く
アーキテクチャ図
詳細は後述していきます。
参照
登録/更新
ディレクトリ構成
BtoCのECサイトにおいて、
・ shop -> ショップマスタ
・ customer -> 購入者マスタ
・ order -> 注文マスタ
が存在するとした場合の例
.
├── xxxxxx -> サービス名
│ ├── model
│ │ ├── ex -> 後述
│ │ │ ├── shop_ex.py
│ │ │ ├── customer_ex.py
│ │ │ └── order_ex.py
│ │ ├── shop.py
│ │ ├── customer.py
│ │ └── order.py
│ ├── url
│ │ ├── customer.py
│ │ └── shop.py
│ ├── view
│ │ ├── customer.py
│ │ └── shop.py
│ ├── serializer
│ │ ├── customer.py
│ │ └── shop.py
│ ├── commands
│ ├── migrations
│ ├── tests
│ └── common -> メール通知など、Model操作と紐づかないビジネスロジックを書く
│ ├── email.py
│ └── slack.py
│
├── lib -> ビジネスロジックが存在しない/外部公開をしても問題のない処理
│ ├── utils -> 採用しているライブラリの拡張/根底クラス
│ │ └── serializers.vue
│ │ └── models.vue
│ │ ├── datetime.py
│ │ └── pandas.py
│ ├── pdf.py
│ ├── csv.py
│ └── convert.py
│
└── config
/xxxxxx/commonや/libに入っているファイルが、アーキテクチャ図の「他のライブラリー群」に当てはまります
- ビジネスロジックのあるなし
- 継承元があるかどうか
でそれぞれ、4つに分けているイメージです。
また、pythonはfat classは問題ですが、fat fileは問題ないと思っているので、ViewファイルとSerializerファイルは、エンドユーザーごとに大きくざっくりファイルを分けるようにしています
View
ポイント
- ビジネスロジックは書きません。
- Viewの責務は、HTTPアクションをみて、PermissionやSeiralizerやModelExを適時に呼び出すことのみです。
- また、CRUDっぽくないapiは、@actionで対応させます。
例 注文マスタを参照、作成、CSVDL、要約取得をするView
class OrderViewSet(_ShopBaseViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin):
"""
注文マスタ
"""
def get_serializer_class(self):
if self.action in ['list', 'retrieve']:
return serializers.OrderReadSerializer
elif self.action in ['create']:
return serializers.OrderCreateSerializer
elif self.action in ['summary']:
return serializers.OrderSummarySerializer
else:
raise Http404
def get_queryset(self):
# ここには原則何も書かない
# filter条件とかは、全てModelExの仕事
return OrderEx.get_queryset_for_shop(shop=self.get_shop())
@action(methods=["get"], detail=True)
def dl_csv(self, request, *args, **kwargs):
# ファイルダウンロードとかはこんな感じで書く。
# CRUD群と同じviewクラスに入れとくと、
# get_object()やget_quryset()を共通で使用しやすいので、権限漏れの心配も少なくなる
target_order = self.get_object()
file_path = target_order.create_csv()
# file_response()
return self.file_response(file_path)
@action(methods=["get"], detail=False)
def summary(self, request, *args, **kwargs):
serializer = self.get_serializer(self.get_shop())
return Response(serializer.data)
Serializer
ポイント
- (LIST、RETRIVEの場合) Serializerの責務は、出力fieldの選択のみ。(整形はしない)
- (CREATE、UPDATEの場合) Serializerの責務は、リクエストボディの完璧なバリデーションと、ModelExを呼び出すのみ
- 出力fieldは、極力全てModelにproperty化させる(serializerMethodは使わないようにする)
- fatにならないように、readとwriteでクラス分ける
class OrderReadSerializer(_BaseShopModelSerializer,
_BaseReadOnlySerializer):
class _OrderRecordSerializer(serializers.ModelSerializer):
class Meta:
model = OrderRecord
fields = ('id', 'quantity', 'product_code', 'product_name',
'product_unit', 'product_price', 'product_tax_rate')
records = _OrderRecordSerializer(many=True)
customer_code = serializers.CharField()
class Meta:
model = Order
fields = ('id', 'customer_code', 'customer_user_name',
'delivery_date', 'order_date', 'is_first_order',
'records')
class ShopOrdersWriteSerializer(_BaseShopModelSerializer,
_BaseWriteOnlySerializer):
class _OrderRecordSerializer(_BaseShopModelSerializer):
class Meta:
model = OrderRecord
fields = ('product', 'quantity')
def validate_quantity(self, quantity):
if quantity <= 0 :
raise serializers.ValidationError('quantityは0より大きい数字にしてください')
return quantity
records = serializers.ListSerializer(
child=_OrderRecordSerializer(),
required=True)
class Meta:
model = Order
fields = ('customer', 'delivery_date', 'records')
def validate_customer(self, customer):
# modelにアクセスする系のバリデーションは、ModelExを通す
if self.shop not in customerEx.get_queryset_for_shop(shop=shop):
raise serializers.ValidationError('注文できないshopです')
return customer
def create(self, validated_data):
# ModelExを呼ぶだけ
order = OrderEx.create_(customer=validated_data['customer'], shop=self.get_shop(),
delivery_date=validated_data['delivery_date'],
order_date=DateTimeUtil.now())
## bulk_createしたいなら、こんな感じで書く
## 少々冗長だが、ビジネスロジックが書かれているわけではないので良しとする
## ModelExにbulk_create()作ってもいい
order_record_bulk_create_object_list = []
for record in validated_data['records']:
order_record_object = OrderRecordEx.create_(order=order,
product=record['product'],
quantity=record['quantity'],
is_skip_save=True)
order_record_bulk_create_object_list.append(order_record_object)
OrderRecord.objects.bulk_create(order_record_bulk_create_object_list)
## メール送信とかはここらへん
return order
ModelEx
こいつが本アーキテクチャの要になります。
Djangoのproxyモデルを使います
Modelのサービスクラスのような働きをします、restfulな設計をこいつが柔軟に
基本的に、継承するModelのテーブル操作との紐付きが非常に強いビジネスロジックが書かれます。
import Order from xxxxx.models
class OrderEx(Order):
class Meta:
proxy = True
@classmethod
def get_queryset_for_shop(cls, shop, target_customer=None):
"""
注文参照。shopに紐づく注文を返す
"""
assert shop, 'shop is required'
assert shop.is_active, 'this shop is not active'
## ビジネスロジックはここに書く
## 他のModelExクラスをよんでOK
## Viewのget_querysetから呼び出されることが多い
return cls.objects.filter(shop=shop,
target_customer=target_customer,
is_deleted=False).filter(q)
@classmethod
def create_(cls, customer, target_shop, order_date, deliver_date,
is_skip_save=False):
"""
注文作成
is_skip_save: 使用ケースとしては、本関数のビジネスロジックを使用したいが、
本関数の外で、createやbulk_createする方がいい時用に使う。
save()をせず、orderオブジェクトを返す。
"""
# 初めての注文ならフラグ立たせとく(分かりやすくするために、変な書き方してます)
if customer.has_ordered(shop=target_shop):
is_first_order = True
else:
is_first_order = False
# bulk_createしたい際などがあり、
# 即時sql発行して欲しくないので、objects.create()は使わない。
order = cls(target_shop=target_shop,
order_date=order_date,
deliver_date=deliver_date,
is_first_order=is_first_order)
if not is_skip_save:
order.save()
retunr order
@classmethod
def get_csv(cls, customer):
## 省略
補足: Modelとの棲み分け
Model ・・ レコード単体に作用する操作
ModelEx ・・ テーブル全体に作用する操作
って感じの棲み分けしていますが、なかなか難しいので、propertyメソッドやインスタンスメソッドにする必要のあるものはModelに書いて、クラスメソッドでいいものはModelExに書いてます。
ちなみに、テーブルに紐づかない操作は、Model群のfatを避けるために、/commonや/libに切り出しています
補足: models.Managerを使わない理由
参照メソッド(get_queryset())で、Djangoを使う際の標準である、models.Managerを使わない理由としては、
- 用途が限定的になりすぎる
- こんな使い方↓をしていたため、queryset取得時に、.objects()を必ず通る前提の設計にしたくなかった
## /models配下のModelは、こいつを継承する
class BaseModel(TimeStampedModel):
class ModelManagerActive(models.Manager):
def get_queryset(self):
return models.QuerySet(self.model).filter(is_active=True)
# 論理削除したレコードは通常のfilterでは拾わないようにする
objects = ModelManagerActive()
# 論理削除したレコードも取得したい場合はentireを使う
entire = models.Manager()
is_active = models.BooleanField(
default=True,
)
deleted = models.DateTimeField(
null=True,
blank=True,
default=None,
help_text="論理削除された時刻"
)
def delete(self, using=None, keep_parents=False, is_hard=False):
if is_hard:
return super().delete(using, keep_parents)
else:
self.deleted = now_tz()
self.is_active = False
self.save(using)
return 1, {}
def delete_hard(self, using=None, keep_parents=False):
self.delete(using, keep_parents, is_hard=True)
などがあったため
最後に
以上のような設計としています。
厳密に守っている訳でも、めちゃくちゃ上手くいっている訳でもないです。
番外編: DRFの便利拡張クラスなどの紹介
DRFの便利拡張クラスなどの紹介
ここで書いた設計フル無視ですが、こういったViewSetmixinも作ってます。
views.UpdateModelMixinのbulkバージョンです。
形としては絶対に良くないんですが、めっちゃ便利です。
class BulkUpdateModelMixin:
"""
Bulk Update a model instance.
bulk_update_list
(ex,
[{"pk": 4, "name": "satou", "country": "JPN"},
{"pk": 11, "name": "tom", "country": "UK"}]
更新は、pkをkeyに行います。
"""
@action(methods=["put"], detail=False)
def bulk_update(self, request, *args, **kwargs):
assert hasattr(self, 'bulk_update_serializer'), (
"BulkUpdateModelMixinを利用するには、bulk_update_serializerを指定してください。"
"詳細は、BulkUpdateModelMixinのDocs参照。"
)
serializer = self.bulk_update_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
bulk_update_list = serializer.data['bulk_update_list']
bulk_update_dict = {dict(i)['pk']: dict(i) for i in bulk_update_list}
queryset: QuerySet = self.get_queryset()
update_queryset: QuerySet = queryset.filter(id__in=bulk_update_dict.keys())
assert len(bulk_update_dict.keys()) == update_queryset.count(), (
"bulk_update_listに指定できないPKが含まれています。"
)
updated_list = []
updated_field_list = set([])
for qs in update_queryset:
update_field_dict = bulk_update_dict[qs.pk]
for update_field, value in update_field_dict.items():
setattr(qs, update_field, value)
updated_field_list.add(update_field)
updated_list.append(qs)
updated_field_list = list(updated_field_list)
updated_field_list.remove('pk')
update_queryset.model.objects.bulk_update(updated_list, fields=updated_field_list)
return Response(status=status.HTTP_204_NO_CONTENT)
## 使い方
class CustomerViewSet(_ShopBaseViewSet,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
customMixins.BulkUpdateModelMixin):
bulk_update_serializer = serializers.customerbulkUpdateSerializer
## 以下省略
同じような、viewSetMixinsは結構作っています。使い方はBulkUpdateModelMixinと同じ
class BulkDestroyModelMixin:
"""
Bulk Destroy a model instance.
bulk_delete_pk_list 削除したいmodelのプライマリーキーをlistで指定してください (ex, [3, 14, 27]
"""
@action(methods=["delete"], detail=False, serializer_class=BulkDeleteSerializer)
def bulk(self, request, *args, **kwargs):
bulk_delete_pk_list = serializer.data['bulk_delete_pk_list']
delete_queryset: QuerySet = self.get_queryset().filter(id__in=bulk_delete_pk_list)
# instance.delete()を呼びたいので、filter().delete()にしていない
# __inは、レコード数多いと、めっちゃ重くなるのでなんとかする。
for q in delete_queryset.filter(id__in=bulk_delete_pk_list):
q.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class GetAllCountModelMixin:
"""
urlクエリパラム無視して、全件の数返します
"""
@action(methods=["get"], detail=False)
def all_count(self, request, *args, **kwargs):
## len()の方が早いらしいが、件数多いと高負荷になりかねないので、count()使う
return Response(data={'count': self.get_queryset().count()})
また、View, Serializer, ModelEx, Modelなどでは、基本的に外部ライブラリを呼ばないように努力しています。
例えば、日付の操作などは、DateTimeUtilクラスを作成して、
他のファイルは、import datetimeはせず、DateTimeUtilを通して日付の操作を行います。
class DateTimeUtil:
@classmethod
def now(cls):
return localtime(timezone.now())
@classmethod
def today(cls):
return datetime.date.today()
@classmethod
def str_to_datetime(cls, str_, format='%Y-%m-%d %H:%M:%S'):
return timezone.make_aware(datetime.strptime(str_, format),
timezone.get_default_timezone())
@classmethod
def utc_to_localtime(cls, datetime):
return localtime(datetime)
@classmethod
def n_weeks_later(cls, week=0):
return cls.now() + relativedelta(weeks=week)
Discussion