Pull to refresh
13
0
Александр Васин @alvassin

Разработчик программного обеспечения

Send message
При желании можно отдельно описать на python модели/таблицы и использовать отдельно. У нас есть сервисы на go, разработчики которых используют миграции Alembic.
А на мой взгляд миграции на чистом SQL это нормально и в большинстве это плюс.

Я не говорю что это не нормально. Я лишь перечислил преимущества и недостатки разных подходов. Кто-то файлики с SQL запросами вручную по очереди применяет и кому-то это нравится. Каждый сам решает, что решает его задачу лучше.

Страшилка для джунов. Если миграция в транзакции, то от опечатки просто упадет транза.
Причем для того чтобы такое получилось это надо, прям в консоле продовой базы без транзы, копипастой вставить НЕПРОТЕСТИРОВАННУЮ миграцию. Одним словом шедевральный аргумент.

Речь шла как раз об on-premise инсталляциях. Доступа к продовой базе нет — она может находиться на другом конце света в бункере у клиента, без доступа к интернетам. «Откат транзы» означат, что инсталлятор не сможет обновить продукт у клиента. Это достаточно больно. Единственное, что может как-то застраховать от незапланированного выпуска нового инсталлятора — хорошо протестированные миграции. Alembic позволил решить эту задачу.

Была же история с майлру, когда у них кластер сломался из-за того что в миграции конфигурации кластера поехало форматирование.

Не нам их судить за то, что они не используют минимальное тестирование типа stairway-тестирования.

Кстати, а вы вообще пишете тесты на миграции? Как сделать тесты на миграции когда у вас миграции — это чистый SQL с upgrade, без downgrade (я уже молчу про rollback релизов, только катим вперед, сжигая мосты, ни шагу назад)? Как написать тесты на миграцию с данными?

А можно например посложнее задачку:
upgrade -> ALTER TYPE test_type ADD VALUE 'test';

Вообще-то в рамках транзакции такой запрос работать не будет — только через пересоздание типа данных (что документация, кстати и рекомендует):

...ALTER TYPE… ADD VALUE (the form that adds a new value to an enum type) cannot be executed inside a transaction block...

А гонять миграции без транзакций… ну такое себе.
А почему не классические Liquibase или Flyway?
Я не работал с этими инструментами, но на первый взгляд они уступают по возможностям Alembic.

Alembic позволяет в миграциях написать любой Python/SQL код, декомпозировать запросы, использовать практически любые абстракции и любую обработку данных (обрабатывать данные на стороне базы или на стороне Python) и в большинстве случаев делает всю рутину за вас (генерирует миграции, следит за именованием шаблонов и др).

SQLAlchemy и Alembic — это Python-библиотеки, которые легко описать как зависимости и установить стандартными средствами. Вы можете использовать любой их API, расширять эти инструменты, написать свою программу для управления состоянием базы или написать разные тесты для ваших миграций. Их можно использовать в рамках 1 процесса и потока вашей программы.

Liquibase предлагает описывать миграции в виде XML (также поддерживаются YAML, JSON и SQL), технически возможности этого инструмента сильно ограничены и самый простой способ их расширить — писать код на SQL со всеми вытекающими. Поставляется в виде бинарника, который нужно устанавливать отдельно и запускать как отдельный процесс. Также смутило, что Liquibase добавляет две таблицы DATABASECHANGELOG и DATABASECHANGELOGLOCK, хотя большинство инструментов, с которыми я работал, вполне обходятся одной.

На мой взгляд, Liquibase по возможностям не сильно превосходит yoyo: она решает ту же задачу построения цепочки миграций, и хотя теоретически существующие change types могут поддерживать разные DSL инструкции для разных СУБД, их набор ограничен примитивными операциями, любая более-менее сложная обработка данных сведется к написанию больших объемов SQL (при этом yoyo устанавливается стандартными средствами и его API можно использовать в программах и тестах на Python, для его вызова из Python-программы не требуется создавать отдельный процесс).

Flyway также написан на Java и в ряде случаев платный (dry-run и undo платные?). Не предоставляет query builder-а, не умеет из коробки обрабатывать задачи типа ограниченного ALTER TABLE в SQLite.

Зачем, если есть бесплатные, мощнейшие инструменты с открытым исходным кодом и поддержкой не хуже платных инструментов (в мейлинг-листах отвечают достаточно шустро, можно бесплатно задать вопрос лично главному разработчику алхимии — Майку Байеру).
Миграции надо писать только на SQL, никаких генераторов. Применять инструменты типа yoyo-migrations, чтобы просто следили за порядком выполнения миграций.

Звучит достаточно категорично, а ведь вы не привели ни одного аргумента.

Во-первых, миграции на чистом SQL нужно писать, на мой взгляд это уже минус. Нужно думать о зависимостях, порядке выполнения запросов, едином именовании constraint-ов. Alembic автоматизирует много рутинных задач — пробежаться глазами по готовой миграции сильно быстрее, чем писать код с нуля.

Во-вторых, миграции бывают большими и сложными. Читать декомпозированный код на Python сильно проще чем смесь из Python и SQL запросов, которые декомпозируются только конкатенацией строк. Мне приходилось поддерживать написанные на yoyo миграции, я бы не назвал их поддержку приятным опытом.

В-третьих, инструментов БД для обработки данных бывает недостаточно: необходимо получить данные из базы, обработать с помощью Python и записать обратно. Alembic даже предлагает для этого два режима выполнения миграций — online (миграция выполняется с живым подключением к серверу) и offline (из миграции можно сгенерировать SQL запросы для последующего выполнения).

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

В-четвертых, существуют проекты, рассчитанные на разные инсталляции (например, для on-premises инсталляций — SQLite, для разворачивания в облаке — PostgreSQL). У SQLite есть свои особенности с ALTER TABLE, например. Alembic предлагает решение для подобных задач.

Мне приходилось работать и с такими проектами, где из-за 1 опечатки в миграции продукты клиентов могут перестать работать и им потребуется выпускать и доставлять апдейты, что сопряжено с огромными репутационными и денежными издержками. Alembic, в свою очередь позволил писать достаточно краткий, лаконичный, поддерживаемый код и не подвел ни разу.
Подводя итог: Alembic и SQLALchemy предланают много надежных, проверенных временем решений для широкого круга задач, тогда как yoyo решает только одну задачу с цепочкой миграций. Выполнять ли рутинную работу самому или поручить ее программам — это уже личный выбор каждого. Я бы не назвал yoyo-миграции современным решением.

Попробуйте сделать такую миграцию на alembic:
INSERT INTO table2 SELECT * FROM table1 WHERE condition;

В SQLAlchemy есть метод Insert.from_select
Пример
from sqlalchemy import (
    Column, Integer, MetaData, String, Table, select,
)


metadata = MetaData()

table1 = Table(
    'table1',
    metadata,
    Column('id', Integer, primary_key=True),
    Column('name', String)
)

table2 = Table(
    'table2',
    metadata,
    Column('id', Integer, primary_key=True),
    Column('name', String)
)

select_stmt = select([table1.c.id, table1.c.name]).where(table1.c.id > 5)
insert_stmt = table2.insert().from_select(['id', 'name'], select_stmt)

print(insert_stmt)

Сгенерирует желаемый вами SQL:

INSERT INTO table2 (id, name) SELECT table1.id, table1.name 
FROM table1 
WHERE table1.id > :id_1

Ой, оказывается alembic это про миграцию схемы, а не про миграцию данных.

Не понял, как вы пришли к такому выводу, на мой взгляд с точки зрения релиза эти понятия неотделимы. Alembic позволяет выполнять любые запросы, которые вы можете (или не можете) описать с помощью SQLAlchemy для получения/изменения данных.

А как дела обстоят с хранимыми процедурами/функциями и триггерами? Да никак.

Мы не используем хранимые процедуры, в нашей команде это запрещено.

Короче в любой сложной ситуации будет op.execute('...') c чистым SQL внутри.

Alembic предлагает разработчикам целый ряд механизмов (тот же batch для SQLite), а также query builder Алхимии, на котором можно хоть рекурсивные запросы описать.
Что делает команда make postgres
# Останавливает контейнер analyzer-postgres, если он запущен
$ docker stop analyzer-postgres || true

# Запускает контейнер с именем analyzer-postgres
$ docker run --rm --detach --name=analyzer-postgres \
    --env POSTGRES_USER=user \
    --env POSTGRES_PASSWORD=hackme \
    --env POSTGRES_DB=analyzer \
    --publish 5432:5432 postgres

У вас не получалось подключиться, потому что если при запуске контейнера сеть не указана явно, он создается в дефолтной bridge-сети. В такой сети контейнеры могут обращаться друг к другу (и к хосту) только по ip адресу. Кстати, Docker for Mac предоставляет специальное DNS имя host.docker.internal для обращений к хост-машине из контейнеров, что довольно удобно для локальной разработки.

Аргумент --publish 5432:5432 указывает Docker добавить в iptables правило, по которому запросы к хост-машине на порт 5432 отправляются в контейнер с Postgres на порт 5432.

Поэксперементируем с подключением к контейнеру с Postgres
Подключение с хост-машины на published порт:
$ telnet localhost 5432
Trying ::1...
Connected to localhost.
Escape character is '^]'.

Подключение из контейнера с приложением (по IP хост-машины на published-порт):
# Получаем IP хост-машины
$ docker run -it alvassin/backendschool2019 /bin/bash -c \
    'apt update && apt install -y iproute2 && ip route show | grep default'
default via 172.17.0.1 dev eth0

# Применяем миграции
$ docker run -it \
    -e ANALYZER_PG_URL=postgresql://user:hackme@172.17.0.1/analyzer \
    alvassin/backendschool2019 \
    analyzer-db upgrade head

Подключение из контейнера с приложением (по host.docker.internal, published порт нужен, потому что подключаемся через хост-машину):
$ export HOST=host.docker.internal
$ docker run -it \
    -e ANALYZER_PG_URL=postgresql://user:hackme@${HOST}/analyzer \
    alvassin/backendschool2019 \
    analyzer-db upgrade head

Подключение из контейнера с приложением (по IP контейнера Postgres, published порт в этом случае не нужен):
# Получаем IP
$ docker inspect \
    -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
    analyzer-postgres
172.17.0.2

$ docker run -it \
    -e ANALYZER_PG_URL=postgresql://user:hackme@172.17.0.2/analyzer \
    alvassin/backendschool2019 \
    analyzer-db upgrade head


Если контейнер с приложением запускается в сети хост-машины (параметр --network host), он не получает отдельного ip адреса и его сетевой стек не изолируется.

Поэтому такой контейнер имеет доступ к портам хост-машины через localhost.
$ docker run -it \
    --network host \
    -e ANALYZER_PG_URL=postgresql://user:hackme@localhost/analyzer \
    alvassin/backendschool2019 \
    analyzer-db upgrade head


Третий вариант подружить контейнеры — создать bridge сеть вручную и запустить в ней оба контейнера — и Postgres и контейнер с приложением. В отличие от дефолтной bridge-сети, в созданных пользователем bridge-сетях можно обращаться по имени контейнера.

Это бы выглядело следующим образом
# Создаем сеть
$ docker network create analyzer-net

# Запускаем контейнер с PostgreSQL в сети analyzer-net
$ docker run --rm -d \
    --name=analyzer-postgres \
    --network analyzer-net \
    -e POSTGRES_USER=user \
    -e POSTGRES_PASSWORD=hackme \
    -e POSTGRES_DB=analyzer \
    -p 5432:5432 \
    postgres

# Запускаем контейнер с приложением в сети analyzer-net,
# обращаемся по имени к контейнеру с Postgres
$ docker run -it --network analyzer-net \
    -e ANALYZER_PG_URL=postgresql://user:hackme@analyzer-postgres/analyzer \
    alvassin/backendschool2019 \
    analyzer-db upgrade head

1. Почему не прикрутили NGINX? Не будет ли standalone сервер aiohttp медленее, чем с NGINX?
Андрей Светлов рекомендует использовать nginx c aiohttp по следующим причинам:

  • Nginx может предовратить множество атак на основе некорректных запросов HTTP, отклонять запросы, методы которых не поддерживаются приложением, запросы со слишком большим body.
    Это может снять какую-то нагрузку с приложения в production, но выходит за рамки данного задания.
  • Nginx здорово раздает статику.
    В нашем приложении нет статических файлов.
  • Nginx позволяет распределить нагрузку по нескольким экземплярам приложения слушающих разные сокеты (upstream module), чтобы загрузить все ядра одного сервера.
    На мой взгляд проще и эффективнее обслуживать запросы в одном приложении на 1 сокете несколькими форками, чем устанавливать и настраивать для этого отдельное приложение. Я рассказывал как это сделать в разделе «Приложение».
  • Добавлю от себя: nginx может играть роль балансера, распределяя нагрузку по разным физическим серверам (виртуалкам).
    По условиям задания, у нас есть только один сервер, где мы можем развернуть приложение, поэтому это тоже выходит за рамки нашей задачи.

Как вы видите, именно для этого приложения nginx не очень полезен. Но в других случаях (описанных выше), он может очень здорово увеличить эффективность вашего сервиса.

2. Почему citizens не разбиты на отдельные таблицы для каждого импорта? Намек на это также был в вашем FAQ видео. Еще это позволило бы лочиться только по citizens.id, а не по всей выгрузке (import_id).
Для этой задачи большой разницы нет, можно создавать и отдельные таблицы. Минусы этого подхода: если потребуется реализовать обработчик, возвращающий всех жителей всех выгрузок придется делать очень много UNION-ов, а также при создании таблиц с динамическими названиями их придется экранировать вручную, т.к. PostgreSQL не позволяет использовать аргументы в DDL.

3. Не рассматривался ли вариант получения (от клиента) выгрузки по частям? Возможно ли это сделать средствами aiohttp/python? Например, через request.content: docs.aiohttp.org/en/stable/streams.html или это не поможет читать данные кусками?
Получать и обрабатывать json запрос по частям cилами aiohttp можно
from http import HTTPStatus

import ijson
from aiohttp.web_response import Response

from .base import BaseView


class ImportsView(BaseView):
    URL_PATH = '/imports'

    async def post(self):
        async for citizen in ijson.items_async(
            self.request.content,
            'citizens.item',
            buf_size=4096
        ):
            # Обработка жителя
            print(citizen)
        return Response(status=HTTPStatus.CREATED)

Сейчас валидация входных данных в обработчике POST /imports происходит двумя Marshmallow схемами: CitizenSchema (проверяет конкретного жителя) ImportSchema (проверяет связи между жителями и уникальность citizen_id) и только затем пишется в базу.

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

4. Не очень понял используется ли connection pool. В коде, при старте, приложение коннектится к postgres, но не очень понятен размер пула (или коннекшн один?).
Используется пул с 10 подключениями (размер по умолчанию). При запуске aiohttp приложения cleanup_ctx вызывает генератор setup_pg, который в свою очередь создает объект asyncpgsa.PG (с пулом соединений) и сохраняет его в app['pg']. Базовый обработчик, для удобства предоставляет объект app['pg'] в виде свойства pg. Кол-во подключений, кстати, можно вынести аргументом ConfigArgParse — настраивать приложение будет очень удобно.

5. Я c экосистемой python не очень хорошо знаком (в последние пару лет сижу на java), немного смутило, что нет разделения на слои («луковой» архитектуры).
Транспортный слой (контроллеры, у вас это handlers) перемешан со слоем бизнес логики и со слоем БД. Можно было бы как-нибудь разделить, часть вынести в сервисы. Кмк, это уменьшило бы связанность (если говорить о проекте, который будет со временем расти).
У нас микросервисы, поэтому на мой взгляд нужно руководствоваться правилом KISS. Если что — можно быстро написать новый и выкинуть старый.

6. Так же не используется DI. Для python есть библиотека injector, но создается впечатление что в экосистеме python'a так не принято :) Кстати, в Яндексе где-нибудь используется injector или своя DI-библиотека для python'а?
Не хотелось переусложнять это приложение. В Едадиле мы часто пользуемся модулем aiomisc-dependency. Он здорово выручает, когда в одной программе живет несколько сервисов (REST API, почтовый сервис, еще что-нибудь) и им требуется раздлеляемый пул ресурсов (например, один connection pool asyncpg), хотя этот модуль вполне можно использовать и для создания любых других объектов. Что касается Яндекса — не могу сказать, разработчиков очень много и у всех есть свои любимые инструменты.
Существует два стандарта: зрелый setup.py и новый, описанный в PEP 518 и PEP 517.

PEP 518 описывает возможность указывать требования (зависимости) к системе сборки с помощью файла pyproject.toml и находится в статусе final. Это означает, что он больше не будет изменяться.

PEP 517 описывает интерфейсы для независимых от distutils и/или setuptools систем сборки (которые использует poetry и другие альтернативные инструменты), и находится в статусе provisional. Авторы PEP в данный момент собирают фидбек сообщества и PEP еще может измениться или его вообще могут не принять в существующем виде. Поэтому на мой взгляд рекомендовать его пока рано — не хотелось бы потом переписывать все проекты.

Вообще PEP 517 и poetry выглядят здорово, лично мне они нравятся. К слову, aiohttp уже вместе с setup.py использует pyproject.toml.
rmsbeetle, согласно документации «модуль» — файл с инструкциями Python, а «пакет» — способ организации пространства имен модулей (синтаксически — с помощью точек). Однако в ней же слово «модуль» иногда трактуется более широко: например, технически asyncio — пакет (неймспейс с несколькими модулями), но иногда называется модулем.
Под пакетом я имел в виду единицу дистрибуции, а под модулем разные вещи, в зависимости от контекста. Я исправлю терминологию, чтобы не вводить никого в заблужение. Спасибо за замечание.

На второй вопрос я, пожалуй, не смогу ответить за весь Яндекс, так как в компании очень много команд, и у всех немного по-разному.
В Едадиле — это прозвучит очень банально — мы стараемся как можно больше делиться опытом друг с другом и общаться с коллегами, смотреть PR друг друга (посмотреть PR другой команды — скорее правило, а не исключение).
Если кто-то видит, что он уже решал похожую проблему — скорее всего появится хорошо решающая эту проблему библиотека, покрытая тестами. Например, в свое время так появился aiomisc, aiomisc-dependency, snakepacker, wsrpc и другие.
Для начала стоит использовать bigint. Это конечно не очевидно, но с переполнением int я уже сталкивался.
Зависит от задачи. В постоянно растущих таблицах в этом конечно есть смысл.
И в этом случае вместо одного поля в 16 байт и одного индекса у меня будет 2 поля в сумме дающие 20 байт, а если все же брать bigint 24 байта и два индекса. И собственно зачем?
Cкорость выполнение запросов (и как следствие — скорость работы сервиса) очень важна, в то время как диск почти ничего не стоит.

Предлагаемый вами вариант на вставке медленнее на ~20%
Для теста создал две таблицы, в которые вставлял 3 миллиона записей частями по 10 тысяч записей за раз:
CREATE TABLE citizens_int_uuid4 (
    citizen_id bigint primary key, 
    name text,
    external_id uuid DEFAULT uuid_generate_v4() UNIQUE
);

CREATE TABLE citizens_uuid1 (
    citizen_id uuid primary key DEFAULT uuid_generate_v1mc(),
    name text
);

Вариант с uuid1, который вы предлагаете, выполнился за 2 мин 27 сек. Вариант с uuid4 — за 2 мин.

Также попробовал что вариант с uuid4 как с bigint так и с int. Интересно, что разница получилась всего на 2-4 секунды.

Уверен, что другие операции (например, JOIN) тоже будут работать медленее, вопрос только насколько.
Пишутся схемы ожидаемых полей и все. Декларативно и хорошо, вполне прозрачно.

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

Гарантировать, что в наружу лишнее не уйдет конечно можно и нужно схемами, но например в тестах. Это можно проверить 1 раз на CI.

Надо просто использовать функцию uuid_generate_v1mc() она имеет корелляцию к timestamp. Что позволяет это уменьшить.

Не понимаю только зачем. Не проще ли использовать первичным ключом int (оно очевидно будет быстрее и проще для постгрес — 4 байта против 16) и иметь дополнительное поле uuid4 с уникальным индексом, которое отдавать наружу клиентам, если вы хотите избежать перебора сущностей по id?
Для сериализации данных для клиента Marshmallow полезен, если нужно из одной структуры в базе сделать совсем другую структуру и когда где-то необходимо описать этот маппинг.

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

В PostgreSQL уже давно можно использовать uuid в качестве ключей

Если в качестве ключа использовать случайный uuid то индекс будет перестраиваться на каждый запрос на вставку. Это тяжело.
Ага, думал вы другую задачу решаете. Сериализовать объект с произвольными типами в json можно силами asyncpg методом Connection.set_type_codec. Ему можно указать произвольный сериализатор, например json.dumps с параметром default из stdlib.

Пример
import asyncio
import json
import logging
import uuid
from functools import partial

import asyncpg


def convert(value):
    if isinstance(value, uuid.UUID):
        return str(value)
    raise NotImplementedError


async def main():
    logging.basicConfig(level=logging.INFO)

    conn = await asyncpg.connect('postgresql://user:hackme@localhost/example')
    await conn.set_type_codec(
        'json',
        encoder=partial(json.dumps, default=convert),
        decoder=json.loads,
        schema='pg_catalog'
    )
    await conn.execute(
        'CREATE TABLE IF NOT EXISTS examples '
        '(example_id serial primary key, data JSON)'
    )

    uuid_value = uuid.uuid4()
    result = await conn.fetchrow(
        'INSERT INTO examples (data) VALUES ($1) RETURNING examples.*',
        {'uuid': uuid_value}
    )

    # Не сработает, т.к. uuid десериализуется в виде строки.
    assert isinstance(result['data']['uuid'], uuid.UUID)


Вот десериализовать json-поле обратно в желаемые объекты без дополнительного инструмента уже просто так не получится — тут, безусловно, Marshmallow будет очень кстати.

Но тогда возникает вопрос: вы хотите хранить UUID как нативный тип PostgreSQL, или использовать тип данных JSON, который будет хранить UUID в виде строки?
Marshmallow я использовал исключительно для валидации, сериализацию UUID asyncpg умеет из коробки. Кстати, ему можно указать как сериализовать/десериализовать произвольные типы.

Пример
import asyncio
import logging
import uuid

import asyncpg


async def main():
    logging.basicConfig(level=logging.INFO)
    
    conn = await asyncpg.connect('postgresql://user:hackme@localhost/example')
    await conn.execute(
        'CREATE TABLE IF NOT EXISTS examples '
        '(example_id serial primary key, uuid UUID)'
    )

    value = uuid.uuid4()
    result = await conn.fetchrow(
        'INSERT INTO examples (uuid) VALUES ($1) RETURNING examples.*', 
        value
    )
    assert isinstance(result['uuid'], uuid.UUID)
    assert value == result['uuid']


asyncio.run(main())

Интересный пример) модуль dis показывает что сначала ссылка на список (DUP_TOP) присваивается переменной x (STORE_FAST), а только потом уже идет зацикливание (STORE_SUBSCR):

In [1]: def f():
...: x = x[0] = [0]
...:

In [2]: f.__code__.co_consts
Out[2]: (None, 0)

In [3]: f.__code__.co_varnames
Out[3]: ('x',)

In [4]: from dis import dis

In [5]: dis(f)
2 0 LOAD_CONST 1 (0)
2 BUILD_LIST 1
4 DUP_TOP
6 STORE_FAST 0 (x)
8 LOAD_FAST 0 (x)
10 LOAD_CONST 1 (0)
12 STORE_SUBSCR
14 LOAD_CONST 0 (None)
16 RETURN_VALUE

Работает только с мутабельными объектами)

Information

Rating
Does not participate
Location
Москва, Москва и Московская обл., Россия
Works in
Registered
Activity