Привет, Хабр! Хочу поделиться самодельной питонской библиотекой (ссылка на 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/ – «нурм», «нурмализация», «сделаем сразу нурмально».

Комментарии (21)


  1. whoisking
    02.06.2024 13:28
    +4

    Запросов бд в декораторах я ещё не встречал


    1. maslyaev Автор
      02.06.2024 13:28
      +1

      Экспериментировал с разными вариантами, и этот оказался самым симпатичным


  1. samizdam
    02.06.2024 13:28

    Шёл 2024 год, в питоне пытались реализовать базовые шаблоны проектирования взаимодействия с БД)


    1. edo1h
      02.06.2024 13:28
      +3

      А как правильно-то?


  1. janvaljan
    02.06.2024 13:28

    А что вы скажите на счет того подхода к взаимодействию с базой данных, что реализовано библиотекой JOOQ, которая позиционирует себя как то, что избавляет от ORM проблем, а так же избавляет от проблем использования простого SQL. Это, конечно, библиотека из мира Java разработки, но все же это та же некоторая альтернатива "Object-Relational Mapping", хоть и в строго типизированном языке, я понимаю, есть другие проблемы и решения.


    1. maslyaev Автор
      02.06.2024 13:28

      Мне JOOQ показался поразительно похожим на то, как делаются запросы в SqlAlchemy. Такое же цепочечное нанизывание "родных" конструкций. В сочетании с NoORM работает замечательно. Пользуемся.


  1. Kerman
    02.06.2024 13:28
    +1

    По своему опыту ормостроения я понял, что для разных случаев нужны разные уровни доступа к данным. От SQL всё равно не уйти, но это не значит, что ORM не нужен.

    задача "Object-Relational Mapping" решения не имеет

    Небольшая поправка: не имеет решения для общего случая. Как и задача сжатия данных. Но алгоритмами сжатия пользуются

    Невозможно найти единственно правильный баланс между eager- и lazy-загрузкой

    Вот меня удивляет, что в гибернейте eager/lazy прибиты гвоздями в описании структуры данных. А EF тем временем позволяет явно подключать джойном данные в рантайме с помощью .Include() для каждого запроса индивидуально.

    Спасибо за статью, это мотивирует самого что-то написать. Давно хотел рассказать про свою ORM, где "базы данных не существует".


    1. ptr128
      02.06.2024 13:28
      +1

      EF тем временем позволяет явно подключать джойном данные в рантайме с помощью .Include() для каждого запроса индивидуально

      Даже в MS SQL, от EF нередко прилетают такие запросы, что ставят оптимизатор в тупик. А уж оптимизатор PostgreSQL явно тупей и там это начинает приобретать катастрофические масштабы. Поэтому, если бизнес-логика требует сложных запросов, то, скрепя сердце, приходится деплоить в БД представления, функции и процедуры. И если в количественном отношении их намного меньше, чем напрямую формируемых EF запросов, то по количеству строк SQL/plpgsql кода у них паритет.


    1. maslyaev Автор
      02.06.2024 13:28

      А EF тем временем позволяет явно подключать джойном данные в рантайме с помощью .Include() для каждого запроса индивидуально.

      Это тоже порождает массу неудобств и кривизны. Когда мы получаем объект, нам нужно раскинуть карты Таро и предугадать, какие вещи нам нужно включить в .include. И не только для выполнения текущей задачи, но и последующих доработок, о которых у нас пока что нет никаких идей.
      Представьте себе, что у нас есть некая функция, принимающая параметр order, экземпляр персистентного ORM-ного класса Order. Можем ли мы без дополнительного похода в базу обратиться к order.lines или к order.customer.email? Чтобы ответить на эти вопросы, нужно проследить все возможные цепочки вызовов, ведущие в эту функцию. Притом не только имеющиеся, но и те, которые, возможно, появятся в будущем.
      С простыми и предсказуемыми датаклассами всё намного проще и надёжнее. Если у DbOrder есть свойство customer_email, можем без доп. изысканий его использовать.

      Давно хотел рассказать про свою ORM, где "базы данных не существует".

      Так расскажите. Интересно.


      1. Le0Wolf
        02.06.2024 13:28

        Угу, а если нужно, например, получить всех пользователей, у которых сумма хотя бы одного из заказов превышает тысячу рублей? Как вы это будете решать с "простыми и предсказуемыми датаклассами"? Вы это либо где то инкапсулируете, делая аналогично спецификациям совместно с orm, либо задача решается вытягиванием половины БД в оперативную память, чтобы в ней сделать то, для чего sql и базы данных в общем то и предназначались.

        А что касается include, то в языке давно есть linq, который позволяет повесить этот include ровно там, где надо. И тут опять, же, ничего специально отслеживать не надо, если приложение правильно спроектировано.

        Вообще, по большому счету, проблемы не в каких то конкретных подходах, а в том, что их используют либо не там, где надо, либо не так, как стоило бы.


        1. maslyaev Автор
          02.06.2024 13:28

          Не понимаю, в чём проблема с заказами. Можно сделать разными способами. Например, так:

          select id, username
          from users
          where id in (
            select distinct user_id
            from orders
            where amount_rub > 1000
          )
          

          Или через join, или через exists.


      1. idd451289
        02.06.2024 13:28

        Ну вообще средствами одной либы можно явно прибить автозагрузку путем пометки поля модификатором virtual. А если прям вот никак не хочется тащить такую магию, можно сделать вычисляемое свойство где уже вручную написать все инклюды, и пользоваться им. Благо Шарп позволяет


    1. popov654
      02.06.2024 13:28

      Я не питонист, но в PHP есть Doctrine, который умеет исполнять DQL, в котором явно можно сделать eager loading через LEFT JOIN и lazy loading через JOIN. Имхо очень удобно. Кроме того, такой подход позволяет получить объекты model классов (или просто ассоциативные массивы полей требуемой структуры, что почти то же самое) на выходе, написав некое подобие SQL, то есть тут и гибкие манипуляции данными, и агрегатные запросы, и все плюшки.


      1. maslyaev Автор
        02.06.2024 13:28

        Слышал много "добрых" слов про сюрпризы, которые иногда преподносит доктринский Hydration.
        Очень простое правило вне зависимости от языка программирования, инфраструктуры и прочего: в Проде, особенно нагруженном, этих "объектов model классов" быть не должно. Халтура это. Времянка. Быстренько сляпать – кайф, но потом всё равно придётся всю дорогу тащить этот чемодан без ручки.


  1. googoosik
    02.06.2024 13:28

    Поздравляю, вы изобрели DAO/DTO


    1. maslyaev Автор
      02.06.2024 13:28

      Лучше переизобретать хорошие вещи, чем переизобретать плохие.
      Вообще, заворачивать результаты запросов в датаклассы – весьма очевидная идея. Весь вопрос, как это делать так, чтобы это было удобно и приятно. Вот для этого как раз и нужна библиотечка.


  1. KReal
    02.06.2024 13:28

    Про EF уже упомянули. А я уж тогда оставлю ссылку на https://github.com/DapperLib/Dapper


    1. maslyaev Автор
      02.06.2024 13:28
      +1

      Посмотрел. Судя по всему, в мире .NET очень полезная и популярная штука. Решает ту же задачу, которая для Питона решена в PEP 249. Тот самый подход, который в начале статьи я обозначил как "Воспользоваться «низкоуровневым» интерфейсом СУБД". В нём всё хорошо за исключением того, что по мере роста функциональности всё превращается в долбаный хаос – вкрапления SQL, разбросанные по всему коду приложения где попало. Основная мотивация NoORM – удобняшка, которая которая не только что-то там упрощает (хотя это тоже важно), но в первую очередь мягко и нежно мотивирует разработчика не плодить хаос.


      1. KReal
        02.06.2024 13:28

        Отличие даппера от PEP'а в том, что он ещё и мапит (очень эффективно) возвращамые результаты обратно в доменную модель, за что и любИм.


  1. Reposlav
    02.06.2024 13:28

    Прошу прощения, плохо знаком с питоном. Но чем это отличается от DataMapper и использования репозиториев?


    1. maslyaev Автор
      02.06.2024 13:28

      Если совсем коротко, то это отличается от DataMapper тем, что здесь ничего никуда не мэппится. DbUser не является никаким in-memory representation для строки таблицы базы данных. Это всего лишь завёрнутый в удобную типизированную обёртку конкретный SELECT. Будет какой-нибудь другой select - появится другой датакласс, например, DbAuthenticatedUser. Может показаться, что такой подход плодит много лишних сущностей, но нет, мы просто перестаём смешивать мух с котлетами, и в результате всем становится только лучше.