курсовая_v1. Курсовая работа Необходимо
Скачать 2.93 Mb.
|
Курсовая работа Необходимо: разработать REST API-сервис посредством языка Python, протестировать, упаковать его в Docker-контейнер, а также развернуть с помощью Ansible. Примечание: Реализовать REST API-сервис можно по-разному, с помощью различных инструментов. Описанное решение не единственно верное, поэтому вы можете выбрать инструменты для реализации исходя из своего личного опыта и предпочтений. Рис. 1 – структура проекта Представим, что интернет-магазин подарков планирует запустить акцию в разных регионах. Чтобы стратегия продаж была эффективной, необходим анализ рынка. У магазина есть поставщик, регулярно присылающий (например, на почту) выгрузки данных с информацией о жителях. Давайте разработаем REST API-сервис на Python, который будет анализировать предоставленные данные и выявлять спрос на подарки у жителей разных возрастных групп в разных городах по месяцам. В сервисе реализуем следующие обработчики: • POST /imports Добавляет новую выгрузку с данными; • GET /imports/$import_id/citizens Возвращает жителей указанной выгрузки; • PATCH /imports/$import_id/citizens/$citizen_id Изменяет информацию о жителе (и его родственниках) в указанной выгрузке; • GET /imports/$import_id/citizens/birthdays Вычисляет число подарков, которое приобретет каждый житель выгрузки своим родственникам (первого порядка), сгруппированное по месяцам; • GET /imports/$import_id/towns/stat/percentile/age Вычисляет 50-й, 75-й и 99-й перцентили возрастов (полных лет) жителей по городам в указанной выборке. Итак, необходимо написать сервис на Python, используя фреймворки, библиотеки и СУБД. Для реализации предлагается выбрать СУБД PostgreSQL (или другую, по вашему усмотрению) , зарекомендовавшую себя как надежное решение c отличной документацией на русском языке , сильным русским сообществом (всегда можно найти ответ на вопрос на русском языке). Реляционная модель достаточно универсальна и хорошо понятна многим разработчикам. Хотя то же самое можно было сделать на любой NoSQL СУБД. Основная задача сервиса — передача данных по сети между БД и клиентами — не предполагает большой нагрузки на процессор, но требует возможности обрабатывать несколько запросов в один момент времени. Асинхронный подход позволяет эффективно обслуживать нескольких клиентов в рамках одного процесса ОС (в отличие, например, от используемой во Flask/Django pre-fork-модели, которая создает несколько процессов для обработки запросов от пользователей, каждый из них потребляет память, но простаивает большую часть времени). Поэтому в качестве библиотеки для написания сервиса предлагается выбрать асинхронный aiohttp Рис 2. – Схема обработки запросов SQLAlchemy позволяет декомпозировать сложные запросы на части, переиспользовать их, генерировать запросы с динамическим набором полей (например, PATCH-обработчик позволяет частичное обновление жителя с произвольными полями) и сосредоточиться непосредственно на бизнес-логике. С выполнением этих запросов и передачей данных быстрее всех справится драйвер asyncpg , а подружить их поможет asyncpgsa Один из инструментов для управления состоянием БД и работы с миграциями — Alembic Логику валидации можно лаконично описать схемами Marshmallow (включая проверки на родственные связи). С помощью модуля aiohttp-spec связать aiohttp- обработчики и схемы для валидации данных, а бонусом сгенерировать документацию в формате Swagger и отобразить ее в графическом интерфейсе Для написания тестов используйте pytest. Для отладки и профилирования можно использовать отладчик PyCharm. На любом компьютере с Docker (и даже на разных ОС) можно запускать упакованное приложение без необходимости настраивать окружение для запуска и легко устанавливать/обновлять/удалять приложение на сервере. Для деплоя воспользуйтесь Ansible . Он позволяет декларативно описывать желаемое состояние сервера и его сервисов, работает по ssh и не требует специального софта. Разработка Задайте Python-пакету название analyzer и используйте следующую структуру: Рис. 3 – структура пакета В файле analyzer/__init__.py разместите общую информацию о пакете: описание ( docstring ), версию, лицензию, контакты разработчиков. Затем ее можно посмотреть встроенной командой help $ python >>> import analyzer >>> help(analyzer) Пример: Пакет имеет две входных точки — REST API-сервис ( analyzer/api/__main__.py ) и утилита управления состоянием БД ( analyzer/db/__main__.py ). Файлы называются __main__.py неспроста — во- первых, такое название привлекает внимание, по нему понятно, что файл является входной точкой. Во-вторых, благодаря этому подходу к входным точкам можно обращаться с помощью команды python -m : Почему нужно начать с setup.py? Забегая вперед, подумаем, как можно распространять приложение: оно может быть упаковано в zip- (а также wheel/egg-) архив, rpm-пакет, pkg-файл для macOS и установлено на удаленный компьютер, в виртуальную машину, MacBook или Docker-контейнер. Главная цель файла setup.py — описать пакет с приложением для distutils / setuptools В файле необходимо указать общую информацию о пакете (название, версию, автора и т. д.), но также в нем можно указать требуемые для работы модули, «экстра»-зависимости (например для тестирования), точки входа (например, исполняемые команды) и требования к интерпретатору. Плагины setuptools позволяют собирать из описанного пакета артефакт. Есть встроенные плагины: zip, egg, rpm, macOS pkg. Остальные плагины распространяются через PyPI: wheel , xar , pex В сухом остатке, описав один файл, мы получаем огромные возможности. Именно поэтому разработку нового проекта нужно начинать с setup.py. В функции setup() зависимые модули указываются списком: Но необходимо описать зависимости в отдельных файлах requirements.txt и requirements.dev.txt , содержимое которых используется в setup.py . Это кажется более гибким, плюс тут есть секрет: впоследствии это позволит собирать Docker-образ быстрее. Зависимости будут ставиться отдельным шагом до установки самого приложения, а при пересборке Docker-контейнера попадать в кеш. Чтобы setup.py смог прочитать зависимости из файлов requirements.txt и requirements.dev.txt , написана функция: Стоит отметить, что setuptools при сборке source distribution по умолчанию включает в сборку только файлы .py , .c , .cpp и .h . Чтобы файлы с зависимостями requirements.txt и requirements.dev.txt попали в пакет, их необходимо явно указать в файле MANIFEST.in setup.py целиком import os from importlib.machinery import SourceFileLoader from pkg_resources import parse_requirements from setuptools import find_packages, setup module_name = 'analyzer' # Возможно, модуль еще не установлен (или установлена другая версия), поэтому # необходимо загружать __init__.py с помощью machinery. module = SourceFileLoader( module_name, os.path.join(module_name, '__init__.py' ) ).load_module() def load_requirements (fname: str) -> list: requirements = [] with open(fname, 'r' ) as fp: for req in parse_requirements(fp.read()): extras = '[{}]' .format( ',' .join(req.extras)) if req.extras else '' requirements.append( '{}{}{}' .format(req.name, extras, req.specifier) ) return requirements setup( name=module_name, version=module.__version__, author=module.__author__, author_email=module.__email__, license=module.__license__, description=module.__doc__, long_description=open( 'README.rst' ).read(), url= 'https://github.com/alvassin/backendschool2019' , platforms= 'all' , classifiers=[ 'Intended Audience :: Developers' , 'Natural Language :: Russian' , 'Operating System :: MacOS' , 'Operating System :: POSIX' , 'Programming Language :: Python' , 'Programming Language :: Python :: 3' , 'Programming Language :: Python :: 3.8' , 'Programming Language :: Python :: Implementation :: CPython' ], python_requires= '>=3.8' , packages=find_packages(exclude=[ 'tests' ]), install_requires=load_requirements( 'requirements.txt' ), extras_require={ 'dev' : load_requirements( 'requirements.dev.txt' )}, entry_points={ 'console_scripts' : [ # f-strings в setup.py не используются из-за соображений # совместимости. # Несмотря на то, что этот пакет требует Python 3.8, технически # source distribution для него может собираться с помощью более # ранних версий Python. Не стоит лишать пользователей этой # возможности. '{0}-api = {0}.api.__main__:main' .format(module_name), '{0}-db = {0}.db.__main__:main' .format(module_name) ] }, include_package_data= True ) Установить проект в режиме разработки можно следующей командой (в editable- режиме Python не установит пакет целиком в папку site-packages , а только создаст ссылки, поэтому любые изменения, вносимые в файлы пакета, будут видны сразу): # Установить пакет с обычными и extra-зависимостями "dev" pip install -e '.[dev]' # Установить пакет только с обычными зависимостями pip install -e . Как указать версии зависимостей? Здорово, когда разработчики активно занимаются своими пакетами — в них активнее исправляются ошибки, появляется новая функциональность и можно быстрее получить обратную связь. Но иногда изменения в зависимых библиотеках не имеют обратной совместимости и могут привести к ошибкам в вашем приложении, если не подумать об этом заранее. Для каждого зависимого пакета можно указать определенную версию, например aiohttp==3.6.2 . Тогда приложение будет гарантированно собираться именно с теми версиями зависимых библиотек, с которыми оно было протестировано. Но у этого подхода есть и недостаток — если разработчики исправят критичный баг в зависимом пакете, не влияющий на обратную совместимость, в приложение это исправление не попадет. Существует подход к версионированию Semantic Versioning , который предлагает представлять версию в формате MAJOR.MINOR.PATCH : • MAJOR — увеличивается при добавлении обратно несовместимых изменений; • MINOR — увеличивается при добавлении новой функциональности с поддержкой обратной совместимости; • PATCH — увеличивается при добавлении исправлений багов с поддержкой обратной совместимости. Если зависимый пакет следует этому подходу (о чем авторы обычно сообщают в файлах README или CHANGELOG), то достаточно зафиксировать значения MAJOR , MINOR и ограничить минимальное значение для PATCH-версии: >= MAJOR.MINOR.PATCH, == MAJOR.MINOR.* Такое требование можно реализовать с помощью оператора = Например, aiohttp=3.6.2 позволит PIP установить для aiohttp версию 3.6.3, но не 3.7. Если указать интервал версий зависимостей, это даст еще одно преимущество — не будет конфликтов версий между зависимыми библиотеками. Если вы разрабатываете библиотеку, которая требует другой пакет- зависимость, то разрешите для него не одну определенную версию, а интервал. Тогда потребителям вашей библиотеки будет намного легче ее использовать (вдруг их приложение требует этот же пакет-зависимость, но уже другой версии). Semantic Versioning — лишь соглашение между авторами и потребителями пакетов. Оно не гарантирует, что авторы пишут код без багов и не могут допустить ошибку в новой версии своего пакета. База данных Проектируем схему В описании обработчика POST /imports приведен пример выгрузки с информацией о жителях: Первой мыслью может возникнуть хранить всю информацию о жителе в одной таблице citizens, где родственные связи были бы представлены полем relatives в виде списка целых чисел. Но у этого способа есть ряд недостатков 1. В обработчике GET /imports/$import_id/citizens/birthdays для получения месяцев, на которые приходятся дни рождения родственников, потребуется выполнить слияние таблицы citizens с самой собой. Для этого будет необходимо развернуть список с идентификаторами родственников relatives с помощью фунции UNNEST Такой запрос будет выполняться сравнительно медленно, и обработчик не уложится в 10-секундный таймаут: SELECT relations.citizen_id, relations.relative_id, date_part( 'month' , relatives.birth_date) as relative_birth_month FROM ( SELECT citizens.import_id, citizens.citizen_id, UNNEST (citizens.relatives) as relative_id FROM citizens WHERE import_id = 1 ) as relations INNER JOIN citizens as relatives ON relations.import_id = relatives.import_id AND relations.relative_id = relatives.citizen_id 2. В таком подходе целостность данных в поле relatives не обеспечивается PostgreSQL, а контролируется приложением: технически в список relatives можно добавить любое целое число, в том числе идентификатор несуществующего жителя. Ошибка в коде или человеческий фактор (редактирование записей напрямую в БД администратором) обязательно рано или поздно приведут к несогласованному состоянию данных. Далее, нужно привести все требуемые для работы данные к третьей нормальной форме , и тогда получается следующая структура: 1. Таблица imports состоит из автоматически инкрементируемого столбца import_id . Он нужен для создания проверки по внешнему ключу в таблице citizens 2. В таблице citizens хранятся скалярные данные о жителе (все поля за исключением информации о родственных связях). В качестве первичного ключа используется пара ( import_id , citizen_id ), гарантирующая уникальность жителей citizen_id в рамках import_id Внешний ключ citizens.import_id -> imports.import_id гарантирует, что поле citizens.import_id будет содержать только существующие выгрузки. 3. Таблица relations содержит информацию о родственных связях. Одна родственная связь представлена двумя записями (от жителя к родственнику и обратно): эта избыточность позволяет использовать более простое условие при слиянии таблиц citizens и relations и получать информацию более эффективно. Первичный ключ состоит из столбцов ( import_id , citizen_id , relative_id ) и гарантирует, что в рамках одной выгрузки import_id у жителя citizen_id будут родственники c уникальными relative_id Также в таблице используются два составных внешних ключа: (relations.import_id, relations.citizen_id) -> (citizens.import_id, citizens.citizen_id) и (relations.import_id, relations.relative_id) -> (citizens.import_id, citizens.citizen_id) , гарантирующие, что в таблице будут указаны существующие житель citizen_id и родственник relative_id из одной выгрузки. Такая структура обеспечивает целостность данных средствами PostgreSQL, позволяет эффективно получать жителей с родственниками из базы данных, но подвержена состоянию гонки во время обновления информации о жителях конкурентными запросами (подробнее рассмотрим при реализации обработчика PATCH). Описываем схему в SQLAlchemy Для создания запросов с помощью SQLAlchemy необходимо описать схему базы данных с помощью специальных объектов: таблицы описываются с помощью sqlalchemy.Table и привязываются к реестру sqlalchemy.MetaData, который хранит всю метаинформацию о базе данных. К слову, реестр MetaData способен не только хранить описанную в Python метаинформацию, но и представлять реальное состояние базы данных в виде объектов SQLAlchemy. Эта возможность в том числе позволяет Alembic сравнивать состояния и генерировать код миграций автоматически. Кстати, у каждой базы данных своя схема именования constraints по умолчанию. Чтобы вы не тратили время на именование новых constraints или на воспоминания/поиски того, как назван constraint, который вы собираетесь удалить, SQLAlchemy предлагает использовать шаблоны именования naming conventions Их можно определить в реестре MetaData. Создаем реестр MetaData и передаем в него шаблоны именования # analyzer/db/schema.py from sqlalchemy import MetaData convention = { 'all_column_names' : lambda constraint, table: '_' .join([ column.name for column in constraint.columns.values() ]), # Именование индексов 'ix' : 'ix__%(table_name)s__%(all_column_names)s' , # Именование уникальных индексов 'uq' : 'uq__%(table_name)s__%(all_column_names)s' , # Именование CHECK-constraint-ов 'ck' : 'ck__%(table_name)s__%(constraint_name)s' , # Именование внешних ключей 'fk' : 'fk__%(table_name)s__%(all_column_names)s__%(referred_table_name)s' , # Именование первичных ключей 'pk' : 'pk__%(table_name)s' } metadata = MetaData(naming_convention=convention) Если указать шаблоны именования, Alembic воспользуется ими во время автоматической генерации миграций и будет называть все constraints в соответствии с ними. В дальнейшем cозданный реестр MetaData потребуется для описания таблиц: Описываем схему базы данных объектами SQLAlchemy # analyzer/db/schema.py from enum import Enum, unique from sqlalchemy import ( Column, Date, Enum as PgEnum, ForeignKey, ForeignKeyConstraint, Integer, String, Table ) @unique class Gender (Enum) : female = 'female' male = 'male' imports_table = Table( 'imports' , metadata, Column( 'import_id' , Integer, primary_key= True ) ) citizens_table = Table( 'citizens' , metadata, Column( 'import_id' , Integer, ForeignKey( 'imports.import_id' ), primary_key= True ), Column( 'citizen_id' , Integer, primary_key= True ), Column( 'town' , String, nullable= False , index= True ), Column( 'street' , String, nullable= False ), Column( 'building' , String, nullable= False ), Column( 'apartment' , Integer, nullable= False ), Column( 'name' , String, nullable= False ), Column( 'birth_date' , Date, nullable= False ), Column( 'gender' , PgEnum(Gender, name= 'gender' ), nullable= False ), ) relations_table = Table( 'relations' , metadata, Column( 'import_id' , Integer, primary_key= True ), Column( 'citizen_id' , Integer, primary_key= True ), Column( 'relative_id' , Integer, primary_key= True ), ForeignKeyConstraint( ( 'import_id' , 'citizen_id' ), ( 'citizens.import_id' , 'citizens.citizen_id' ) ), ForeignKeyConstraint( ( 'import_id' , 'relative_id' ), ( 'citizens.import_id' , 'citizens.citizen_id' ) ), ) |