現場で使っているDRFのアーキテクチャ


新卒年度最後の月なので、所属するプロジェクトで考案/実践したDRFのアーキテクチャを記しておきます。

Django rest framework

https://www.django-rest-framework.org/

読者の想定

・DRFを使った中規模程度の開発経験がある
・DRFを使っていて、どこに何を書くかで悩んでいる

本章の流れ

  1. はじめに
  2. 目的とゴール
  3. アーキテクチャ図
  4. ディレクトリ構成
  5. View
  6. Serializer
  7. ModelEx
  8. 最後に
  9. 番外編

はじめに

・ 自分自身、採用するべきアーキテクチャは、サービスの内容やチームの技術レベルによって相対的に決まるものだと思っています
・ ここで書くことは、自分のプロジェクトでこうしているというだけなので、悪しからず。。

目的とゴール

かつて、プロジェクトで起きていた問題として、
・ビジネスロジックが散り散りになっている
・処理の共通化がなされていないし、構造的にしにくい
・単体テストが存在しないし、構造的に作りにくい
がありました、これを解決したくてスタートしています。

ので、アーキテクチャ導入後のゴールは
・ビジネスロジックの集約がなされている
・処理の使い回しが容易である
・テスタブルである
としています。

本章の要約

前提

  1. 複数のテーブル更新があっても、フロントに叩かせるapiは一つです。要は、ガチガチrestfulにはしません。
  2. 参照(LIST, RETRIEVE)と更新(CREATE, UPDATE)で設計分けています。

結論

  1. View及びSerializerにビジネスロジックを書かない。Model群に集約する
  2. fat Modelは、ModelExクラス(Modelごとのサービスクラスのようなもの)で解決する
    3.(urlとview)、(ModelとModelEx)は互いに1:1でrestfulに振る舞わせ、その間で柔軟に繋げ合う
  3. Modelに紐づかないビジネスロジックは、DRFディレクトリーの外に書く
  4. 外部公開しても問題がないようなロジックは、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に入っているファイルが、アーキテクチャ図の「他のライブラリー群」に当てはまります

  1. ビジネスロジックのあるなし
  2. 継承元があるかどうか
    でそれぞれ、4つに分けているイメージです。

また、pythonはfat classは問題ですが、fat fileは問題ないと思っているので、ViewファイルとSerializerファイルは、エンドユーザーごとに大きくざっくりファイルを分けるようにしています

View

ポイント

  1. ビジネスロジックは書きません。
  2. Viewの責務は、HTTPアクションをみて、PermissionやSeiralizerやModelExを適時に呼び出すことのみです。
  3. また、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

ポイント

  1. (LIST、RETRIVEの場合) Serializerの責務は、出力fieldの選択のみ。(整形はしない)
  2. (CREATE、UPDATEの場合) Serializerの責務は、リクエストボディの完璧なバリデーションと、ModelExを呼び出すのみ
  3. 出力fieldは、極力全てModelにproperty化させる(serializerMethodは使わないようにする)
  4. 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モデルを使います

https://docs.djangoproject.com/en/3.1/topics/db/models/#proxy-models

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を使わない理由としては、

  1. 用途が限定的になりすぎる
  2. こんな使い方↓をしていたため、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)