[Перевод] Чистая архитектура в Python: пошаговая демонстрация. Часть 5
- Часть 1
- Часть 2
- Часть 3
- Часть 4
- Часть 5
REST-слой (часть1)
Git tag: Step12
Наступил завершающий этап нашего приключения за чистой архитектурой. Мы создали модели предметной области, сериализаторы, сценарии и хранилище. Но пока отсутствует интерфейс, который склеивает все вместе: получает параметры вызова от пользователя, инициализирует сценарий с хранилищем, выполняет сценарий, который получает модели предметной области из хранилища, и преобразует их в стандартный формат. Этот слой может быть представлен с помощью множества интерфейсов и технологий. Например, с помощью интерфейса командной строки (CLI): получать параметры с помощью ключей командной строки и возвращать результат в виде текста на консоли. Но та же базовая система может быть использована и для web-страницы, которая получает параметры вызова из набора виджетов, выполняет описанные выше шаги, и разбирает возвращенные данные в формате JSON для отображения результата на той же странице.
Вне зависимости от выбранной технологии для взаимодействия с пользователем, сбора входных данных и предоставления выходных результатов, нам необходимо взаимодействовать с недавно созданной чистой архитектурой. Поэтому сейчас мы создадим слой для вынесения наружу API для работы с HTTP. Реализовано это будет при помощи сервера, который предоставляет набор HTTP-адресов (конечных точек API), при обращении к которым возвращаются некоторые данные. Такой слой обычно называют REST-слой, потому что, как правило, семантика адресов схожа с рекомендациями REST.
Flask — это легкий веб-сервер с модульной структурой, которая обеспечивает только необходимые пользователю части. В частности, мы не будем использовать какую-либо базу данных/ORM, так как у нас уже имеется собственный реализованный слой хранилища.
Замечу, что обычно этот слой вместе со слоем хранилища реализуется в виде отдельного пакета, но в рамках этого урока я разместил их вместе.
Обновим файл зависимостей. Файл prod.txt
должен содержать модуль Flask
Flask
Файл dev.txt
содержит расширение Flask-Script
-r test.txt
pip
wheel
flake8
Sphinx
Flask-Script
И в файл test.txt
добавим расширение pytest для работы с Flask (подробнее об этом позже)
-r prod.txt
pytest
tox
coverage
pytest-cov
pytest-flask
После этих изменений обязательно снова запустим pip install -r requirenments/dev.txt
, чтобы установить новые пакеты в виртуальную среду.
Настройка Flask-приложения проста, но включает много особенностей. Так как это не учебник по Flask, я бегло пройдусь по этим шагам. Тем не менее, я буду предоставлять ссылки на документацию по Flask для каждой особенности.
Обычно я определяют отдельные конфигурации для моей тестовой, девелоп и продакшн среды. Так как приложение Flask можно настроить с помощью обычного Python-объекта (документация), я создаю файл rentomatic/settings.py
для размещения этих объектов
import os
class Config(object):
"""Base configuration."""
APP_DIR = os.path.abspath(os.path.dirname(__file__)) # This directory
PROJECT_ROOT = os.path.abspath(os.path.join(APP_DIR, os.pardir))
class ProdConfig(Config):
"""Production configuration."""
ENV = 'prod'
DEBUG = False
class DevConfig(Config):
"""Development configuration."""
ENV = 'dev'
DEBUG = True
class TestConfig(Config):
"" "Тестовая конфигурация". ""
ENV = 'test'
TESTING = True
DEBUG = True
Чтобы узнать больше о параметрах конфигурации Flask, прочтите эту страницу. Теперь нам нужна функция, которая инициализирует приложение Flask (документация), настраивает его и регистрирует чертежи (документация). В файле rentomatic/app.py
содержится следующий код:
from flask import Flask
from rentomatic.rest import storageroom
from rentomatic.settings import DevConfig
def create_app(config_object=DevConfig):
app = Flask(__name__)
app.config.from_object(config_object)
app.register_blueprint(storageroom.blueprint)
return app
Конечные точки приложения должны возвращать Flask-объект Response
с актуальными результатами и HTTP-статусом. Содержанием ответа, в данном случае, является JSON -сериализация ответа сценария.
Начнём писать тесты шаг за шагом, что бы вы смогли хорошо понять, что будет происходить в конечной точке REST. Базовая структура теста выглядит следующим образом
[SOME PREPARATION]
[CALL THE API ENDPOINT]
[CHECK RESPONSE DATA]
[CHECK RESPONDSE STATUS CODE]
[CHECK RESPONSE MIMETYPE]
Поэтому, наш первый тест — tests/rest/test_get_storagerooms_list.py
состоит из следующих частей
@mock.patch('rentomatic.use_cases.storageroom_use_cases.StorageRoomListUseCase')
def test_get(mock_use_case, client):
mock_use_case().execute.return_value = res.ResponseSuccess (storagerooms)
Помните, что здесь мы не тестируем сценарий, так что мы можем его замокать. Мы заставляем сценарий возвращать экземпляр ResponseSuccess
, содержащий список моделей предметной области (которые мы еще не определили).
http_response = client.get('/storagerooms')
Это текущий вызов API. Мы выставляем конечную точку по адресу /storagerooms
.Обратите внимание на использование фикстуры client
, предоставленной pytest-Flask.
assert json.loads(http_response.data.decode('UTF-8')) == [storageroom1_dict]
assert http_response.status_code == 200
assert http_response.mimetype == 'application/json'
Вот эти упомянутые выше три проверки. Вторая и третья довольно просты, в то время как первая нуждается в некотором объяснений. Мы хотим сравнить http_response.data
с [storageroom1_dict]
, который представляет собой список Python-словарей, содержащих данные объекта storageroom1_domain_model
. Flask-объекты Response
содержат двоичное представление данных, поэтому мы сначала декодируeм байты, используя UTF-8, а затем преобразуем их в Python-объект. Гораздо удобнее сравнивать Python-объекты, так как у pytest могут возникать проблемы с неупорядоченной природой словарей. Но если сравнивать строки, то подобных сложностей не будет.
Окончательный тестовый файл с тестом модели предметной области и его словарь:
import json
from unittest import mock
from rentomatic.domain.storageroom import StorageRoom
from rentomatic.shared import response_object as res
storageroom1_dict = {
'code': '3251a5bd-86be-428d-8ae9-6e51a8048c33',
'size': 200,
'price': 10,
'longitude': -0.09998975,
'latitude': 51.75436293
}
storageroom1_domain_model = StorageRoom.from_dict(storageroom1_dict)
storagerooms = [storageroom1_domain_model]
@mock.patch('rentomatic.use_cases.storageroom_use_cases.StorageRoomListUseCase')
def test_get(mock_use_case, client):
mock_use_case().execute.return_value = res.ResponseSuccess(storagerooms)
http_response = client.get('/storagerooms')
assert json.loads(http_response.data.decode('UTF-8')) == [storageroom1_dict]
assert http_response.status_code == 200
assert http_response.mimetype == 'application/json'
Пришло время написать конечную точку, где мы, наконец, увидим работу всех частей архитектуры.
Минимальную конечную точку Flask можно поместить в rentomatic/rest/storageroom.py
blueprint = Blueprint('storageroom', __name__)
@blueprint.route('/storagerooms', methods=['GET'])
def storageroom():
[LOGIC]
return Response([JSON DATA],
mimetype='application/json',
status=[STATUS])
Первое, что мы создаём, это StorageRoomListRequestObject
. На данный момент можно игнорировать необязательные параметры строки запроса и использовать пустой словарь
def storageroom():
request_object = ro.StorageRoomListRequestObject. from_dict ({})
Как видите, я создаю объект из пустого словаря, так что параметры строки запроса не учитываются. Второе, что нужно сделать, это инициализировать хранилище
repo = mr.MemRepo ()
Третьим идёт инициализация сценария конечной точкой
use_case = uc.StorageRoomListUseCase(repo)
И, наконец, мы выполняем сценарий, передавая объект запроса
response = use_case.execute(request_object)
Но этот ответ пока ещё не HTTP-ответ. Мы должны явно построить его. HTTP-ответ будет содержать JSON представление атрибута response.value
.
return Response(json.dumps(response.value, cls=ser.StorageRoomEncoder),
mimetype='application/json',
status=200)
Обратите внимание, что эта функция еще не завершена, так как она всегда возвращает успешный ответ (код 200). Но этого достаточно для прохождения написанных тестов. Весь файл выглядит так:
import json
from flask import Blueprint, Response
from rentomatic.use_cases import request_objects as req
from rentomatic.repository import memrepo as mr
from rentomatic.use_cases import storageroom_use_cases as uc
from rentomatic.serializers import storageroom_serializer as ser
blueprint = Blueprint('storageroom', __name__)
@blueprint.route('/storagerooms', methods=['GET'])
def storageroom():
request_object = req.StorageRoomListRequestObject.from_dict({})
repo = mr.MemRepo()
use_case = uc.StorageRoomListUseCase(repo)
response = use_case.execute(request_object)
return Response(json.dumps(response.value, cls=ser.StorageRoomEncoder),
mimetype='application/json',
status=200)
Этот код демонстрирует работу чистой архитектуры целиком. Написанная функция не закончена, поскольку он не учитывает параметры строковых запросов и случаи с ошибками.
Сервер в действии
Git tag: Step13
Перед тем, как дописать недостающие части конечной точки, давайте посмотрим на сервер в работе и увидим наш продукт, который мы так долго строим, в действии.
Для того чтобы при обращении к конечной точки увидеть результаты, мы должны заполнить хранилище тестовыми данными. Очевидно, что сделать нам это приходится из-за непостоянности хранилища, которое мы используем. Реальное хранилище будет оборачивать постоянный источник данных, и эти тестовые данные уже будут не нужны. Определяем их для инициализации хранилища:
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',
}
И передаём их в наше хранилище
repo = mr.MemRepo ([storageroom1, storageroom2, storageroom3])
Теперь мы можем запустить Flask через файл manage.py
и проверить опубликованные URL-адреса:
$ python manage.py urls
Rule Endpoint
------------------------------------------------
/static/ static
/storagerooms storageroom.storageroom
И запустить сервер разработки
$ python manage.py server
Если вы откроете браузер и перейдёте по адресу http://127.0.0.1:5000/storagerooms, вы увидите результат API вызова. Рекомендую установить расширение форматирования для браузера, чтобы ответ был удобочитаемым. Если вы используете Chrome, попробуйте JSON Formatter.
REST-слой (часть2)
Git tag: Step14
Давайте рассмотрим два нереализованных случая в конечной точке. Сначала я введу тест, проверяющий корректность обработки параметров строки запроса конечной точкой
@mock.patch('rentomatic.use_cases.storageroom_use_cases.StorageRoomListUseCase')
def test_get_failed_response(mock_use_case, client):
mock_use_case().execute.return_value = res.ResponseFailure.build_system_error('test message')
http_response = client.get('/storagerooms')
assert json.loads(http_response.data.decode('UTF-8')) == {'type': 'SYSTEM_ERROR',
'message': 'test message'}
assert http_response.status_code == 500
assert http_response.mimetype == 'application/json'
Теперь мы проверяем, что сценарий возвращает ответ об ошибке, а так же смотрим, что HTTP-ответ содержит код ошибки. Для прохождения теста мы должны соотнести коды ответов предметной области с кодами HTTP-ответов
from rentomatic.shared import response_object as res
STATUS_CODES = {
res.ResponseSuccess.SUCCESS: 200,
res.ResponseFailure.RESOURCE_ERROR: 404,
res.ResponseFailure.PARAMETERS_ERROR: 400,
res.ResponseFailure.SYSTEM_ERROR: 500
}
Затем нам нужно создать Flask-ответ с корректным кодом
return Response(json.dumps(response.value, cls=ser.StorageRoomEncoder),
mimetype='application/json',
status=STATUS_CODES[response.type])
Второй и последний тест немного сложнее. Как и прежде, мы замокаем сценарий, но на этот раз мы также запатчим и StorageRoomListRequestObject
. Нам нужно знать, что объект запроса инициализируется верными параметрами из командной строки. Так что, двигаемся шаг за шагом:
@mock.patch('rentomatic.use_cases.storageroom_use_cases.StorageRoomListUseCase')
def test_request_object_initialisation_and_use_with_filters(mock_use_case, client):
mock_use_case().execute.return_value = res.ResponseSuccess([])
Здесь, как и ранее, патч класса сценария, гарантирующий возврат прецедентом экземпляр объекта ResponseSuccess
.
internal_request_object = mock.Mock()
Объект запроса будет создан внутри StorageRoomListRequestObject.from_dict
, и мы хотим, чтобы функция возвращала инициализированный ранее здесь мок-объект.
request_object_class = 'rentomatic.use_cases.request_objects.StorageRoomListRequestObject'
with mock.patch(request_object_class) as mock_request_object:
mock_request_object.from_dict.return_value = internal_request_object
client.get('/storagerooms?filter_param1=value1&filter_param2=value2')
Мы патчем StorageRoomListRequestObject
и назначаем заранее известный результат на метод from_dict()
. Затем мы обращаемся к конечной точке с некоторыми параметрами строки запроса. Происходит следующее: метод from_dict()
запроса вызывается с параметрами фильтра, и метод execute()
экземпляра сценария вызывается с internal_request_object
.
mock_request_object.from_dict.assert_called_with(
{'filters': {'param1': 'value1', 'param2': 'value2'}}
)
mock_use_case().execute.assert_called_with(internal_request_object)
Функция конечной точки должна быть изменена так, чтобы отразить это новое поведение и сделать тест валидным. Итоговый код нового Flask-метода storageroom()
выглядит следующим образом
import json
from flask import Blueprint, request, Response
from rentomatic.use_cases import request_objects as req
from rentomatic.shared import response_object as res
from rentomatic.repository import memrepo as mr
from rentomatic.use_cases import storageroom_use_cases as uc
from rentomatic.serializers import storageroom_serializer as ser
blueprint = Blueprint('storageroom', __name__)
STATUS_CODES = {
res.ResponseSuccess.SUCCESS: 200,
res.ResponseFailure.RESOURCE_ERROR: 404,
res.ResponseFailure.PARAMETERS_ERROR: 400,
res.ResponseFailure.SYSTEM_ERROR: 500
}
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',
}
@blueprint.route('/storagerooms', methods=['GET'])
def storageroom():
qrystr_params = {
'filters': {},
}
for arg, values in request.args.items():
if arg.startswith('filter_'):
qrystr_params['filters'][arg.replace('filter_', '')] = values
request_object = req.StorageRoomListRequestObject.from_dict(qrystr_params)
repo = mr.MemRepo([storageroom1, storageroom2, storageroom3])
use_case = uc.StorageRoomListUseCase(repo)
response = use_case.execute(request_object)
return Response(json.dumps(response.value, cls=ser.StorageRoomEncoder),
mimetype='application/json',
status=STATUS_CODES[response.type])
Обратите внимание, что мы извлекаем параметры из строки запроса глобального Flask-объекта request
. После того как параметры строки запроса окажутся в словаре, необходимо лишь создать объект запроса от него.
Заключение
Ну, вот и все! Некоторые тесты в REST-слое отсутствуют, но, как я говорил, это лишь рабочая реализация для демонстрации чистой архитектуры, а не полностью разработанный проект. Думаю, вы должны попробовать добавить самостоятельно некоторые изменения, такие как:
- ещё одна конечная точка, к примеру, доступ к одиночному ресурсу (
/storagerooms/
) - реальное хранилище, подключенное к настоящей БД (как вариант, SQLite)
- реализация нового параметра строки запроса, например, расстояние от заданной точки на карте (рекомендую использовать geopy для простого вычисления расстояния)
Во время разработки вашего кода, всегда пытайтесь следовать TDD-подходу. Тестируемость — это одна из главных особенностей чистой архитектуры, очень важно писать тесты, не игнорируйте их.
Вне зависимости от того, решите ли вы использовать чистую архитектуру или нет, я надеюсь, что этот пост поможет вам получить свежий взгляд на архитектуру программного обеспечения, как это случилось со мной, когда я впервые узнал об изложенных здесь концепциях.