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

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

Сценарии (часть 2)

Git tag: Step06

Теперь, когда мы реализовали объекты запроса и ответа, добавляем их. Помещаем в файл 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 request_objects as ro
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)
    request_object = ro.StorageRoomListRequestObject.from_dict({})

    response_object = storageroom_list_use_case.execute(request_object)

    assert bool(response_object) is True
    repo.list.assert_called_with()

    assert response_object.value == domain_storagerooms

Новая версия файла rentomatic/use_case/storageroom_use_cases.py теперь выглядит следующим образом:

from rentomatic.shared import response_object as ro


class StorageRoomListUseCase(object):

   def __init__(self, repo):
       self.repo = repo

   def execute(self, request_object):
       storage_rooms = self.repo.list()
       return ro.ResponseSuccess (storage_rooms)

Давайте рассмотрим, чего же мы добились с этой чистой архитектурой. У нас есть очень легкая модель, сериализуемая в JSON и полностью независимая от других частей системы. Так же в коде содержится сценарий, в котором хранилище, предоставленное данным API, извлекает все модели и возвращает их внутри структурированного объекта.

Правда, мы упустили некоторые объекты. Например, мы не реализовали какой-либо отрицательный ответ или валидированный входящий объект запроса.

Для того, чтобы можно было рассмотреть эти упущенные части архитектуры, давайте изменим текущий сценарий, чтобы он принимал параметр filters, представляющий некоторые фильтры, применяемые к извлечению списка моделей. При передачи этого параметра могут возникать ошибки, поэтому нам придётся реализовать проверку для входящего объекта запроса.

Запросы и валидация

Git tag: Step07

Я хочу добавить параметр filters для запроса. Благодаря этому параметру вызывающая сторона может добавлять различные фильтры, указав имя и значение для каждого (например, { 'price_lt': 100} для получения всех результатов с ценой меньше, чем 100).

Первое, где мы начинаем наши правки, это тест. Новая версия файла tests/use_cases/test_storageroom_list_request_objects.py выглядит так:

import pytest

from rentomatic.use_cases import request_objects as ro


def test_valid_request_object_cannot_be_used():
    with pytest.raises(NotImplementedError):
        ro.ValidRequestObject.from_dict({})


def test_build_storageroom_list_request_object_without_parameters():
    req = ro.StorageRoomListRequestObject()

    assert req.filters is None
    assert bool(req) is True


def test_build_file_list_request_object_from_empty_dict():
    req = ro.StorageRoomListRequestObject.from_dict({})

    assert req.filters is None
    assert bool(req) is True


def test_build_storageroom_list_request_object_with_empty_filters():
    req = ro.StorageRoomListRequestObject(filters={})

    assert req.filters == {}
    assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_empty_filters():
    req = ro.StorageRoomListRequestObject.from_dict({'filters': {}})

    assert req.filters == {}
    assert bool(req) is True


def test_build_storageroom_list_request_object_with_filters():
    req = ro.StorageRoomListRequestObject(filters={'a': 1, 'b': 2})

    assert req.filters == {'a': 1, 'b': 2}
    assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_filters():
    req = ro.StorageRoomListRequestObject.from_dict({'filters': {'a': 1, 'b': 2}})

    assert req.filters == {'a': 1, 'b': 2}
    assert bool(req) is True


def test_build_storageroom_list_request_object_from_dict_with_invalid_filters():
    req = ro.StorageRoomListRequestObject.from_dict({'filters': 5})

    assert req.has_errors()
    assert req.errors[0]['parameter'] == 'filters'
    assert bool(req) is False

Проверяем assert req.filters is None для первоначальных двух тестов, а затем я добавил ещё 5 тестов для проверки, могут ли фильтры быть уточнены, и проверить поведение объекта с недопустимым параметром фильтра.

Для того, чтобы тесты прошли, необходимо изменить наш класс StorageRoomListRequestObject. Естественно, имеется множество возможных решений, которые вы можете придумать, и я рекомендую вам попробовать найти свой собственный. Здесь описано то решение, которое я обычно используют сам. Файл rentomatic/use_cases/request_object.py теперь выглядит как

import collections


class InvalidRequestObject(object):

    def __init__(self):
        self.errors = []

    def add_error(self, parameter, message):
        self.errors.append({'parameter': parameter, 'message': message})

    def has_errors(self):
        return len(self.errors) > 0

    def __nonzero__(self):
        return False

    __bool__ = __nonzero__


class ValidRequestObject(object):

    @classmethod
    def from_dict(cls, adict):
        raise NotImplementedError

    def __nonzero__(self):
        return True

    __bool__ = __nonzero__


class StorageRoomListRequestObject(ValidRequestObject):

    def __init__(self, filters=None):
        self.filters = filters

    @classmethod
    def from_dict(cls, adict):
        invalid_req = InvalidRequestObject()

        if 'filters' in adict and not isinstance(adict['filters'], collections.Mapping):
            invalid_req.add_error('filters', 'Is not iterable')

        if invalid_req.has_errors():
            return invalid_req

        return StorageRoomListRequestObject(filters=adict.get('filters', None))

Давайте я поясню этот новый код.

Во-первых, были введены два вспомогательные объекты, ValidRequestObject и InvalidRequestObject. Они отличаются друг от друга потому, что неправильный запрос должен содержать ошибки валидации, но при этом они оба должны преобразовываться к булеву значению.

Во-вторых, StorageRoomListRequestObject принимает опциональный параметр filters в момент создания. В методе __init __ (), нет никаких проверок на валидность, так, как он считается внутренним методом, который вызывается уже после того, как параметры уже были подтверждены.

В итоге, метод from_dict() проверяет наличие параметра filters. Я использую абстрактный класс collections.Mapping для проверки, что входящие параметры являются словарями, и что возвращается экземпляр объектов InvalidRequestObject или ValidRequestObject.

Раз уж мы теперь можем сообщить о наличии плохих или хороших запросов, нам необходимо ввести новый тип ответа для управления плохими ответами или ошибками в сценарии.

Ответы и провалы

Git tag: Step08

Что произойдет, если в сценарии возникает ошибка? Сценарии могут столкнуться с большим количеством ошибок: не только ошибки валидации, о которых мыговорили в предыдущем разделе, но и бизнес ошибки или ошибки из слоя хранилища. Какой бы ни была ошибка, сценарий должен всегда возвращать объект с известной структурой (ответ), поэтому нам нужен новый объект, который обеспечивает хорошую поддержку для различных типов провалов.

Как и с запросами, нет единственного верного способа представления такого объекта, и следующий код является лишь одним из возможных решений.

Первое, что нужно сделать, это расширить файл tests/shared/test_response_object.py, добавив, тесты для провальных случаев.

import pytest

from rentomatic.shared import response_object as res
from rentomatic.use_cases import request_objects as req


@pytest.fixture
def response_value():
    return {'key': ['value1', 'value2']}


@pytest.fixture
def response_type():
    return 'ResponseError'


@pytest.fixture
def response_message():
    return 'This is a response error'

Это шаблонный код, основанный на фикстурах pytest, которые мы будем использовать в следующих тестах.

def test_response_success_is_true(response_value):
    assert bool(res.ResponseSuccess(response_value)) is True


def test_response_failure_is_false(response_type, response_message):
    assert bool(res.ResponseFailure(response_type, response_message)) is False

Два базовых теста для проверки того, что прежний ResponseSuccess и новый ResponseFailure ведут себя согласовано при преобразовании в булево значение.

def test_response_success_contains_value(response_value):
    response = res.ResponseSuccess(response_value)

    assert response.value == response_value

Объект ResponseSuccess содержит результат вызова в атрибуте value.

def test_response_failure_has_type_and_message(response_type, response_message):
    response = res.ResponseFailure(response_type, response_message)

    assert response.type == response_type
    assert response.message == response_message


def test_response_failure_contains_value(response_type, response_message):
    response = res.ResponseFailure(response_type, response_message)

    assert response.value == {'type': response_type, 'message': response_message}

Эти два теста гарантируют, что объект ResponseFailure обеспечивает тот же интерфейс, что и при успехе, и что у этого объекта имеются параметры type и message.

def test_response_failure_initialization_with_exception():
    response = res.ResponseFailure(response_type, Exception('Just an error message'))

    assert bool(response) is False
    assert response.type == response_type
    assert response.message == "Exception: Just an error message"


def test_response_failure_from_invalid_request_object():
    response = res.ResponseFailure.build_from_invalid_request_object(req.InvalidRequestObject())

    assert bool(response) is False


def test_response_failure_from_invalid_request_object_with_errors():
    request_object = req.InvalidRequestObject()
    request_object.add_error('path', 'Is mandatory')
    request_object.add_error('path', "can't be blank")

    response = res.ResponseFailure.build_from_invalid_request_object(request_object)

    assert bool(response) is False
    assert response.type == res.ResponseFailure.PARAMETERS_ERROR
    assert response.message == "path: Is mandatorynpath: can't be blank"

Иногда необходимо создать ответы от Python-исключений, которые могут произойти в сценариях, поэтому мы проверяем, что объекты ResponseFailure можно инициализировать с исключением.

И последнее, у нас есть тесты для метода build_from_invalid_request_object(), автоматизирующие инициализацию ответа от недопустимого запроса. Если запрос содержит ошибки (помним, запрос проверяет себя), мы должны передать их в ответном сообщении.

Последний тест использует атрибут класса для классификации ошибки. Класс ResponseFailure будет содержать три предопределенных ошибки, которые могут произойти при выполнении сценария: RESOURCE_ERROR, PARAMETERS_ERROR и SYSTEM_ERROR. Подобным разделением мы пытаемся охватить различные виды ошибок, которые могут произойти при работе с внешней системой через API. RESOURCE_ERROR содержит ошибки, связанные с ресурсами, содержащимися в хранилище, например, когда вы не можете найти запись по её уникальному идентификатору. PARAMETERS_ERROR описывает ошибки, возникающие при неправильных или пропущенных параметрах запроса. SYSTEM_ERROR охватывает ошибки, происходящие в базовой системе на уровне операционной системы, такие как сбой в работе файловой системы или ошибка подключения к сети во время выборки данных из базы данных.

Сценарий ответственен за взаимодействие с различными ошибками, возникающими в Python-коде, и преобразует их один из трёх только что описанных типов сообщения, имеющего описание данной ошибки.

Давайте напишем класс ResponseFailure, который позволяет тестам успешно выполняться. Создадим его в rentomatic/shared/response_object.py

class ResponseFailure(object):
    RESOURCE_ERROR = 'RESOURCE_ERROR'
    PARAMETERS_ERROR = 'PARAMETERS_ERROR'
    SYSTEM_ERROR = 'SYSTEM_ERROR'

    def __init__(self, type_, message):
        self.type = type_
        self.message = self._format_message(message)

    def _format_message(self, msg):
        if isinstance(msg, Exception):
            return "{}: {}".format(msg.__class__.__name__, "{}".format(msg))
        return msg

С помощью метода _format_message() мы позволяем классу принимать как строковое сообщение, так и Python-исключение, что очень удобно при работе с внешними библиотеками, которые могут вызывать неизвестные или не интересующие нас исключения.

@property
def value(self):
    return {'type': self.type, 'message': self.message}

Это свойство делает класс согласованным с API ResponseSuccess, предоставляя атрибут value, являющийся словарём.

def __bool__(self):
    return False


@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
    message = "n".join(["{}: {}".format(err['parameter'], err['message'])
                         for err in invalid_request_object.errors])
    return cls(cls.PARAMETERS_ERROR, message)

Как было объяснено выше, тип PARAMETERS_ERROR охватывает все те ошибки, которые происходят при неверном наборе передаваемых параметров, то есть, некоторые параметры содержат ошибки или пропущены.

Поскольку нам часто придётся создавать ответы с провалом, полезно иметь вспомогательные методы. Добавляю три теста для функций-построителей в файле tests/shared/test_response_object.py

def test_response_failure_build_resource_error():
    response = res.ResponseFailure.build_resource_error("test message")

    assert bool(response) is False
    assert response.type == res.ResponseFailure.RESOURCE_ERROR
    assert response.message == "test message"


def test_response_failure_build_parameters_error():
    response = res.ResponseFailure.build_parameters_error("test message")

    assert bool(response) is False
    assert response.type == res.ResponseFailure.PARAMETERS_ERROR
    assert response.message == "test message"


def test_response_failure_build_system_error():
    response = res.ResponseFailure.build_system_error("test message")

    assert bool(response) is False
    assert response.type == res.ResponseFailure.SYSTEM_ERROR
    assert response.message == "test message"

Мы добавили соответствующие методы в классе и добавили использование нового метода build_parameters_error() в методе build_from_invalid_request_object(). В файле rentomatic/shared/response_object.py теперь должен находиться такой код

   @classmethod
def build_resource_error(cls, message=None):
    return cls(cls.RESOURCE_ERROR, message)

@classmethod
def build_system_error(cls, message=None):
    return cls(cls.SYSTEM_ERROR, message)

@classmethod
def build_parameters_error(cls, message=None):
    return cls(cls.PARAMETERS_ERROR, message)

@classmethod
def build_from_invalid_request_object(cls, invalid_request_object):
    message = "n".join(["{}: {}".format(err['parameter'], err['message'])
                         for err in invalid_request_object.errors])
    return cls.build_parameters_error(message)
 

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

Автор: CJay

Источник

Поделиться

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