本文翻译自 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_at
和create_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_profile
和 send_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
- 使用最简单的
APIView
和GenericAPIView
- 调用服务与选择器,不在API中处理业务逻辑
- 使用序列化器从参数中取得对象,参数通过一个GET或POST请求传递
- 序列化器需要作为API类的子类,使用
InputSerializer
或OutputSerializer
来命名- 如果需要,
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 可以理解的异常。
进行这项工作最好的地方是在 APIView
的 handle_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)