Django | 代码分层与模块划分

本文翻译自 https://github.com/HackSoftware/Django-Styleguide
[ Django 风格指南 ]

概览


在Django中,业务逻辑应该位于:

  • 模型(Model)的属性(抛异常)
  • 模型(Model)的 clean 方法,处理附加的验证(抛异常)
  • 服务(Services)- 函数,处理写数据库的逻辑
  • 选择器(Selector)- 函数,处理读数据库的逻辑

在Django中,因为逻辑不应该位于:

  • 接口(APIs)与视图(Views)
  • 序列化器(Serializers)与表单(Forms)
  • 表单条目(Form tags)
  • 模型(Model)的 save 方法

模型属性与选择器的对比:

  • 如果一个模型属性是跨越了多个表的关系,那么它最好放在选择器中
  • 如果一个模型属性,当添加到一个列表接口时,会产生 N + 1 问题(列表每一条数据都是一次查询),且很难使用 select_related,那么它最好放在选择器中

模型


看如下示例模型:

class Course(models.Model):
    name = models.CharField(unique=True, max_length=255)

    start_date = models.DateField()
    end_date = models.DateField()

    attendable = models.BooleanField(default=True)

    students = models.ManyToManyField(
        Student,
        through='CourseAssignment',
        through_fields=('course', 'student')
    )

    teachers = models.ManyToManyField(
        Teacher,
        through='CourseAssignment',
        through_fields=('course', 'teacher')
    )

    slug_url = models.SlugField(unique=True)

    repository = models.URLField(blank=True)
    video_channel = models.URLField(blank=True, null=True)
    facebook_group = models.URLField(blank=True, null=True)

    logo = models.ImageField(blank=True, null=True)

    public = models.BooleanField(default=True)

    generate_certificates_delta = models.DurationField(default=timedelta(days=15))

    objects = CourseManager()

    def clean(self):
        if self.start_date > self.end_date:
            raise ValidationError("End date cannot be before start date!")

    def save(self, *args, **kwargs):
        self.full_clean()
        return super().save(*args, **kwargs)

    @property
    def visible_teachers(self):
        return self.teachers.filter(course_assignments__hidden=False).select_related('profile')

    @property
    def duration_in_weeks(self):
        weeks = rrule.rrule(
            rrule.WEEKLY,
            dtstart=self.start_date,
            until=self.end_date
        )
        return weeks.count()

    @property
    def has_started(self):
        now = get_now()

        return self.start_date <= now.date()

    @property
    def has_finished(self):
        now = get_now()

        return self.end_date <= now.date()

    @property
    def can_generate_certificates(self):
        now = get_now()

        return now.date() <= self.end_date + self.generate_certificates_delta

    def __str__(self) -> str:
        return self.name

关于模型有几点需要指出:

自定义验证:

  • clean 方法中定义了一个自定义模型认证,这个认证只使用了模型字段,没有使用关系。
  • 需要有方法来调用模型的 full_clean 方法,定义的 clean 方法才能生效。调用 full_clean 最好的地方是模型的 save 方法中,因为各个服务可能会遗漏调用。

属性:

  • 除了 visible_teachers 外的其他所有属性,都是直接作用于模型字段。
  • visible_teachers 是选择器很好的替代对象

关于自定义验证和模型属性/方法有如下几条通用规格:

自定义验证

  • 如果自定义验证仅仅依赖于不包含关系的模型字段,在 clean 方法中进行定义,在 save 方法中调用 full_clean
  • 如果自定义验证更加复杂,或是跨越了多个表的关系,在创建模型的服务中进行。
  • 可以在服务中将clean和其他附加的验证结合起来。

属性:

  • 如果一个属性只使用不包含关系的模型字段,可以在模型属性中进行定义
  • 如果一个属性,例如 visible_teacher,是跨多表的关系,最好为其定义一个单独的选择器。

方法:

  • 如果你需要一个同事更新多个字段的方法,例如一个表的 create_atcreate_by 字段,可以定义一个方法完成这项工作。
  • 每个模型方法都需要被包含在服务中,不应该在服务之外的地方出现方法调用。

测试

只有在模型中有附加逻辑时才需要被测试,像自定义验证或属性
如果我们严格地不测试自定义验证和属性,那么我们可以在测试模型时不往数据库中添加任何内容,这将加速测试的运行速度
例如,如果我们想测试自定义验证,可以采用如下这种方式:

from datetime import timedelta

from django.test import TestCase
from django.core.exceptions import ValidationError

from odin.common.utils import get_now

from odin.education.factories import CourseFactory
from odin.education.models import Course


class CourseTests(TestCase):
    def test_course_end_date_cannot_be_before_start_date(self):
        start_date = get_now()
        end_date = get_now() - timedelta(days=1)

        course_data = CourseFactory.build()
        course_data['start_date'] = start_date
        course_data['end_date'] = end_date

        course = Course(**course_data)

        with self.assertRaises(ValidationError):
            course.full_clean()

这个测试进行了如下内容:

  • get_now() 返回时区的时间
  • CourseFactory.build() 返回课程需要的所有字段的字典
  • 我们替换了开始时间和结束时间
  • 我们测试了在调用 full_clean 时是否会抛出验证错误
  • 过程中我们没有往数据库中添加任何内容,因为没有必要
    CourseFactory 的实现如下:
class CourseFactory(factory.DjangoModelFactory):
    name = factory.Sequence(lambda n: f'{n}{faker.word()}')
    start_date = factory.LazyAttribute(
        lambda _: get_now()
    )
    end_date = factory.LazyAttribute(
        lambda _: get_now() + timedelta(days=30)
    )

    slug_url = factory.Sequence(lambda n: f'{n}{faker.slug()}')

    repository = factory.LazyAttribute(lambda _: faker.url())
    video_channel = factory.LazyAttribute(lambda _: faker.url())
    facebook_group = factory.LazyAttribute(lambda _: faker.url())

    class Meta:
        model = Course

    @classmethod
    def _build(cls, model_class, *args, **kwargs):
        return kwargs

    @classmethod
    def _create(cls, model_class, *args, **kwargs):
        return create_course(**kwargs)

服务:


服务是一个简单的函数:

  • 位于 your_app/services.py 模块中
  • 只接受关键字参数
  • 是类型注释(即使你现在并未使用 mypy
  • 一般与模型,其他服务和选择器共同完成工作
  • 处理业务逻辑:简单的模型创建,复杂的 cross-cutting concerns (https://stackoverflow.com/questions/23700540/cross-cutting-concern-example),调用其他服务,开启其他任务

以下是一个创建用户的示例服务:

def create_user(
    *,
    email: str,
    name: str
) -> User:
    user = User(email=email)
    user.full_clean()
    user.save()

    create_profile(user=user, name=name)
    send_confirmation_email(user=user)

    return user

可以看到,这个服务调用了其他两个服务,create_profilesend_confirmation_email

选择器


选择器是一个简单的函数:

  • 位于 your_app/selectors.py 模块中
  • 只接受关键字参数
  • 是类型注释(即使你现在并未使用 mypy
  • 一般与模型,其他服务和选择器共同完成工作
  • 处理从数据库中取数据的业务逻辑
    以下是一个从数据库中列出用户的选择器:
def get_users(*, fetched_by: User) -> Iterable[User]:
    user_ids = get_visible_users_for(user=fetched_by)

    query = Q(id__in=user_ids)

    return User.objects.filter(query)

如你所见, get_visible_users_for 是另外一个选择器。

API与序列化器


当使用服务和选择器时,所有的API应该看起来简洁且相似
API的通用规则如下:

  • 每个API对应一个操作。对于模型的一个CRUD,应该是四个API
  • 使用最简单的 APIViewGenericAPIView
  • 调用服务与选择器,不在API中处理业务逻辑
  • 使用序列化器从参数中取得对象,参数通过一个GET或POST请求传递
  • 序列化器需要作为API类的子类,使用 InputSerializerOutputSerializer来命名
    • 如果需要,OutputSerializer 可以是 ModelSerializer 的子类
    • InputSerializer 总是普通的 Serializer
    • 尽量不复用序列化器
    • 如果需要交叉序列化器,使用 inline_serializer 公用方法

示例列表API

class CourseListApi(SomeAuthenticationMixin, APIView):
    class OutputSerializer(serializers.ModelSerializer):
        class Meta:
            model = Course
            fields = ('id', 'name', 'start_date', 'end_date')

    def get(self, request):
        courses = get_courses()

        data = self.OutputSerializer(courses, many=True)

        return Response(data)

示例详情API

class CourseDetailApi(SomeAuthenticationMixin, APIView):
    class OutputSerializer(serializers.ModelSerializer):
        class Meta:
            model = Course
            fields = ('id', 'name', 'start_date', 'end_date')

    def get(self, request, course_id):
        course = get_course(id=course_id)

        data = self.OutputSerializer(course)

        return Response(data)

示例添加API

class CourseCreateApi(SomeAuthenticationMixin, APIView):
    class InputSerializer(serializers.Serializer):
        name = serializers.CharField()
        start_date = serializers.DateField()
        end_date = serializers.DateField()

    def post(self, request):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        create_course(**serializer.validated_data)

        return Response(status=status.HTTP_201_CREATED)

示例更新API

class CourseUpdateApi(SomeAuthenticationMixin, APIView):
    class InputSerializer(serializers.Serializer):
        name = serializers.CharField(required=False)
        start_date = serializers.DateField(required=False)
        end_date = serializers.DateField(required=False)

    def post(self, request, course_id):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        update_course(course_id=course_id, **serializer.validated_data)

        return Response(status=status.HTTP_200_OK)

交叉序列化器

class Serializer(serializers.Serializer):
    weeks = inline_serializer(many=True, fields={
        'id': serializers.IntegerField(),
        'number': serializers.IntegerField(),
    })

异常处理


在服务或选择器中抛出异常

现在我们已经将我们的HTTP接口和应用的核心逻辑分离开来了
我们保持我们的分离的风格,我们的序列化器和服务不能使用 rest_framework.exception 类,因为这个类与HTTP的状态码不是分离的。
我们的服务和选择器必须是以下类之一:

  • Python内建异常
  • django.core.exceptions
  • 自定义异常,继承自以上两个类
    以下是一个服务进行一些验证并在服务中抛出 django.core.exceptions.ValidationError
from django.core.exceptions import ValidationError

def create_topic(*, name: str, course: Course) -> Topic:
    if course.end_date < timezone.now():
       raise ValidationError('You can not create topics for course that has ended.')

    topic = Topic.objects.create(name=name, course=course)

    return topic

在API里处理异常

为了将服务和选择器中抛出的异常转换成HTTP响应,你需要处理这个异常,并抛出 rest framework 可以理解的异常。
进行这项工作最好的地方是在 APIViewhandle_exception 方法中
在这里你可以将自定义的异常,映射成DRF的异常,示例如下:

from rest_framework import exceptions as rest_exceptions

from django.core.exceptions import ValidationError


class CourseCreateApi(SomeAuthenticationMixin, APIView):
    expected_exceptions = {
        ValidationError: rest_exceptions.ValidationError
    }

    class InputSerializer(serializers.Serializer):
        ...

    def post(self, request):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        create_course(**serializer.validated_data)

        return Response(status=status.HTTP_201_CREATED)

    def handle_exception(self, exc):
        if isinstance(exc, tuple(self.expected_exceptions.keys())):
            drf_exception_class = self.expected_exceptions[exc.__class__]
            drf_exception = drf_exception_class(get_error_message(exc))

            return super().handle_exception(drf_exception)

        return super().handle_exception(exc)

以下是 get_error_message 的实现:

def get_first_matching_attr(obj, *attrs, default=None):
    for attr in attrs:
        if hasattr(obj, attr):
            return getattr(obj, attr)

    return default


def get_error_message(exc):
    if hasattr(exc, 'message_dict'):
        return exc.message_dict
    error_msg = get_first_matching_attr(exc, 'message', 'messages')

    if isinstance(error_msg, list):
        error_msg = ', '.join(error_msg)

    if error_msg is None:
        error_msg = str(exc)

    return error_msg

你可以将这些代码移动到一个mixin中,并在每个API中使用以避免重复代码
我们将这叫做 ExceptionHandlerMixin。以下是我们项目中的一个示例实现:

from rest_framework import exceptions as rest_exceptions

from django.core.exceptions import ValidationError

from project.common.utils import get_error_message


class ExceptionHandlerMixin:
    """
    Mixin that transforms Django and Python exceptions into rest_framework ones.
    without the mixin, they return 500 status code which is not desired.
    """
    expected_exceptions = {
        ValueError: rest_exceptions.ValidationError,
        ValidationError: rest_exceptions.ValidationError,
        PermissionError: rest_exceptions.PermissionDenied
    }

    def handle_exception(self, exc):
        if isinstance(exc, tuple(self.expected_exceptions.keys())):
            drf_exception_class = self.expected_exceptions[exc.__class__]
            drf_exception = drf_exception_class(get_error_message(exc))

            return super().handle_exception(drf_exception)

        return super().handle_exception(exc)

应用这个这个mixin,我们的创建API可以进行如下实现:

class CourseCreateApi(
  SomeAuthenticationMixin,
  ExceptionHandlerMixin,
  APIView
):
    class InputSerializer(serializers.Serializer):
        ...

    def post(self, request):
        serializer = self.InputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        create_course(**serializer.validated_data)

        return Response(status=status.HTTP_201_CREATED)

测试


在我们的Django项目中,我们依据代码代表的类型将测试进行拆分。
这意味着,通常来说我们会有模型、服务、选择器和API的测试
文件结构通常如下:

project_name
├── app_name
│   ├── __init__.py
│   └── tests
│       ├── __init__.py
│       ├── models
│       │   └── test_some_model_name.py
│       ├── selectors
│       │   └── test_some_selector_name.pyy
│       └── services
│           ├── __init__.py
│           └── test_some_service_name.py
└── __init__.py

命名约定

我们遵循两条基本的命名约定:

  • 测试文件的命名:test_the_name_of_the_thing_that_is_tested.py
  • 测试用例的命名:class TheNameOfTheThingThatIsTestedTests(TestCase):

例如我们有这样一个服务:

def a_very_neat_service(*args, **kwargs):
    pass

我们将有这样一个测试文件:

project_name/app_name/tests/services/test_a_very_neat_service.py

有这样一个测试用例:

class AVeryNeatServiceTests(TestCase):
    pass

为了测试公共方法,我们遵循如下相似的模式:
例如,我们有一个 project_name/common/utils.py,那么我们将有一个 project_name/common/tests/test_utils.py文件,并在这个文件中放置不同的测试用例。
如果我们将将公共模块拆分了子模块,测试也需要拆分子文件:

  • project_name/common/utils/files.py
  • project_name/common/tests/utils/test_files.py
    我们将测试的结构与它所代表的模块相匹配

示例

示例模型:

import uuid

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone

from djmoney.models.fields import MoneyField


class Item(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    name = models.CharField(max_length=255)
    description = models.TextField()

    price = MoneyField(
        max_digits=14,
        decimal_places=2,
        default_currency='EUR'
    )

    def __str__(self):
        return f'Item {self.id} / {self.name} / {self.price}'


class Payment(models.Model):
    item = models.ForeignKey(
        Item,
        on_delete=models.CASCADE,
        related_name='payments'
    )

    user = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        related_name='payments'
    )

    successful = models.BooleanField(default=False)

    created_at = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return f'Payment for {self.item} / {self.user}'

示例选择器:

from django.contrib.auth.models import User

from django_styleguide.common.types import QuerySetType

from django_styleguide.payments.models import Item


def get_items_for_user(
    *,
    user: User
) -> QuerySetType[Item]:
    return Item.objects.filter(payments__user=user)

示例服务:

from django.contrib.auth.models import User
from django.core.exceptions import ValidationError

from django_styleguide.payments.selectors import get_items_for_user
from django_styleguide.payments.models import Item, Payment
from django_styleguide.payments.tasks import charge_payment


def buy_item(
    *,
    item: Item,
    user: User,
) -> Payment:
    if item in get_items_for_user(user=user):
        raise ValidationError(f'Item {item} already in {user} items.')

    payment = Payment.objects.create(
        item=item,
        user=user,
        successful=False
    )

    charge_payment.delay(payment_id=payment.id)

    return payment

测试服务:

服务测试是项目中最为重要的一项测试。通常测试代码的量也最大。
良好的服务测试通常遵循如下规则:

  • 测试需要全面覆盖服务背后的业务逻辑
  • 测试需要触及数据库,进行数据读写
  • 测试需要mock异步任务调用,和其他所有项目外的内容

当需要创建一个给定的测试需要的状态时,可以使用如下组合:

  • fakes(假数据, https://github.com/joke2k/fake)
  • 其他服务,用来创建需要的对象
  • 特殊的测试公用方法和辅助方法
  • 工厂(推荐https://factoryboy.readthedocs.io/en/latest/orms.html)
  • 直接的 Model.object.create 调用,如果工厂还未被引入项目中

我们的示例服务做了三件事情:

  • 调用了一个选择器用以验证
  • 创建了一个ORM对象
  • 调用了一个任务

我们的测试实现如下:

from unittest.mock import patch

from django.test import TestCase
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError

from django_styleguide.payments.services import buy_item
from django_styleguide.payments.models import Payment, Item


class BuyItemTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user(username='Test User')
        self.item = Item.objects.create(
            name='Test Item',
            description='Test Item description',
            price=10.15
        )

        self.service = buy_item

    @patch('django_styleguide.payments.services.get_items_for_user')
    def test_buying_item_that_is_already_bought_fails(self, get_items_for_user_mock):
        """
        Since we already have tests for `get_items_for_user`,
        we can safely mock it here and give it a proper return value.
        """
        get_items_for_user_mock.return_value = [self.item]

        with self.assertRaises(ValidationError):
            self.service(user=self.user, item=self.item)

    @patch('django_styleguide.payments.services.charge_payment.delay')
    def test_buying_item_creates_a_payment_and_calls_charge_task(
        self,
        charge_payment_mock
    ):
        self.assertEqual(0, Payment.objects.count())

        payment = self.service(user=self.user, item=self.item)

        self.assertEqual(1, Payment.objects.count())
        self.assertEqual(payment, Payment.objects.first())

        self.assertFalse(payment.successful)

        charge_payment_mock.assert_called()

测试选择器:

测试选择器也是测试的重要一环
有时,选择器的逻辑非常简单,以下是对于示例选择器的一个测试:

from django.test import TestCase
from django.contrib.auth.models import User

from django_styleguide.payments.selectors import get_items_for_user
from django_styleguide.payments.models import Item, Payment


class GetItemsForUserTests(TestCase):
    def test_selector_returns_nothing_for_user_without_items(self):
        """
        This is a "corner case" test.
        We should get nothing if the user has no items.
        """
        user = User.objects.create_user(username='Test User')

        expected = []
        result = list(get_items_for_user(user=user))

        self.assertEqual(expected, result)

    def test_selector_returns_item_for_user_with_that_item(self):
        """
        This test will fail in case we change the model structure.
        """
        user = User.objects.create_user(username='Test User')

        item = Item.objects.create(
            name='Test Item',
            description='Test Item description',
            price=10.15
        )

        Payment.objects.create(
            item=item,
            user=user
        )

        expected = [item]
        result = list(get_items_for_user(user=user))

        self.assertEqual(expected, result)

你可能感兴趣的:(Django | 代码分层与模块划分)