[Перевод] Чистая архитектура в Python: пошаговая демонстрация. Часть 2
- Часть 1
- Часть 2
Доменные модели
Git tag: Step02
Начнем с простого определения модели StorageRoom
. Как было сказано ранее, модели в чистой архитектуре очень легкие, по крайней мере, легче, чем их ORM-аналоги в фреймворках.
Раз мы следуем методологии TDD, то первое, что мы напишем, это тесты. Создадим файл tests/domain/test_storageroom.py
и поместим внутри него этот код:
import uuid
from rentomatic.domain.storageroom import StorageRoom
def test_storageroom_model_init():
code = uuid.uuid4()
storageroom = StorageRoom(code, size=200, price=10,
longitude='-0.09998975',
latitude='51.75436293')
assert storageroom.code == code
assert storageroom.size == 200
assert storageroom.price == 10
assert storageroom.longitude == -0.09998975
assert storageroom.latitude == 51.75436293
def test_storageroom_model_from_dict():
code = uuid.uuid4()
storageroom = StorageRoom.from_dict(
{
'code': code,
'size': 200,
'price': 10,
'longitude': '-0.09998975',
'latitude': '51.75436293'
}
)
assert storageroom.code == code
assert storageroom.size == 200
assert storageroom.price == 10
assert storageroom.longitude == -0.09998975
assert storageroom.latitude == 51.75436293
Эти два теста гарантируют, что наша модель может быть инициализированы с корректными переданными ей значениями или при помощи словаря. В первом случае требуется указать все параметры модели. Позже можно будет сделать некоторые из них опциональными, предварительно написав необходимые тесты.
Пока же давайте напишем класс StorageRoom
, разместив его в файле rentomatic/domain/storageroom.py
. Не забываем создать файл __init__.py
в каждом подкаталоге проекта, которые Python должен воспринимать как модули.
from rentomatic.shared.domain_model import DomainModel
class StorageRoom(object):
def __init__(self, code, size, price, latitude, longitude):
self.code = code
self.size = size
self.price = price
self.latitude = float(latitude)
self.longitude = float(longitude)
@classmethod
def from_dict(cls, adict):
room = StorageRoom(
code=adict['code'],
size=adict['size'],
price=adict['price'],
latitude=adict['latitude'],
longitude=adict['longitude'],
)
return room
DomainModel.register(StorageRoom)
Модель очень проста и не требует пояснений. Одним из преимуществ чистой архитектуры является то, что каждый слой содержит небольшие кусочки кода, которые, будучи изолированы, должны выполнять простые задачи. В нашем случае, модель предоставляет API для инициализации и сохранения информации внутри класса.
Метод from_dict
полезен при создании модели из данных, поступающих из другого слоя (такого как слой базы данных или из строки запроса в REST слое).
Может возникнуть соблазн попытаться упростить функцию from_dict
, абстрагируя и предоставляя ее как метод класса Model. И учитывая, что определенный уровень абстракции и обобщения возможен и нужен, а инициализация моделей может взаимодействовать с различными иными сценариями, лучше реализовать её непосредственно в самом классе.
Абстрактный базовый класс DomainModel
— это простой способ классифицировать модель для будущих сценариев, таких как проверка на принадлежности класса к модели в системе. Для получения дополнительной информации об использовании Абстрактных Базовых Классов в Python советую почитать этот пост.
Сериализаторы
Git tag: Step03
Если мы хотим вернуть нашу модель как результат вызова API, то её нужно будет сериализовать. Типичный формат сериализации это JSON, так как это широко распространённый стандарт, используемый для веб-API. Сериализатор не является частью модели. Это внешний специальный класс, который получает экземпляр модели и переводит её структуру и значения в некоторое представление.
Для тестирования JSON-сериализации нашего класса StorageRoom
поместите в файл tests/serializers/test_storageroom_serializer.py
следующий код
import datetime
import pytest
import json
from rentomatic.serializers import storageroom_serializer as srs
from rentomatic.domain.storageroom import StorageRoom
def test_serialize_domain_storageroom():
room = StorageRoom('f853578c-fc0f-4e65-81b8-566c5dffa35a',
size=200,
price=10,
longitude='-0.09998975',
latitude='51.75436293')
expected_json = """
{
"code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
"size": 200,
"price": 10,
"longitude": -0.09998975,
"latitude": 51.75436293
}
"""
assert json.loads(json.dumps(room, cls=srs.StorageRoomEncoder)) == json.loads(expected_json)
def test_serialize_domain_storageruum_wrong_type():
with pytest.raises(TypeError):
json.dumps(datetime.datetime.now(), cls=srs.StorageRoomEncoder)
Поместите в файл
rentomatic/serializers/storageroom_serializer.py
код, который проходит тест: import json
class StorageRoomEncoder(json.JSONEncoder):
def default(self, o):
try:
to_serialize = {
'code': o.code,
'size': o.size,
'price': o.price,
"latitude": o.latitude,
"longitude": o.longitude,
}
return to_serialize
except AttributeError:
return super().default(o)
Предоставляя класс, унаследованный от
JSON.JSONEncoder
, используем json.dumps(room, cls = StorageRoomEncoder)
для сериализации модели.Мы можем заметить некоторое повторение в коде. Это минус чистой архитектуры, который раздражает. Поскольку мы хотим максимально изолировать слои и создать облегченные классы, мы, в конечном итоге, повторяем некоторые действия. Например, код сериализации, который присваивает атрибуты от StorageRoom
на атрибуты JSON, схож с тем, что мы используем для создания объекта из словаря. Не одно и тоже, но сходство этих двух функций имеется.
Сценарии (часть 1)
Git tag: Step04
Пришло время реализовать реальную бизнес-логику нашего приложения, которая будет доступна снаружи. Сценарии — это то место, где мы реализуем классы, которые запрашивают хранилище, применяют бизнес-правила, логику, трансформируют данные как нашей душе угодно, и возвращают результат.
С учетом этих требований, давайте начнем последовательно строить сценарий. Наипростейший сценарий, который мы можем создать, это тот, что извлекает все складские помещения из хранилища и возвращает их. Обратите внимание, что мы пока не реализовали слой хранилища, поэтому в наши тестах мы будем его мокать (подменять фикцией).
Вот основа для простого теста сценария, который выводит список всех складских помещений. Поместите этот код в файл tests/use_cases/test_storageroom_list_use_case.py
import pytest
from unittest import mock
from rentomatic.domain.storageroom import StorageRoom
from rentomatic.use_cases import storageroom_use_cases as uc
@pytest.fixture
def domain_storagerooms():
storageroom_1 = StorageRoom(
code='f853578c-fc0f-4e65-81b8-566c5dffa35a',
size=215,
price=39,
longitude='-0.09998975',
latitude='51.75436293',
)
storageroom_2 = StorageRoom(
code='fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
size=405,
price=66,
longitude='0.18228006',
latitude='51.74640997',
)
storageroom_3 = StorageRoom(
code='913694c6-435a-4366-ba0d-da5334a611b2',
size=56,
price=60,
longitude='0.27891577',
latitude='51.45994069',
)
storageroom_4 = StorageRoom(
code='eed76e77-55c1-41ce-985d-ca49bf6c0585',
size=93,
price=48,
longitude='0.33894476',
latitude='51.39916678',
)
return [storageroom_1, storageroom_2, storageroom_3, storageroom_4]
def test_storageroom_list_without_parameters(domain_storagerooms):
repo = mock.Mock()
repo.list.return_value = domain_storagerooms
storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
result = storageroom_list_use_case.execute()
repo.list.assert_called_with()
assert result == domain_storagerooms
Тест прост. Сначала мы подменили хранилище так, чтобы он предоставлял метод
list()
, возвращающий список заранее созданных выше моделей. Затем мы инициализируем сценарий с хранилищем и выполняем его, запоминая результат. Первое, что мы проверяем, это что метод хранилища был вызван без какого-либо параметра, а второе, это правильность результата.А вот реализация сценария, которая проходит тест. Поместите код в файл rentomatic/use_cases/storageroom_use_case.py
class StorageRoomListUseCase(object):
def __init__(self, repo):
self.repo = repo
def execute(self):
return self.repo.list()
Однако, при такой реализации сценария мы вскоре столкнёмся с проблемой. Во-первых, у нас нет стандартного способа передачи параметров вызова, а это значит, что у нас нет стандартного способа для проверки их корректности. Следующая проблема состоит в том, что мы упускаем стандартный способ возвращения результатов вызова, и, следовательно, мы не можем узнать, был ли вызов успешен или нет, и если нет, то по какой причине. Та же проблема и с неверными параметрами, рассмотренными в предыдущем пункте.
Таким образом, мы хотим, чтобы были введены некоторые структуры для оборачивания входных и выходных данных наших сценариев. Эти структуры называются объектами запроса и ответа.
Запросы и ответы
Git tag: Step05
Запросы и ответы — это важная часть чистой архитектуры. Они перемещают параметры вызова, входные данные и результаты вызова между слоем сценариев и внешним окружением.
Запросы создаются на основе входящих вызовов API, так что им предстоит столкнуться с такими штуками, как неправильные значения, пропущенные параметры, неверный формат и т.д. Ответы, с другой стороны, должны содержать результаты вызовов API, в том числе должны представлять ошибки и давать подробную информацию о том, что произошло.
Вы вправе использовать любую реализацию запросов и ответов, чистая архитектура ничего не говорит об этом. Решение о том, как упаковать и представить данные, полностью лежит на вас.
Ну, а пока нам просто необходим StorageRoomListRequestObject
, который может быть инициализирован без параметров, так что, давайте создадим файл tests/use_cases/test_storageroom_list_request_objects.py
и поместим в него тест для этого объекта.
from rentomatic.use_cases import request_objects as ro
def test_build_storageroom_list_request_object_without_parameters():
req = ro.StorageRoomListRequestObject()
assert bool(req) is True
def test_build_file_list_request_object_from_empty_dict():
req = ro.StorageRoomListRequestObject.from_dict({})
assert bool(req) is True
На данный момент объект запроса пуст, но он нам пригодится сразу, как только у нас появятся параметры для сценария, выдающего список объектов. Код для класса
StorageRoomListRequestObject
находится в файле rentomatic/use_cases/request_objects.py
и имеет следующий вид: class StorageRoomListRequestObject(object):
@classmethod
def from_dict(cls, adict):
return StorageRoomListRequestObject()
def __nonzero__(self):
return True
Запрос так же довольно прост, так как на данный момент нам необходим только успешный ответ. В отличие от запроса, ответ не связан с каким-либо конкретным сценарием, так что файл теста можно назвать
tests/shared/test_response_object.py
from rentomatic.shared import response_object as ro
def test_response_success_is_true():
assert bool(ro.ResponseSuccess()) is True
и фактический объект ответа находится в файле
rentomatic/shared/response_object.py
class ResponseSuccess(object):
def __init__(self, value=None):
self.value = value
def __nonzero__(self):
return True
__bool__ = __nonzero__
Продолжение следует…
Комментарии (2)
10 января 2017 в 13:57
+1↑
↓
Никаких притензий к переводчику, так как уровень моего англиского стремится к нулю, но…Метод from_dict полезен при создании модели из данных, поступающих из другого слоя (такого как слой базы данных или из строки запроса в REST слое)
очень полезен, особенно если у меня 100500 атрибутов… как насчет распаковки аргументов в конструкторе?storageroom_1 = StorageRoom(**kwarg)
to_serialize = { 'code': o.code, 'size': o.size, 'price': o.price, "latitude": o.latitude, "longitude": o.longitude, }
Очень грамотно, особенно если учесть что завтра у меня появится еще одно поле, и мне его надо будет добавить (по коду автора):
— в модель,
— в сериалайзер
— anywereНе ставлю под сомнение пользу статьи, но не уж то практика с базовыми схемами, для моделей, валидаторов, сериализаторов настолько плоха, что нужно писать такие самокаты? (прошу агрументированно проправить если я не прав)
10 января 2017 в 15:47 (комментарий был изменён)
0↑
↓
Думаю, что распаковка аргументов в конструкторе — это здорово и питонично. Но мы тут её не видим, потому, что всякие концепции (чистого кода, DDD и прочее) описывались Робертом Мартиным, Эриком Эвансом, Мартиным Фаулером и прочими для статически типизированных языков как Java или C#. Просто опыт работы с данными методологиями ещё не обтесался и не питонизировался. Надеюсь, всё ещё впереди.