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异步任务调用,和其他所有项目外的内容

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

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

  • 调用了一个选择器用以验证
  • 创建了一个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)
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,294评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,780评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,001评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,593评论 1 289
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,687评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,679评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,667评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,426评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,872评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,180评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,346评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,019评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,658评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,268评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,495评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,275评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,207评论 2 352

推荐阅读更多精彩内容

  • 去年有段时间得空,就把谷歌GAE的API权威指南看了一遍,收获颇丰,特别是在自己几乎独立开发了公司的云数据中心之后...
    骑单车的勋爵阅读 20,500评论 0 41
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,094评论 1 32
  • 第一部分 HTML&CSS整理答案 1. 什么是HTML5? 答:HTML5是最新的HTML标准。 注意:讲述HT...
    kismetajun阅读 27,467评论 1 45
  • iOS 苹果官方Demo合集 字数10517阅读21059评论18喜欢144 其实, 开发了这么久, 不得不说, ...
    bingo居然被占了阅读 10,134评论 2 31
  • 我怎么如此幸运,下班时间在路上走着,和天地人他我去沟通。 我怎么如此幸运,发现自己已经连上了公共能量意识体 我怎么...
    舒小曼laraine阅读 96评论 0 0