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

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

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

Содержание

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/<path:filename>  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/<code>)
  • реальное хранилище, подключенное к настоящей БД (как вариант, SQLite)
  • реализация нового параметра строки запроса, например, расстояние от заданной точки на карте (рекомендую использовать geopy для простого вычисления расстояния)

Во время разработки вашего кода, всегда пытайтесь следовать TDD-подходу. Тестируемость — это одна из главных особенностей чистой архитектуры, очень важно писать тесты, не игнорируйте их.

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

Автор: CJay

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js