Миграции баз данных gino + alembic
Forzend![](/file/4519c96f592a278fa5d0d.png)
В активно развивающихся проектах часто вносятся изменения в структуру базы данных, поэтому хорошей практикой является работа с миграциями. Благодаря alembic вам не нужно будет сносить к х***м базу, чтобы потом поднять её с новыми таблицами, а также вы сможете откатиться на предыдущую версию базы, в случае необходимости. Миграции используются для правильного переезда базы данных на новую структуру. Версия базы данных должна соответствовать версии приложения, иначе приложение банально не будет правильно функционировать.
Представим. Вы работаете в команде разработчиков и работает над одним серьёзным проектом. На продакшене у вас уже имеется база на тысячи пользователей. Вы планируете внести изменения в логику проекта — модели и их использование. Главный вопрос, который возникает в такой ситуации — как без мороки и потери данных перенести базу данных на новую структуру, как написать код так. Решением этой проблемы является использование в своём проекте миграций. Поэтому в этой статье мы ,опираясь на бот-пример от aiogram, познакомимся с миграциями на примере alembic и его интеграцией с асинхронной ORM на базе ядра sqlalchemy — gino.
Важное примечание
Автор статьи поменял ссылки на свой telegram и github профиль, поэтому у вас могут не открываться некоторые ссылки.
Новая ссылка на github профиль автора, в котором находится репозиторий статей: https://github.com/F0rzend
Новая ссылка на мой telegram профиль, для обратной связи: https://t.me/F0rzend
Содержание статьи
. ├── Подготовка проекта ├── Создание моделей | └── Модель пользователя ├── Инициализация alembic ├── Настройка alembic config ├── Создание миграций | ├── Poetry | └── Использование make ├── Docker & docker-compose | ├── Dockerfile | └── docker-compose └── Завершение
Подготовка проекта
В этой статье будет использоваться структура, описанная в одной из предыдущих статей (вы также можете найти её на github).
Сразу после создания проекта создадим виртуальное окружение, чтобы "не замарать" систему:
python3 -m venv venv
активируем его:
GNU/Linux: source venv/bin/activate
Windows: venv/Scripts/activate
После чего установим нужные нам библиотеки. Стандартные библиотеки для работы уже описаны в файле с зависимостями, установим их, а также необходимые для миграций библиотеки: gino
, alembic
и psycopg2-binary
(Необходимая alembic зависимость):
pip install -r requirements.txt gino alembic psycopg2-binary
Также определим необходимые нам переменные в app/config.py
и .env
файле
Начнём с .env
файла. Переименуем .env.dist
в .env
, заполним необходимые поля и добавим поля, необходимые для подключения к базе данных, а именно хост, порт, название базы данных, пользователя, пароль пользователя:
BOT_TOKEN = 0123456789:AB1CD2EF3GH4IJ5KL6MN7PQ8RS9TU-VWXYZ SKIP_UPDATES = True SUPERUSER_ID = 123456789 POSTGRES_HOST = localhost POSTGRES_PORT = 5432 POSTGRES_DB = database POSTGRES_USER = mypguser POSTGRES_PASSWORD = qwerty123456
Теперь перейдём в app/config.py
и добавим необходимые для подключения к базе данных константы. Обратите внимание, что кроме определённых в .env
файле переменных, я добавляю константу POSTGRES_URI
, которая создаётся на основе всех предыдущих. Именно эта константа нужна нам для подключения. После внесения в app/config.py
всех необходимых констант, он принимает вид:
from pathlib import Path from environs import Env env = Env() env.read_env() BOT_TOKEN = env.str("BOT_TOKEN") SKIP_UPDATES = env.bool("SKIP_UPDATES", False) WORK_PATH: Path = Path(__file__).parent.parent SUPERUSER_ID = env.list("SUPERUSER_ID") POSTGRES_HOST = env.str("POSTGRES_HOST", default="localhost") POSTGRES_PORT = env.int("POSTGRES_PORT", default=5432) POSTGRES_DB = env.str("POSTGRES_DB") POSTGRES_USER = env.str("POSTGRES_USER") POSTGRES_PASSWORD = env.str("POSTGRES_PASSWORD") POSTGRES_URI = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
Проект подготовлен к работе.
Хочу немного пояснить что здесь происходит для людей, которые не очень понимают происходящее, ибо я не останавливался на файле app/config.py
в статье о новой структуре. Понимающие могут пропустить эту часть статьи и продолжить с пункта "Создание моделей".
Для загрузки переменных я использую библиотеку environs, а не python-dotenv, который использует обычно Latand. Мне просто она больше нравится, вы можете использовать python-dotenv.
В следующих строках создаётся объект окружения, из которого мы и можем получать переменные окружения:
from environs import Env env = Env() env.read_env()
Далее все переменные подключаются с помощью следующего кода:
VARIABLE = env.str("VARIABE")
Здесь str указывает на строковый тип переменной, вы можете использовать bool, list или другие типы. Подробнее о них вы можете прочесть в readme на github environs.
WORK_PATH: Path = Path(__file__).parent.parent
используется для банального получения полного пути в проекте. Она может вам пригодится, поэтому была добавлена в конфиг структуры. В этой статье мы её использовать не будем, поэтому вы можете просто её удалить.
Создание моделей
Теперь, когда наш проект готов к работе, мы можем приступить к написанию моделей, на которых будем тестировать миграции. В первую очередь создадим объект базы данных в app/loader.py
(Не забудьте вписать его в __all__
). Теперь файл app/loader.py
имеет такой вид:
from aiogram import Bot, Dispatcher, types from aiogram.contrib.fsm_storage.memory import MemoryStorage from gino import Gino from app import config db = Gino() bot = Bot( token=config.BOT_TOKEN, parse_mode=types.ParseMode.HTML, ) storage = MemoryStorage() dp = Dispatcher( bot=bot, storage=storage, ) __all__ = ( "bot", "storage", "dp", "db", )
Создадим пакет app/models
, в который поместим файл __init__.py
, чтобы питон понимал, что это пакет; base
, где мы опишем подключение к базе и разрыв соединения; модуль user, в котором мы опишем модель пользователя, на которой будем всё тестировать. Это должно выглядеть как-то так (на месте ". . .
" находятся прочие файлы и папки, необходимые для работы бота):
. ├── app | ├── ... | ├── models | | ├── __init__.py | | ├── user.py | | └── base.py | ├── loader.py | └── ... └── ...
Откроем модуль app/models/base.py
и определим в нём функцию для подключения к базе данных. Моё подключение практически не отличается от показанного в курсе. Единственное изменение, я предпочёл сделать postgres_uri
аргументом функции, а не брать его напрямую из app/config.py
, чтобы при необходимости, можно было использовать эту функцию и для подключение к другой базе:
from loguru import logger from app.loader import db async def connect(postgres_uri: str): await db.set_bind(postgres_uri) logger.info('PostgreSQL is successfully configured')
Также напишем функцию для разрыва соединения, на случай, если бот упадёт:
from contextlib import suppress from gino import UninitializedError from loguru import logger from app.loader import db async def close_connection(): with suppress(UninitializedError): logger.info('Closing a database connection') await db.pop_bind().close()
Вас может смутить строка with suppress(UnitializedError): . . .
Это абсолютно то же самое, что и следующиее:
try: . . . except UnitializedError: pass
Согласитесь, использование контекстного менеджера (with
) выглядит лаконичнее.
Законченный файл app/models/base.py
имеет вид:
from contextlib import suppress from gino import UninitializedError from loguru import logger from app.loader import db async def connect(postgres_uri): await db.set_bind(postgres_uri) logger.info('PostgreSQL is successfully configured') async def close_connection(): with suppress(UninitializedError): logger.info('Closing a database connection') await db.pop_bind().close()
После всего этого добавим вызов этих функции в app/__main__.py
при запуске и падении бота (функции, переданные в аргументы on_startup
и on_shutdown
executor.start_polling(*args)
). Файл app/__main__.py
обрёл следующий вид:
from aiogram import Dispatcher from aiogram.utils import executor from app import utils, config from app.loader import dp # The configuration of the modules using import from app import middlewares, filters, handlers from app.models import base async def on_startup(dispatcher: Dispatcher): await base.connect(config.POSTGRES_URI) await utils.setup_default_commands(dispatcher) await utils.notify_admins(config.SUPERUSER_ID) async def on_shutdown(dispatcher: Dispatcher): await base.close_connection() if __name__ == '__main__': utils.setup_logger("INFO", ["sqlalchemy.engine", "aiogram.bot.api"]) executor.start_polling( dp, on_startup=on_startup, on_shutdown=on_shutdown, skip_updates=config.SKIP_UPDATES )
Модель пользователя
Наконец приступим к написанию модели пользователя. Перейдём в модуль app/models/user
и создадим там модель User
:
from sqlalchemy.sql import Select, expression from app.loader import db class User(db.Model): __tablename__ = "users" id = db.Column(db.Integer, primary_key=True, index=True, unique=True) is_superuser = db.Column(db.Boolean, server_default=expression.false()) query: Select
У вас может вызвать вопросы поле is_superuser
, а именно аргумент server_default
. Мы определяем его именно таким образом, потому alembic требует, чтобы значение по-умолчанию было заданно на уровне базы через server_default
, а не через default
, который определяет значение на уровне Python.
Note that this statement uses theColumn
construct as is from the SQLAlchemy library. In particular, default values to be created on the database side are specified using theserver_default
parameter, and notdefault
which only specifies Python-side defaults.
Подробнее о значениях типа expression.false()
и зачем они применяются вы можете прочесть в официальной документации sqlalchemy.
После создания, данную модель необходимо импортировать в app/models/__init__.py
:
from .user import User
Инициализация alembic
В первую очередь нам нужно создать необходимую "структуру", на базе которой будет работать alembic. Для этого выполним следующую команду:
alembic init migrations
"migrations" — название директории, которая будет находится в корне проекта и отвечать за миграции. Я советую называть её именно так, ибо данное название говорит само за себя
После выполнения данной команды, alembic создаст в корне файл alembic.ini
и директорию migrations (или директорию с тем названием, которое вы передали как один из аргументов команды init). Таким образом корень проекта имеет вид:
. ├── app/ ├── migrations/ ├── .env ├── .gitignore ├── alembic.ini └── requirements.txt
Настройка alembic config
Если идти по официальной документации gino, следующим шагом будет статично определить sqlalchemy.uri
в файле alembic.ini
. Об этом же просит у нас alembic в самой консоли:
Please edit configuration/connection/logging settings in 'full/path/to/your/project/alembic.ini' before proceeding.
Но мы укажем его напрямую в конфигурационном файле alembic (далее конфиг), поэтому данный шаг мы пропусти и перейдём сразу к конфигу. Конфиг alembic находится в migrations/env.py
, именно на этот файл опирается alembic при генерации миграций. Сейчас он имеет такой вид (Обратите внимание, что я удалил все комментарии, чтобы в коде было легче ориентироваться):
from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context config = context.config fileConfig(config.config_file_name) target_metadata = None def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): connectable = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online()
Мы будем работать с первой половиной файла, до определения функции run_migrations_offline
:
from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context config = context.config fileConfig(config.config_file_name) target_metadata = None def run_migrations_offline(): . . .
Начнём нашу настройку с определения target_metadata
, которую alembic использует для автоматической генерации миграций. Передадим в него экземпляр класса Gino, который мы определили в app/loader.py
. Не забудем его импортировать. Также сразу определим тот самый sqlalchemy.uri
с помощью config.set_main_option()
и импортируем все наши модели, чтобы alembic мог получить к ним доступ. Теперь начало файла migrations/env.py
выглядит так:
from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context from app.config import POSTGRES_URI from app.loader import db from app.models import * config = context.config fileConfig(config.config_file_name) target_metadata = db config.set_main_option('sqlalchemy.url', POSTGRES_URI) def run_migrations_offline(): . . .
У нас всё готово. Можем создавать миграции
Создание миграций
Команда для создания миграций имеет вид:
alembic revision [arguments]
Добавим в неё несколько аргументов и выполним:
alembic revision -m "first migration" --autogenerate
Здесь:
-m "first migration"
— комментарий к миграции, он используется, чтобы легче ориентироваться в версиях миграций;--autogenerate
— название говорит само за себя. Этот аргумент говорил alembic, чтобы тот создал инструкции для базы самостоятельно и нам не пришлось делать это руками;
После выполнения данной команды вы скорее всего получите ошибку. Вот это я мудак, правда? 🤪
Но не спешите закрывать статью!
Если ваша ошибка имеет вид:
File "migrations/env.py", line 8, in <module> from app.config import POSTGRES_URI ModuleNotFoundError: No module named 'app'
Её исправить довольно просто. Об этом даже написано в документации gino:
Either install your project locally withpip install -e .
,poetry install
orpython setup.py develop
, or add you package to PYTHONPATH.
Последовав их совету, просто зададим переменную окружения PYTHONPATH
. (Не забудьте заменить мой путь к проекту на свой)
GNU/Linux:
export PYTHONPATH=$PYTHONPATH:/home/forzend/works/python/articles/app
Windows:
set PYTHONPATH="%PYTHONPATH%;C:\works\python\articles\app"
После чего создаём миграции всё той же командой:
alembic revision -m "first migration" --autogenerate
В итоге alembic создаст файл migrations/versions/
<version_number>
_message.py
, примерное содержание которого следующее:
"""first migration Revision ID: 0c97763a0d2c Revises: Create Date: 2020-11-29 20:35:01.559832 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '0c97763a0d2c' down_revision = None branch_labels = None depends_on = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('is_superuser', sa.Boolean(), server_default=sa.text('false'), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=True) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_users_id'), table_name='users') op.drop_table('users') # ### end Alembic commands ###
Теперь мы можем спокойно применить миграции:
alembic upgrade head
Однако это придётся прописывать для каждого проекта, а в случае с GNU/Linux и вовсе при создании каждой миграции после перезапуска терминала. Поэтому давайте рассмотрим способы упростить нам жизнь.
Poetry
Poetry — очень мощный и удобный инструмент для управления зависимостями. Я давно его использую и без ума от счастья.
Прочесть о его установке вы можете в официальной документации и без меня, поэтому я этого показывать не буду. При установке обратите внимание, что вам не нужно виртуальное окружение при установке poetry, устанавливать его следует прямо в систему.
После установки poetry нам нужно его инициализировать. Перейдём в корень проекта и выполним следующую команду:
poetry init
После чего poetry попросит у вас ввести некоторые данные, чтобы заполнить конфиг. Я сделал это так:
This command will guide you through creating your pyproject.toml config. Package name [articles]: Version [0.1.0]: Description []: Author [Your Name <you@example.com>, n to skip]: License []: Compatible Python versions [^3.8]: Would you like to define your main dependencies interactively? (yes/no) [yes] no Would you like to define your development dependencies interactively? (yes/no) [yes] no Generated file . . . Do you confirm generation? (yes/no) [yes]
Я буквально просто везде нажимал enter, оставляя все значения стандартными, вы же можете ввести нужные вам значения. После инициализации poetry создаст лишь один файл. Вы также можете в интерактивном режиме заполнить зависимости, но я предлагаю добавить их после инициализации проекта самостоятельно следующей командой:
poetry add aiogram environs loguru gino alembic psycopg2-binary
Она автоматически пропишет в наш конфиг (pyproject.toml) все зависимости и сгенерирует poetry.lock
, файл, необходимый poetry. Никогда не вносите изменения в файл poetry.lock
вручную. Все изменения вносятся в файл pyproject.toml
, а после применяются командой poetry update
.
Перейдём в файл pyproject.toml
. Сейчас у вас он выглядит примерно так:
[tool.poetry] name = "bot" version = "0.1.0" description = "" authors = ["Your Name <you@example.com>"] [tool.poetry.dependencies] python = "^3.8" aiogram = "^2.11.2" environs = "^9.2.0" loguru = "^0.5.3" gino = "^1.0.1" alembic = "^1.4.3" psycopg2-binary = "^2.8.6" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api"
Впишем в [tool.poetry] packages = [{ include = "app" }], это позволит нам не прописывать PYTHONPATH
. Блок [tool.poetry]
в pyproject.toml
с вписанным массивом packages у меня выглядит так:
[tool.poetry] name = "bot" version = "0.1.0" description = "" authors = ["Your Name <you@example.com>"] packages = [ { include = "app" } ]
Теперь выполним команду
poetry install
Она создаст нам виртуальное окружение и установит необходимые зависимости.
Активируем наше окружение командой
poetry shell
Теперь мы можем вернуться к нашим миграциям. Запустим создание миграций через alembic из poetry:
poetry run alembic revision -m "first migration" --autogenerate
и применим миграции на нашу базу:
alembic upgrade head
Мне кажется это значительно удобнее, чем каждый раз определять переменную окружения. К тому же poetry очень удобный и гибкий инструмент, с которым приятно работать. Погрузиться в изучение poetry вы можете в официальной документации.
Использование Make
Пользователи GNU/Linux (ну и если запариться, то и windows), которые слишком стары, чтобы изучать новые технологии, такие как poetry, могут использовать make.
Создадим в корне проекта файл Makefile
и приступим к его написанию.
В первую очередь подгрузим в него наши переменные окружения из файла .env
:
include .env
Определим переменную PYTHONPATH
, для этого возьмём текущий PYTHONPATH
и добавим к нему текущую корень проекта (получить его можно с помощью функции shell pwd) и название нашего модуля — /app/
:
PYTHONPATH := ${PYTHONPATH}:$(shell pwd)/app/
Для проверки напишем небольшую команду, которая просто поприветствует пользователя, и установим её командой по-умолчанию:
default:help help: @echo "Hello, world!"
После чего попробуем запустить make
:
![](/file/8122a37025143094d4679.png)
Всё работает, можем продолжать. Добавим четыре команды: для выполнения любой команды alembic; для применения миграций; для создания миграций; для отката миграций.
Итоговый Makefile выглядит так:
include .env PYTHONPATH := ${PYTHONPATH}:$(shell pwd)/app/ default:help help: @echo "Hello, world!" alembic: PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic ${args} migrate: PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic upgrade head migration: PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic revision --autogenerate -m "${message}" downgrade: PYTHONPATH=$(shell pwd):${PYTHONPATH} alembic downgrade -1
Теперь можем спокойно создавать миграции, применять их и откатывать. Хочу обратить ваше внимание на то, что аргументы передаются как перемененные, например message=init
, или message="first migration"
, если вам нужно передать строку, состоящую из двух или более слов:
![](/file/59977ad3afc2331a76b2d.png)
Docker & docker-compose
Следующая проблема, которая может у вас возникнуть —применение миграций в докере перед запуском бота. В своё время я убил несколько дней на её решение, которое оказалось для меня не самым предсказуемым, однако пойдём по-порядку.
Не будем медлить, и начнём с самого сложного. Чтобы решить проблему с которой я столкнулся, нам понадобится создать небольшой bash-скрипт, который будет применять миграции перед запуском бота. Создадим scripts/docker-entrypoint.sh
и наполним его следующим содержимым:
#!/bin/sh set -e if [ -n "${RUN_MIGRATIONS}" ]; then alembic upgrade head fi exec python -O -m app
Здесь мы создаём миграции, если это необходимо, и запускаем наше приложение. Вас может смутить аргумент -O
, он передаётся для небольшой оптимизации, не зацикливайтесь на нём.
Dockerfile
Теперь создадим Dockerfile
Я буду использовать готовый образ python:3.8-slim-buster, чтобы контейнер был более легковесным:
FROM python:3.8-slim-buster
Укажем рабочую директорию и укажем нужные нам переменные окружения:
WORKDIR /src ENV PYTHONPATH "${PYTHONPATH}:/src/" ENV PATH "/src/scripts:${PATH}"
Мы вписываем /src/scripts
в PATH
, чтобы была возможность вызывать скрипты только по названию, не вводя полный путь к папке. Дальше копируем всё содержимое папки в контейнер:
COPY . /src
Обновляем pip в контейнере и устанавливаем зависимости (Если вы используете в своём проекте poetry и не хотите оставлять requirements.txt файл исключительно для докера, вы можете создать окружение poetry прямо в докере, как я сделал это в своём шаблоне):
RUN python -m pip install --upgrade pip && pip install -r requirements.txt
Присваиваем нашим скриптам права администратора, чтобы они могли выполняться без sudo
:
RUN chmod +x /src/scripts/*
Указываем в качестве точки входа наш bash-скрипт (Я использую имеено entrypoint, а не cmd, чтобы была возможность сделать команды с аргументами командной строки, если будет такая необходимость):
ENTRYPOINT ["docker-entrypoint.sh"]
Итоговый Dockerfile выглядит следующим образом:
FROM python:3.8-slim-buster WORKDIR /src ENV PYTHONPATH "${PYTHONPATH}:/src/" ENV PATH "/src/scripts:${PATH}" COPY . /src RUN python -m pip install --upgrade pip && pip install -r requirements.txt RUN chmod +x /src/scripts/* ENTRYPOINT ["docker-entrypoint.sh"]
docker-compose
Теперь давайте в корне проекта создадим docker-compose файл и наполним содержимым его:
version: "3" services: db: container_name: database image: postgres:alpine restart: on-failure environment: POSTGRES_USER: $POSTGRES_USER POSTGRES_PASSWORD: $POSTGRES_PASSWORD POSTGRES_DB: $POSTGRES_DB ports: - 5432:5432 volumes: - ./pgdata:/var/lib/postgresql/data bot: container_name: bot build: context: . restart: on-failure env_file: - .env environment: POSTGRES_HOST: db depends_on: - db
Так же, как и в ситуации с образом питона, в качестве образа для базы данных я использую postgres:alpine, чтобы его установка на сервере требовала меньше времени. Ниже я указываю переменные окружения в качестве переменных для базы данных, пробрасываются порты и data.
Бот собирается из Dockerfile, находящегося в одной директории с docker-compose. Указан env_file: .env
, из которого подтянутся все необходимые переменные окружения. Переопределена переменная POSTGRES_HOST: db
. Это необходимо для подключения бота к базе данных в контейнере. В зависимостях указана база данных, чтобы бот запускался только после её готовности.
На этом всё, мы можем запускать проект (Аргумент -d
запустит его в режиме демона):
docker-compose up -d
![](/file/9761dc23a1868b6e5acd1.png)
Завершение
На этом всё. Мы разобрали как реализовать автоматическое создание миграций с помощью инструмента alembic. Также мы познакомились с отличным инструментом для работы с пакетами — poetry. Написали для проекта Makefile, и обернули проект с миграциями в docker.
Если вы используете tortoise-orm, вы можете посмотреть пример реализации миграций в одном из моих github репозиториев.
Статья получилась очень большой, и вы обязательно достигните успехов, если так усидчивы и не бросили её чтение на середине. Я очень благодарен каждому читателю за уделённое время. Весь код есть на github в отдельной ветке.