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


Зміст
REST-шар (частина1)
Git tag:Step12

Настав завершальний етап нашого пригоди за чистою архітектурою. Ми створили моделі предметної області, сериализаторы, сценарії та сховищі. Але поки відсутній інтерфейс, який склеює всі разом: отримує параметри виклику від користувача, ініціалізує сценарій зі сховищем, виконує сценарій, який отримує моделі предметної області з сховища, і перетворює їх у стандартний формат. Цей шар може бути представлений за допомогою безлічі інтерфейсів і технологій. Наприклад, за допомогою інтерфейсу командного рядка (CLI): отримувати параметри за допомогою ключів командного рядка і повертати результат у вигляді тексту на консолі. Але та ж базова система може бути використана і для web-сторінки, яка отримує параметри виклику з набору віджетів, виконує описані вище кроки, і розбирає повернені дані у форматі JSON для відображення результату на тій же сторінці.

незалежно від обраної технології для взаємодії з користувачем, збору вхідних даних та надання вихідних результатів, нам необхідно взаємодіяти з нещодавно створеної чистої архітектурою. Тому зараз ми створимо шар для винесення назовні API для роботи з HTTP. Реалізовано це за допомогою сервера, який надає набір HTTP-адрес (кінцевих точок API), при зверненні до яких повертаються деякі дані. Такий шар зазвичай називають REST-шар, тому що, як правило, семантика адрес схожа з рекомендаціями REST.
Flask — це легкий веб-сервер з модульною структурою, яка забезпечує тільки потрібні користувачу частини. Зокрема, ми не будемо використовувати якусь базу даних/ORM, так як у нас вже є власний реалізований шар сховища.

Зауважу, що зазвичай цей шар разом з шаром сховища реалізується у вигляді окремого пакета, але в рамках цього уроку я розмістив їх разом.

Оновимо файл залежностей. Файл
<font color="#900">prod.txt</font>
має містити модуль Flask

Flask

Файл
<font color="#900">dev.txt</font>
містить розширення Flask-Script
-r test.txt

pip
wheel
flake8
Sphinx
Flask-Script

І в файл
<font color="#900">test.txt</font>
додамо розширення pytest для роботи з Flask (докладніше про це пізніше)
-r prod.txt

pytest
tox
coverage
pytest-cov
pytest-flask

Після цих змін обов'язково знову запустимо
<font color="#900">pip install -r requirenments/dev.txt</font>
, щоб встановити нові пакети у віртуальне середовище.

Налаштування Flask-додатка проста, але містить багато особливостей. Так як це не підручник по Flask, я побіжно пройдуся по цим крокам. Тим не менше, я буду надавати посилання на документацію з Flask для кожної особливості.
Зазвичай я визначають окремі конфігурації для моєї тестової, девелоп і продакшн середовища. Так як додаток Flask можна налаштувати за допомогою звичайного Python-об'єкта(документація), я створюю файл
<font color="#900">rentomatic/settings.py</font>
для розміщення цих об'єктів
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 (документація), налаштовує його і реєструє креслення (документація). У файлі
<font color="#900">rentomatic/app.py</font>
містить наступний код:
from flask import Flask

from rentomatic.rest import storageroom
from rentomatic.import settings DevConfig


def create_app(config_object=DevConfig):
app = Flask(__name__)
app.config.from_object(config_object)
app.register_blueprint(storageroom.blueprint)
return app

Кінцеві точки додатки повинні повертати Flask-об'єкт
<font color="#900">Response</font>
з актуальними результатами і HTTP-статусом. Змістом відповіді, в даному випадку, є JSON -серіалізація відповіді сценарію.
Почнемо писати тести крок за кроком, що б ви змогли добре зрозуміти, що буде відбуватися в кінцевій точці REST. Базова структура тесту виглядає наступним чином
[SOME PREPARATION]
[CALL THE API ENDPOINT]
[CHECK RESPONSE DATA]
[CHECK RESPONDSE STATUS CODE]
[CHECK RESPONSE MIMETYPE]

Тому, наш перший тест —
<font color="#900">tests/rest/test_get_storagerooms_list.py</font>
складається з наступних частин
@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)

Пам'ятайте, що тут ми не тестуємо сценарій, так що ми можемо його замокати. Ми змушуємо сценарій повертати примірник
<font color="#900">ResponseSuccess</font>
, що містить список моделей предметної області (які ми ще не визначили).
http_response = client.get('/storagerooms')

Це поточний виклик API. Ми виставляємо кінцеву точку за адресою
<font color="#900">/storagerooms</font>
.Зверніть увагу на використання фікстури
<font color="#900">client</font>
, наданої 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'

Ось ці згадані вище три перевірки. Друга і третя досить прості, в той час як перша потребує в деякому пояснень. Ми хочемо порівняти
<font color="#900">http_response.data</font>
,
<font color="#900">[storageroom1_dict]</font>
, який представляє собою список Python-словників, що містять дані об'єкта
<font color="#900">storageroom1_domain_model</font>
. Flask-об'єкти
<font color="#900">Response</font>
містить двійкове подання даних, тому ми спочатку декодируем байти, використовуючи 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 можна помістити в
<font color="#900">rentomatic/rest/storageroom.py</font>

blueprint = Blueprint('storageroom', __name__)


@blueprint.route('/storagerooms', methods=['GET'])
def storageroom():
[LOGIC]
return Response([JSON DATA],
mimetype='application/json',
status=[STATUS])

Перше, що ми створюємо, це
<font color="#900">StorageRoomListRequestObject</font>
. На даний момент можна ігнорувати необов'язкові параметри рядка запиту і використовувати порожній словник
def storageroom():
request_object = ro.StorageRoomListRequestObject. from_dict ({})

Як бачите, я створюю об'єкт з пустого словника, так що параметри рядка запиту не враховуються. Друге, що потрібно зробити, це ініціалізувати сховище
репо = mr.MemRepo ()

Третім йде ініціалізація сценарію кінцевою точкою
use_case = uc.StorageRoomListUseCase(repo)

І, нарешті, ми виконуємо сценарій, передаючи об'єкт запиту
response = use_case.execute(request_object)

Але ця відповідь поки ще не HTTP-відповідь. Ми повинні явно побудувати його. HTTP-відповідь буде містити JSON подання атрибута
<font color="#900">response.value</font>
.
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',
}

І передаємо їх в наше сховище
репо = mr.MemRepo ([storageroom1, storageroom2, storageroom3])

Тепер ми можемо запустити Flask через файл
<font color="#900">manage.py</font>
і перевірити опубліковані 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])

Другий і останній тест трохи складніше. Як і раніше, ми замокаем сценарій, але на цей раз ми також запатчим та
<font color="#900">StorageRoomListRequestObject</font>
. Нам потрібно знати, що об'єкт запиту ініціалізується вірними параметрами командного рядка. Так що, рухаємося крок за кроком:
@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([])

Тут, як і раніше, патч класу сценарію, що гарантує повернення прецедентом екземпляр об'єкта
<font color="#900">ResponseSuccess</font>
.
internal_request_object = mock.Mock()

Об'єкт запиту буде створений всередині
<font color="#900">StorageRoomListRequestObject.from_dict</font>
, і ми хочемо, щоб функція повертала ініціалізований раніше тут мок-об'єкт.
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')

Ми патчем
<font color="#900">StorageRoomListRequestObject</font>
і призначаємо заздалегідь відомий результат на метод
<font color="#900">from_dict()</font>
. Потім ми звертаємося до кінцевої точки з деякими параметрами рядка запиту. Відбувається наступне: метод
<font color="#900">from_dict()</font>
запиту викликається з параметрами фільтра, і метод
<font color="#900">execute()</font>
примірника сценарію викликається з
<font color="#900">internal_request_object</font>
.
mock_request_object.from_dict.assert_called_with(
{'filters': {'param1': 'value1', 'param2': 'value2'}}
)
mock_use_case().execute.assert_called_with(internal_request_object)

Функція кінцевої точки повинна бути змінена так, щоб відобразити це нове поведінку і зробити тест є валідним. Підсумковий код нового Flask-методу
<font color="#900">storageroom()</font>
виглядає наступним чином
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-об'єкта
<font color="#900">request</font>
. Після того як параметри рядка запиту опиняться в словнику, необхідно лише створити об'єкт запиту від нього.
Висновок
Ну, от і все! Деякі тести REST-шарі відсутні, але, як я говорив, це лише робоча реалізація для демонстрації чистої архітектури, а не повністю розроблений проект. Думаю, ви повинні спробувати додати деякі зміни, такі як:
  • ще одна кінцева точка, приміром, доступ до одиночного ресурсу (
    <font color="#900">/storagerooms/<code></font>
    )
  • реальне сховище, підключене до цієї БД (як варіант, SQLite
  • реалізація нового значення рядка запиту, наприклад, відстань від заданої точки на карті (рекомендую використовувати geopy для простого обчислення відстані)
Під час розробки вашого коду, завжди намагайтеся слідувати TDD-підходу. Тестування — це одна з головних особливостей чистої архітектури, дуже важливо писати тести, не ігноруйте їх.
незалежно від того, вирішите ви використовувати чисту архітектуру чи ні, я сподіваюся, що цей пост допоможе вам отримати свіжий погляд на архітектуру програмного забезпечення, як це сталося зі мною, коли я вперше дізнався про викладених тут концепціях.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.