Pull to refresh

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

Reading time 10 min
Views 14K
Original author: Leonardo Giordani

Содержание

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


Git tag: Step09


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


Давайте изменим тест в файле tests/use_cases/test_storageroom_list_use_case.py и добавим ещё 2 теста. Полученный набор тестов (после фикстуры domain_storagerooms) выглядит следующим образом:


import pytest
from unittest import mock

from rentomatic.domain.storageroom import StorageRoom
from rentomatic.shared import response_object as res
from rentomatic.use_cases import request_objects as req
from rentomatic.use_cases import storageroom_use_cases as uc


@pytest.fixture
def domain_storagerooms():
    […]

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 = req.StorageRoomListRequestObject.from_dict({})

    response_object = storageroom_list_use_case.execute(request_object)

    assert bool(response_object) is True
    repo.list.assert_called_with(filters=None)

    assert response_object.value == domain_storagerooms

Этот тест отличается от предыдущего тем, что теперь метод assert_called_with() вызывается с параметром filters=None. В строках с импортом появились response_objects и request_objects. Фикстура Domain_storagerooms была исключена из кода для лаконичности.


def test_storageroom_list_with_filters(domain_storagerooms):
    repo = mock.Mock()
    repo.list.return_value = domain_storagerooms

    storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
    qry_filters = {'a': 5}
    request_object = req.StorageRoomListRequestObject.from_dict({'filters': qry_filters})

    response_object = storageroom_list_use_case.execute(request_object)

    assert bool(response_object) is True
    repo.list.assert_called_with(filters=qry_filters)
    assert response_object.value == domain_storagerooms

Этот тест проверяет, что при вызове хранилища используется значение ключа filters, который используется и для создания запросов.


def test_storageroom_list_handles_generic_error():
    repo = mock.Mock()
    repo.list.side_effect = Exception('Just an error message')

    storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
    request_object = req.StorageRoomListRequestObject.from_dict({})

    response_object = storageroom_list_use_case.execute(request_object)

    assert bool(response_object) is False
    assert response_object.value == {
        'type': res.ResponseFailure.SYSTEM_ERROR,
        'message': "Exception: Just an error message"
    }


def test_storageroom_list_handles_bad_request():
    repo = mock.Mock()

    storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
    request_object = req.StorageRoomListRequestObject.from_dict({'filters': 5})

    response_object = storageroom_list_use_case.execute(request_object)

    assert bool(response_object) is False
    assert response_object.value == {
        'type': res.ResponseFailure.PARAMETERS_ERROR,
        'message': "filters: Is not iterable"
    }

Эти последние два теста проверяют поведение сценария при возникновении исключения в хранилище или при некорректном запросе.


Изменим файл rentomatic/use_cases/storageroom_use_cases.py так, чтобы он содержал новую реализацию сценария, позволяющую тестам проходить успешно.


from rentomatic.shared import response_object as res


class StorageRoomListUseCase(object):

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

    def execute(self, request_object):
        if not request_object:
            return res.ResponseFailure.build_from_invalid_request_object(request_object)

        try:
            storage_rooms = self.repo.list(filters=request_object.filters)
            return res.ResponseSuccess(storage_rooms)
        except Exception as exc:
            return res.ResponseFailure.build_system_error(
                "{}: {}".format(exc.__class__.__name__, "{}".format(exc)))

Как видите, метод execute() проверяет корректность запроса, в противном случае возвращает ResponseFailure, созданный на основе того же объекта запроса. Вот теперь бизнес-логика реализована, вызывая хранилище и возвращая успешный ответ. Если же в этой фазе что-то пойдет не так, перехватывается исключение и возвращается сформированный нужным образом ResponseFailure.


Антракт: рефакторинг


Git tag: Step10


Чистая архитектура — это не фреймворк. Не стоит ждать от него столько же, сколько от таких продуктов, как Django, который предоставляет модели, ORM, всевозможные структуры и библиотеки. Тем не менее, некоторые классы могут быть выделены из нашего кода и представлены в виде библиотеки, так, чтобы можно было повторно использовать этот код. В этом разделе я познакомлю вас с рефакторингом уже существующего кода, при помощи которого мы изолирует основные фичи для запросов, ответов и сценариев.


Мы уже выделили объект ответа. Так как test_valid_request_object_cannot_be_used проверяет общее поведение, а не связь между моделью StorageRoom и сценариями, то мы можем переместить его из tests/use_cases/test_storageroom_list_request_objects.py в tests/shared/test_response_object.py.


Затем мы можем переместить классы InvalidRequestObject и ValidRequestObject из rentomatic/use_cases/request_objects.py в rentomatic/shared/request_object.py, внося необходимые изменения в классе StorageRoomListRequestObject, который теперь наследуется от внешнего класса.


А вот класс сценария подвергается значительным изменениям. Класс UseCase тестируем кодом файла tests/shared/test_use_case.py:


from unittest import mock

from rentomatic.shared import request_object as req, response_object as res
from rentomatic.shared import use_case as uc


def test_use_case_cannot_process_valid_requests():
    valid_request_object = mock.MagicMock()
    valid_request_object.__bool__.return_value = True

    use_case = uc.UseCase()
    response = use_case.execute(valid_request_object)

    assert not response
    assert response.type == res.ResponseFailure.SYSTEM_ERROR
    assert response.message == \
        'NotImplementedError: process_request() not implemented by UseCase class'

Этот тест проверяет, что класс UseCase нельзя использовать для обработки входящих запросов.


def test_use_case_can_process_invalid_requests_and_returns_response_failure():
    invalid_request_object = req.InvalidRequestObject()
    invalid_request_object.add_error('someparam', 'somemessage')

    use_case = uc.UseCase()
    response = use_case.execute(invalid_request_object)

    assert not response
    assert response.type == res.ResponseFailure.PARAMETERS_ERROR
    assert response.message == 'someparam: somemessage'

Тест выполняет сценарий с некорректным запросом и проверяет ответ. Так как запрос неправильный, типом ответа является PARAMETERS_ERROR, тем самым говоря о наличии проблемы в параметрах запроса.


def test_use_case_can_manage_generic_exception_from_process_request():
    use_case = uc.UseCase()

    class TestException(Exception):
        pass

    use_case.process_request = mock.Mock()
    use_case.process_request.side_effect = TestException('somemessage')
    response = use_case.execute(mock.Mock)

    assert not response
    assert response.type == res.ResponseFailure.SYSTEM_ERROR
    assert response.message == 'TestException: somemessage'

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


Как видно из последнего теста, идея состоит в том, чтобы предоставить метод execute() класса UseCase и вызвать метод process_request(), который определён в каждом дочернем классе, являющимся нашим сценарием.


Файл rentomatic/shared/use_case.py содержит следующий код для успешного прохождения теста:


from rentomatic.shared import response_object as res


class UseCase(object):

    def execute(self, request_object):
        if not request_object:
            return res.ResponseFailure.build_from_invalid_request_object(request_object)
        try:
            return self.process_request(request_object)
        except Exception as exc:
            return res.ResponseFailure.build_system_error(
                "{}: {}".format(exc.__class__.__name__, "{}".format(exc)))

    def process_request(self, request_object):
        raise NotImplementedError(
            "process_request() not implemented by UseCase class")

И вот теперь файл rentomatic/use_cases/storageroom_use_cases.py содержит следующий код:


from rentomatic.shared import use_case as uc
from rentomatic.shared import response_object as res


class StorageRoomListUseCase(uc.UseCase):

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

    def process_request(self, request_object):
        domain_storageroom = self.repo.list(filters=request_object.filters)
        return res.ResponseSuccess(domain_storageroom)

Слой хранилища


Git tag: Step11


Слой хранилища — это тот слой, в котором имеется система хранения данных. Как вы видели, когда мы реализовали сценарий, мы получали доступ к хранилищу данных через API, в данном случае через метод хранилища list(). Уровень абстракции, предоставляемой уровнем хранилища, выше, чем уровень, предоставляемый ORM или таким инструментом, как SQLAlchemy. Слой хранилища обеспечивает только необходимые для приложения конечные точки с интерфейсом, который адаптирован для конкретных бизнес-задач и целей приложения.


Для ясности, говоря в терминах конкретных технологий, SQLAlchemy является прекрасным инструментом для абстрактного доступа к базе данных SQL. Внутренняя реализация хранилища слоя может использовать его для доступа к базе данных PostgreSQL. Но внешний API слоя — не то же самое, что даёт SQLAlchemy. API — это (как правило, уменьшенное) множество функций, которые вызывают сценарии для получения данных. В самом деле внутренняя реализация API может использовать сырые запросы SQL. Хранилище даже может не быть основанным на базе данных. Мы можем иметь слой хранилища, который извлекает данные из службы REST или делает удаленные вызовы процедур через RabbitMQ.


Очень важной особенностью слоя хранилища является то, что он всегда возвращает модели предметной области (как и ORM-фреймворки).


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


Вместо этого я собираюсь создать очень простую систему хранения в памяти с некоторыми заранее заданными данными. Думаю, для демонстрации концепции хранилища этого будет достаточно.


Первое, что нужно сделать, это написать несколько тестов, которые описывают публичный API хранилища. Файл tests/repository/test_memrepo.py содержит тесты для данной задачи.


Сначала мы добавим данные, которые будем использовать в тестах. Импортируем доменную модель для проверки корректного типа результата вызова API:


import pytest

from rentomatic.shared.domain_model import DomainModel

from rentomatic.repository import memrepo


storageroom1 = {
    'code': 'f853578c-fc0f-4e65-81b8-566c5dffa35a',
    'size': 215,
    'price': 39,
    'longitude': '-0.09998975',
    'latitude': '51.75436293',
}

storageroom2 = {
    'code': 'fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
    'size': 405,
    'price': 66,
    'longitude': '0.18228006',
    'latitude': '51.74640997',
}

storageroom3 = {
    'code': '913694c6-435a-4366-ba0d-da5334a611b2',
    'size': 56,
    'price': 60,
    'longitude': '0.27891577',
    'latitude': '51.45994069',
}

storageroom4 = {
    'code': 'eed76e77-55c1-41ce-985d-ca49bf6c0585',
    'size': 93,
    'price': 48,
    'longitude': '0.33894476',
    'latitude': '51.39916678',
}


@pytest.fixture
def storagerooms():
    return [storageroom1, storageroom2, storageroom3, storageroom4]

Поскольку объект хранилища будет возвращать модели предметной области, нам нужна вспомогательная функция для проверки правильности результатов. Следующая функция проверяет длину двух списков и гарантирует, что все возвращенные элементы модели из предметной области, и сравнивает коды. Обратите внимание, что мы можем использовать встроенную функцию isinstance(), поскольку DomainModel является абстрактным базовым классом и наши модели зарегистрированы (см rentomatic/domian/storagerooms.py)


def _check_results(domain_models_list, data_list):
    assert len(domain_models_list) == len(data_list)
    assert all([isinstance(dm, DomainModel) for dm in domain_models_list])
    assert set([dm.code for dm in domain_models_list]) == set([d['code'] for d in data_list])

Нам нужно иметь возможность инициализировать хранилище со списком словарей, и чтобы вызов метода list() без какого-либо параметра возвращал тот же список записей.


def test_repository_list_without_parameters(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    assert repo.list() == storagerooms

Метод list() должен принимать параметр filters, являющийся словарём. Ключи словаря должны быть в виде <attribute>__<operator>, похожий на синтаксис, используемый в Django ORM. Таким образом, чтобы выразить, что цена должна быть меньше 65, можно записать filters={'price__lt': 60}.


Нужно проверить несколько условий возникновения ошибки: неизвестный ключ должен вызвать исключение KeyError, а использование неверного оператора должно вызвать исключение ValueError.


def test_repository_list_with_filters_unknown_key(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    with pytest.raises(KeyError):
        repo.list(filters={'name': 'aname'})


def test_repository_list_with_filters_unknown_operator(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    with pytest.raises(ValueError):
        repo.list(filters={'price__in': [20, 30]})

Давайте проверим, что механизм фильтрации работает. Мы хотим, чтобы оператором по умолчанию был __eq. Это означает, что если мы не передаём какой-либо оператор, должен выполняться оператор равенства.


def test_repository_list_with_filters_price(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    _check_results(repo.list(filters={'price': 60}), [storageroom3])
    _check_results(repo.list(filters={'price__eq': 60}), [storageroom3])
    _check_results(repo.list(filters={'price__lt': 60}), [storageroom1, storageroom4])
    _check_results(repo.list(filters={'price__gt': 60}), [storageroom2])


def test_repository_list_with_filters_size(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    _check_results(repo.list(filters={'size': 93}), [storageroom4])
    _check_results(repo.list(filters={'size__eq': 93}), [storageroom4])
    _check_results(repo.list(filters={'size__lt': 60}), [storageroom3])
    _check_results(repo.list(filters={'size__gt': 400}), [storageroom2])


def test_repository_list_with_filters_code(storagerooms):
    repo = memrepo.MemRepo(storagerooms)

    _check_results(
        repo.list(filters={'code': '913694c6-435a-4366-ba0d-da5334a611b2'}), [storageroom3])

Реализация класса MemRepo довольно проста, и я не буду объяснять её построчно.


from rentomatic.domain import storageroom as sr


class MemRepo:

    def __init__(self, entries=None):
        self._entries = []
        if entries:
            self._entries.extend(entries)

    def _check(self, element, key, value):
        if '__' not in key:
            key = key + '__eq'

        key, operator = key.split('__')

        if operator not in ['eq', 'lt', 'gt']:
            raise ValueError('Operator {} is not supported'.format(operator))

        operator = '__{}__'.format(operator)

        return getattr(element[key], operator)(value)

    def list(self, filters=None):
        if not filters:
            return self._entries

        result = []
        result.extend(self._entries)

        for key, value in filters.items():
            result = [e for e in result if self._check(e, key, value)]

        return [sr.StorageRoom.from_dict(r) for r in result]


Продолжение в Часть 5
Tags:
Hubs:
+14
Comments 0
Comments Leave a comment

Articles