Как стать автором
Обновить

Не только ORM (NoORM)

Уровень сложностиПростой
Время на прочтение8 мин
Количество просмотров6.9K

Привет, Хабр! Хочу поделиться самодельной питонской библиотекой (ссылка на GitHub в конце статьи), существенно упрощающей взаимодествие с базами данных.

На настоящий момент популярны два основных способа организовать работу с базой данных:

  1. Воспользоваться «низкоуровневым» интерфейсом СУБД. Например, если у нас база данных в SQLite, можем работать через стандартную библиотеку sqlite3.

  2. Задействовать какой-нибудь из популярных ORM-ов, например, SqlAlchemy.

Первый вариант хорош для небольших скриптов, простеньких микросервисов и прочих изделий, объём исходного кода которых не превышает пары тысяч строк. В больших и серьёзных проектах хождение в базу данных через низкоуровневые интерфейсы становится мучительным, и народ предпочитает работать через ORM. Но ORM это тоже не сахар с мёдом. На примерах из туториала любой ORM выглядит как сбывшаяся мечта, но по мере роста функциональности системы, объёма базы данных и нагрузки выясняется, что ORM-ное счастье не было бесплатным. Ранее здесь я уже поворчал о том, что задача "Object-Relational Mapping" решения не имеет, и что-то с этим нужно делать. Предложенный тогда вариант просто взять и посадить себя на без-ORM-ную диету был явно непрактичным, поэтому в серьёзных проектах ничего другого не оставалось, как продолжать есть этот кактус, но при этом не прекращать попыток придумать альтернативу.

Постепенно выкристаллизовалась идея NoORM ("Not only ORM", по аналогии с "Not only SQL"):

  1. Мы не объявляем священную войну ORM-ам, а гармонично с ними сосуществуем, давая альтернатианые решения там, где ORM-ы традиционно приносят головную боль.

  2. Мы не пытаемся сделать плюс ещё один тысяче первый, но на этот раз самый окончательно правильный ORM. Чётко осознаём, что задача "ORM" не имеет решения.

  3. Технология должна быть применима не только на маленьких поделках и DIY-проектах, но и на больших и сложных бэкендах.

  4. Фокус на улучшение developer experience. Все эти удобняшки современных IDE – атодополнение, подсветка синтаксиса, переход к объявлению – всё это очень сладко, вносит комфорт и повышает продуктивность.

  5. Технология должна быть подобна истинному дао – делать правильные вещи простыми и естественными, сопротивляясь использованию корявых и неэффективных решений.

Откуда берутся сложности с SQL

Назовём клиентом программу, общающуюся с сервером СУБД. Проигнорируем тот факт, что этот наш клиент сам, возможно, для кого-то является сервером. Взаимодействие с СУБД построено таким образом, что сервер в качестве запроса принимает, по сути, текст программы (SQL это тоже язык программирования, даже не пытайтесь спорить), исполняет её, и отдаёт назад результат. Структура результата зависит от текста запроса, а также от схемы базы данных.

Весьма странный API, не правда ли? Нетипичный. Какой-то текст на вход, какая-то табличка (или число, или просто ничего) на выход – вот и вся схема, вот и весь контракт. Этим, конечно, достигается потрясающая функциональная гибкость, но с точки зрения кода клиента, написанного на «обычном» языке программирования, это сущий кошмар.

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

ORM-ы, собственно, нужны как раз для того, чтобы сделать взаимодействие с базой данных более естественным для того языка, на котором реализована логика приложения:

Взаимодействие с БД через ORM можно схематично изобразить так:

Примечательно здесь то, что работа с базой данных идёт через персистентные объекты, являющиеся экземплярами «модельных» классов, описывающих структуру БД. Эти персистентные объекты умеют себя прочитать из базы и в неё себя записать. Они живут внутри открытой сессии. И ещё эти объекты умеют «лениво» дотягивать из базы связанные с ними другие персистентные объекты. Эти самые персистентные объекты – корень всех проблем:

  1. По сути, это передача мутабельного объекта в другой процесс. Безобразно тупая затея. Мы запросили сущность «пользователь Вася» из базы данных в процесс своего бэкенда, и теперь где у нас теперь мастер-копия? Как мы их собираемся синхронизировать, в какой момент, и что собираемся делать с возможными коллизиями?

  2. Что случается с живущими в сессии объектами когда сессия закрывается? Что если они продолжают быть нужны в логике приложения? Что если эта логика продолжает считать, что это по-прежнему нормальные объекты, принадлежащие живой сессии?

  3. Невозможно найти единственно правильный баланс между eager- и lazy-загрузкой. Если увлекаемся lazy, получаем проблему N+1, и всё начинает страшно тормозить. Если увлекаемся eager, на каждый невинный чих ORM пытается вычитать полбазы, и тоже всё тормозит. Короче, у нас две педали, но обе они педали тормоза.

Идея персистентных объектов – тяжёлое наследие платоновской концепции «Мира идеальных сущностей». Поначалу нам может показаться соблазнительно один раз на веки вечные и на все случаи жизни реализовать класс Person, но потом внезапно оказывается, что с точки зрения сервиса аутентификации пользователей Person это одно, с точки зрения бухгалтерии другое, а с точки зрения HR третье, и эти точки зрения местами противоречат друг другу. Мы пытаемся создать класс Person, экземпляры которого будут удобны и полезны везде, но в итоге у нас получается корявый, огромный и чрезвычайно капризный программный монстр, жрущий как аппаратные ресурсы, так и рабочее время сотрудников. Даже если база данных одна общая на всех, даже если таблица "persons" там тоже одна, всё же для разных целей нам бывает удобно делать совсем разные, порой весьма причудливые SELECT-ы. Одна из ключевых идей NoORM – отказ от использования персистентных объектов. Не модельных классов, заметьте, а именно персистентных объектов.

Программный интерфейс базы данных

Создаём в своей программе дополнительный слой, и тем самым рассмотренный ранее «весьма странный API» превращаем в обычный:

Для любого императивного (и тем более функционального) языка программирования самая естественная в мире вещь это функция, которую можно вызвать с известно какими параметрами, и которая вернёт результат известно какого типа. С точки зрения кода-потребителя программный интерфейс БД выглядит как-то так:

Что мы здесь видим:

  • У нас есть модуль db_api, в котором есть модуль users, в котором есть функция get_users.

  • Эта функция принимает на вход соединение с базой данных и отдаёт список объектов DbUser, у которых есть атрибуты email, id и username.

  • Всё это замечательно дружит с удобняшками IDE, линтерами и mypy.

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

Что касается DbUser, то это никакой не персистентный объект, никакая не платоновская идеальная сущность, а всего лишь dataclass, в который заворачивается результат конкретного запроса. Вот как это выглядит в модуле db_api.users:

У вас может возникнуть резонный вопрос: а не закончится ли это тем, что у нас в "db_api" будут сотни и тысячи каких-то маловразумительных датаклассов и функций, и ориентироваться в этом станет совсем невозможно? Честно скажу, у меня самого были такие опасения, когда в порядке эксперимента я взялся переводить с ORM на NoORM один приличного объёма сервис, который много и разнообразно общается с базой данных. Однако обошлось. Более того, стало значительно легче находить ответы на вопросы о том, как, где и для чего используются конкретные таблицы и поля базы данных. Стал проще рефакторинг. Избавившись от персистентных объектов, избавились от необходимости держать открытой сессию на протяжении всей обработки клиентского запроса. Плюс абсолютно предсказуемое поведение коммита – в базу пишется только то, что мы хотим в неё записать здесь и сейчас, и нет никаких персистентных объектов, которые по какой-то неясной причине тоже решили пристроиться к этому коммиту. Ну и, самое сладкое, все "N+1" стали видны как на ладони – если мы в потенциально длинном цикле вызываем какую-то функцию, в которую параметром передаём соединение с БД, мы же не просто так его туда передаём, а, очевидно, для того, чтобы сходить в БД столько раз, сколько раз прокрутится цикл.

NoORM + ORM = ♥

ORM это не только плохие капризные портящие нам кровь персистентные объекты, но и множество восхитительных удобств, от которых нет смысла отказываться:

  • Модель структуры базы данных. Можно держать структуру базы данных в голове, можно нарисовать её тушью на ватмане, можно в разбросанных по столу черновиках, можно в заметочках в ноушене или на страничках в конфлюенсе, а можно в питонском коде под гитом. Последний вариант мне кажется самым симпатичным.

  • Миграции. Они в любом случае боль, но ORM-мы умеют облегчать страдания. Глупо этим пренебрегать.

  • Я тут сказал много злых слов про персистентные объекты, но если их не пускать в «боевой» код, а использовать только для генерации тестовых данных, то там они чудо как хороши. По сути, едва объявив структуру данных, мы сразу забесплатно «из коробки» получаем для неё реализованный CRUD. Когда нам абсолютно наплевать и на производительность, и на масштабируемость, и на конкурентное исполнение, тогда персистентные объекты – прекрасное решение.

DB-API-функция извлечения данных, работающая через SqlAlchemy выглядит так:

Знаю, некоторым в тягость такой стиль написания SQL, но нельзя не признать, что у него есть свои преимущества, особенно на простых запросах.

Некосколько рекомендаций

По ходу «опытной эксплуатации» этой технологии выработались несколько рекомендаций, которых полезно придерживаться:

  1. Не надо нагружать бизнес-логикой объекты, возвращаемые DB API. Пусть это будут просто датаклассы или даже namedtuple-s. Впрочем, никто не мешает реализовать несколько дополнительных свойств, если их вычисление в SQL-запросе по каким-то причинам затруднительно.

  2. Объявление этих датаклассов – непосредственно перед функциями, которые их будут возвращать. Точно не в отдельном модуле. Между SQL-запросом и тем местом, где определяется структура его результата должно быть не далеко ходить. Идеально, если объявление датакласса вместе с SQL-запросом помещаются одновременно на один экран.

  3. С именами этих датаклассов сильно упариваться не нужно, но удобно, когда они выделяются визуально – когда мы видим, что имя типа переменной начинается с "Db", мы сразу понимаем, что значение взялось из базы данных.

  4. Удобно, когда выработано некоторое соглашение об именовании DB-API-функций. Мы используем префиксы "get_", "ins_", "upd_", "del_", "upsert_". Для функций, в которых принудительно отключается автокоммит, используем суффикс "_no_commit".

  5. Когда DB-API-функций мало, их можно держать в одном модуле "db_api.py". Если их становится больше, распиливаем этот модуль по функциональным областям, например, "db_api/users.py", "db_api/orders.py", "db_api/warehause.py".

  6. Если в монорепозитории живёт несколько подсистем, есть смысл иметь один общий модуль "db_api", но специфические вещи вынести в "db_api"-модули подсистем.

  7. Если какая-то часть системы по своей сути является скопищем SQL-запросов (например, коллекция даталоадеров для GraphQL), оставьте эти запросы где они есть. Просто избавьтесь от персистентных объектов, но ни в какое "db_api" не выносите.

Библиотека true-noorm

В принципе, NoORM-стиль можно практиковать и без дополнительной библиотеки, но тогда нам приходится каждый раз писать одинаковый код, вызывающий исполнение запроса и преобразующий результат в датаклассы. Это раздражает и утомляет. Кроме того, выгода от вынесения доступа к базе в отдельные DB-API-функции становится менее очевидной, и в результате всё заканчивается тем, что мы снова начинаем свинячить SQL-код где попало.

Библиотека предельно проста в использовании. Всего лишь пять декораторов – sql_fetch_all для изготовления функции, возвращающей список объектов, а также sql_one_or_none, sql_scalar_or_none, sql_fetch_scalars и sql_execute для сами угадайте чего. Плюс немножко дополнительной функциональности, в частности, реестр функций, автоматически собирающий метрики. Всё это реализовано для:

  • SQLite – через стандартную библиотеку sqlite3 и async через aiosqlite.

  • Postgres – sync через psycopg2 и async через asyncpg.

  • MySQL/MariaDB – sync через PyMySQL и async через aiomysql.

  • Для всего остального, с чем работает SqlAlchemy, если выбран вариант «NoORM через ORM». Тоже в исполнениях sync и async.

Если нужно что-то ещё, пишите в гитхаб в Issues. Руки чешутся добавить адаптеры для Mongo, но пока удаётся себя сдерживать. С интересом смотрю в сторону PonyORM, и если кому-нибудь это надо, могу добавить. Адаптера для Django нет и не будет, поскольку, к сожалению, работа через персистентные объекты там безальтернативна.

Обещанная ссылка на гитхаб: здесь.

P.S. «НоуОуАрЭм» – язык сломаешь, поэтому прижился вариант произношения /nuːrm/ – «нурм», «нурмализация», «сделаем сразу нурмально».

Теги:
Хабы:
+7
Комментарии21

Публикации

Истории

Работа

Data Scientist
81 вакансия
Python разработчик
128 вакансий

Ближайшие события

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область