
Доменные модели
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.pyimport 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.pyclass 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.pyfrom rentomatic.shared import response_object as ro def test_response_success_is_true(): assert bool(ro.ResponseSuccess()) is True
и фактический объект ответа находится в файле
rentomatic/shared/response_object.pyclass ResponseSuccess(object): def __init__(self, value=None): self.value = value def __nonzero__(self): return True __bool__ = __nonzero__
Продолжение следует в Части 3.