Django REST frameworkになるべく逆らわずに戦術的DDDを取り入れる
本記事は、 DDD-Community-Jp Advent Calendar 2020の17日目です。
はじめに
PythonとDjango REST frameworkを扱う際に、DDDの戦術的設計を取り入れたらどんな感じになるかを書いてみようと思います。
Django REST frameworkでのプロジェクトの悩み
通常のDjango REST frameworkでのプロジェクトの雑な簡単な図を描いてみました。
WebブラウザからのAPI呼び出しに対して、Webサーバを通してViewを呼び出します。
(Viewの手前にURLディスパッチャやミドルウェアが間にありますが、今回の話の本質では無いので、割愛させて頂きます1)
ViewからSerializerを呼び出し、入力値のバリデーションや値の結果のJSON組み立てをします。
その過程でデータの作成や取得などのCRUD操作はModelを介して行われます。
だんだん太ってくるSerializer or View
この構成だと、SerializerもしくはView(場合によってはModel)にビジネスロジックが点在しやすくなり、そこの部分が太ってきます。
これによってどんなことが起きるかと言いますと、
- 新規作成時と更新時のルールが同じだけど、それぞれの手続きの中に紛れ込んでいる
- ある顧客に対して、必ず1つ以上の連絡先を設定しなければいけない、など
- ルールの視認性の悪さ
- フレームワークとビジネスルールが密結合している
という、フレームワーク依存なプログラムとなっていき、コードの見通しが悪くなっていきます。
そこでビジネスロジックは、なるべくフレームワークに依存しない形に切り出していきたいと思います。
こんな感じにしてみる
Presentation層、Infrastructure層、Application層、Domain層と4つのレイヤーに分けて、考えていきます。
- レイヤーの分け方に関しては、以下の記事、スライドを参考にしています。
Presentation層を、APIの値を受け取り、バリデーションする責務と考えたときに、Django REST frameworkのViewとSerializerをそこに置くと良さそうです。
Infrastructure層は、DBのやり取りをするため、DjangoのModelと、それを使用したRepositoryの具象クラスを配置するようにします。
フォルダ配置例
通常のDRFのプロジェクトルートの直下に、1つパッケージを切ります。
このパッケージ自体にはフレームワークの都合を含ませないようにします。
コード例
顧客の新規作成を例にしてみます。
顧客を新規で作る時に、以下のルールがあったとします。
- 顧客には必ず1つの連絡先(電話番号、担当者)を付ける必要がある
- 顧客が持てる連絡先は5つまで
Django REST frameworkの場合、ModelViewSetを使っていることが多いと思いますが、
Viewのcreate()が呼ばれる ↓ Serializerのis_valid()でバリデーションが行われ、 perform_create()でModelを操作してDBに保存とモデルのインスタンスを返す
といった動作をしてくれます。
つまりこの流れに逆らうのであれば、ViewやSerializerの各種メソッドをオーバーライドし、それに沿った戻り値を返さないといけません。
正直それをやるのは面倒くさいですし、なんのためにDjango REST frameworkを選んだって気持ちにもなるので、
ここは最小限の変更で済ませるように、なんとかDomain層(とApplication層)をフレームワークから隔離するようにしていきたいと思います。
バリデーション
それぞれの値が何桁までなのか、数字じゃないといけないとか、といったルールも値オブジェクトとして持っておきたいと思うので、Serializerのvalidate()をオーバーライドしていきます。
def validate(self, data): try: # 値オブジェクトに詰め替えることで、エラーを確認する。 code = AccountCode(data['code']) name = AccountName(data['name']) except AccountCodeError as err: raise ValidationError({'code': err}) except AccountNameError as err: raise ValidationError({'name': err})
Django REST frameworkに対しては、ValidationErrorと、バリデーションに失敗したパラメータとエラー内容のkey valueで返す必要があります。
そのため、どこでバリデーション失敗したのか(=値オブジェクトとして変換ができなかった)、分かる必要があるため、独自のExceptionクラスを作成して値オブジェクトからraiseしています。
値オブジェクトのコード
class AccountCode: """ 顧客コード """ def __init__(self, code: str): if not type(code) is str: raise AccountCodeError('値が文字列ではありません。') if code.isdigit(): raise AccountCodeError('数字のみを入れてください。') if len(code) == 4: raise AccountCodeError('4桁の半角数字で入力してください。') self.__code = code @property def code(self): return self.__code
class AccountName: """ 顧客名 """ def __init__(self, account_name: str): if not type(account_name) is str: raise AccountNameError('値が文字列ではありません。') if len(account_name) > 50: raise AccountNameError('顧客名は50文字までです。') self.__account_name = account_name @property def code(self): return self.__account_name
連絡先オブジェクト
import re class Contact: """ 連絡先 """ def __init__(self, tel_no: str, staff: str): assert bool(re.match(r'[0-9]{3}-[0-9]{4}-[0-9]{4}', tel_no)), '電話番号の書式が間違っています' assert len(staff) <= 50, '担当者名は50文字までです' self.__tel_no = tel_no self.__staff = staff @property def tel_no(self): return self.__tel_no @property def staff(self): return self.__staff
class ContactList: """ 連絡先リスト """ def __init__(self, contacts: List[Contact] ): # 5つまでしか追加できない if len(contacts) > 5: raise ListOutOfRangeError('5つまでしか設定できません。') if len(contacts) <= 0: raise ListOutOfRangeError('必ず1つ以上設定してください。') self.__contacts = contacts def add(self, contact: Contact) -> ContactList: new_contact_list = copy.copy(self.__contacts) new_contact_list.append(contact) return ContactList(new_contact_list)
アプリケーションサービスの呼び出し
Serializerのcreate()をオーバーライドし、アプリケーション層のメソッドを呼び出します。
このAccountService.create()
は、Django REST frameworkでバリデーションが済んだデータvalidated_data
をコマンドオブジェクトに入れてから引数として渡しています。
Serializerのcreate()は、Djangoのモデルのインスタンスを返す必要があるため、AccountService.create()
の戻り値で返したDTOから、Querysetを使って検索し、作成したばかりのモデルを返しています。
def create(self, validated_data): repository: AccountRepository = AccountRepositoryImpl() service = AccountService(repository) account_object = service.create(AccountCreateCommand( validated_data['code'], validated_data['name'], validated_data['contacts'], )) return Account.objects.filter(code=account_object.code).get()
Application層のメソッド
def create(self, command: AccountCreateCommand) -> AccountObject: contacts = list(map( lambda x: Contact( tel_no=x['tel_no'], staff=x['staff'], ), command.account_list )) contact_list = ContactList(contacts) code = AccountCode(command.code) name = AccountName(command.name) account = Account( code, name, contact_list ) self._repository.save(account) return self._repository.find_by_code_reserved(code)
リポジトリのインターフェースの定義
リポジトリのインターフェースは、Domainのパッケージ内に作っておきます。
from abc import ABCMeta, abstractmethod from item_application.domain.account.account import Account from item_application.domain.account.account_code import AccountCode class AccountRepository(metaclass=ABCMeta): @abstractmethod def save(self, account: Account) -> None: raise NotImplementedError('未実装です。') @abstractmethod def find_by_code(self, code: AccountCode) -> Account: raise NotImplementedError('未実装です。')
リポジトリの実装
リポジトリの実装は、Djnagoのモデルに依存することになるので、Djangoのフレームワーク側に定義するようにします。
class AccountRepositoryImpl(AccountRepository): """ Django Modelでの取引先リポジトリ実装 """ def save(self, account: Account) -> None: Account.objects.create(account.code, account.name, account.contacts)
とりあえず隔離ができた
これで、ViewとSerializerにあったビジネスロジックは、Domain層に移すことができました。
Serializerは、Application層のメソッドを呼び出すことで、新規作成を行うことができます。
課題
Python自体はパッケージのアクセス制限を厳密にやるのが難しい
- 結局Application層とか無視してDomain層のオブジェクトにアクセスできてしまう
- そもそもModelにアクセスするのも出来るので、そこは規約で守らないとどうしようもなさそう
GETなどの参照系の扱い
- 検索などの参照系は、Viewのget_querysetで処理していることが多い
- get_querysetの戻り値はQuerySet型である為、Application層のメソッドに持っていくのには少し微妙な感じ
- あくまで参照系であり、DBから取ってきた値をあえてドメインオブジェクトに組み立て直して何かしらチェックすることは無さそうだから、一旦は保留。
- うまいやり方を見つけたい
まとめ
Django REST frameworkと組合せで気をつけること
- DRFについてわかりやすい解説については、現場で使えるDjango REST Frameworkの教科書がオススメです。↩