Чистая архитектура в Python: пошаговая демонстрация. Часть 2

в 10:19, , рубрики: clean code, flask, python, refactoring, tdd, Проектирование и рефакторинг, Совершенный код
Содержание

Доменные модели

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__

Продолжение следует...

Автор: CJay

Источник

Поделиться

* - обязательные к заполнению поля