Pull to refresh
8K+
23
Никита Волков@mojojojo

Архитектор, консультант

11
Rating
2
Subscribers
Send message

Я и спорить не стану. В терминологии наблюдается бардачок.

Возможно, правда, у нас тут ещё и недопонимание закралось. Я имел в виду:

  • App-first = Code-first

  • DB-First = SQL-first

Очень рад!

Но код миграций надо вывести из-под приложения.

Это я и предлагаю :)

Взглянуть на БД как на самостоятельный микросервис со своим репозиторием. В этом репозитории миграции и запросы и никакого кода приложений. Свой процесс ревью. Своё решение для тестирования (это возможно в SQL), инструменты мигрирования это поддерживают (как минимум, я это делал в Sqitch). Свои линтеры по SQL (они существуют). Свой CI, который прогоняет анализ с pGenie и упомянутые тесты и линтеры. Свой CD, который накатывает миграции в проде и выкладывает во всякие artifactory сгенерированные библиотеки под клиентские языки.

Помимо прочего это даёт манёвр в структурировании организации. За этот репозиторий могут отвечать люди, разбирающиеся в Postgres, тонкостях работы индексов и антипаттернах SQL. И им не нужно ничего знать про целевые языки. Точно так же это снимает необходимость с ваших джавистов, растовщиков и тд знать чего-либо про SQL.

А ещё это снимает основной аргумент “Shared DB is antipattern” и позволяет вам снова использовать общую БД из нескольких приложений и с разных стеков. Только безопасно.

Если интересно, я 4 года назад, когда шёл по стратегии SaaS, писал про многое из этого подробно: https://habr.com/ru/articles/808279/. Всё написанное актуально для pGenie.

Спасибо за обратную связь и раскрытие темы!

Кстати, в случае с pGenie подразумевается, что пользователь вообще не будет менять сгенерированный код. Только использовать, как библиотеку от третьей стороны, которую невозможно изменить. На моём опыте работы с генерацией кода, это самый простой и надёжный способ отгородиться от проблем с затиранием изменённого пользователем кода при перегенерации.

К сожалению, терминология *-first уже обросла маркетингом и стала контекстно-зависимой. Код действительно везде: и в приложении, и в БД. Подсвечиваемая мной дихотомия по существу сводится к тому, какая из этих сторон является источником истины (БД или приложение). Человек программирует там, где источник, а всё вторичное выводится автоматом. В случае с ORM и Query Builder источник либо не определён (ад в поддержке), либо это приложение (получше). Я же аргументирую за подход, когда источником истины является БД.

Наверное, корректнее было бы назвать дихотомию App-first / DB-first, но, к сожалению, в индустрии устоялось Code-first / SQL-first, а подразумевается одно и то же.

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

Если я вас верно понял, то с точки зрения точности решения, вы, конечно же, правы. Но тут есть и обратная сторона: усложнение UX для типичных кейсов. Передо мной стоит задача поиска баланса в таких ситуациях. Поэтому нужно понять, насколько критично то, чего мы лишаемся, объединяя задачи.

Учитывая, что это по существу касается только переключения nullable в non-null, а также того, что в очень многих случаях Postgres всегда подразумевает null и на уровне Postgres это не изменить (параметры почти всегда nullable), я предположил, что объединить будет удобнее. Собственно эта фича переопределения для борьбы с этими недостатками и была введена. Также ещё стоит учесть, что в некоторых языках программирования (Java) по существу все значения nullable.

Резюмируя, так как в дизайне этой фичи я опираюсь на субъективное предположение, я открыт к тому, чтобы пересмотреть механику в предлагаемую вами сторону, но для этого надо накопить пользовательский фидбек.

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

Что произойдет у пользователя, в случае расхождения со схемой, причем расхождения такого, которое можно поймать типобезопасностью/программно? Приложение упадет. Так или иначе. При старте или в процессе, выбор неоднозначный. Возможно текущая сессия не затронет измененный участок развесистой схемы. Тогда пусть работает?

Часто бывает, что сбоящий запрос исполняется в состояниях приложения, в которые оно заходит редко. Узнать об этом сбое, определённо, лучше превентивно, нежели, дождавшись, когда до этого состояния доберётся пользователь, ведь любой сбой несёт за собой дополнительные издержки.

Какой стратегии придерживаться, конечно, выбор тех, кто за приложение отвечает и в каких-то ситуациях, действительно, может быть важнее, чтобы приложение продолжало работать даже когда часть запросов стала несовместимой. Так или иначе, об этом, как минимум, стоит рапортнуть через алёрт, чтобы возникла возможность начать принимать меры.

А у разработчика… это не столько фича, сколько, кмк, смещение источника истины. Много-к-одному должно решаться иначе, концептуально, либо начнутся костыли натягивания одного на другое.

И еще, считаю один-к частным случаем много-к. Если научиться хорошо и удобно работать с “чужой” схемой, расширить дополнительными плюшками при “владении” своей намного проще.

Тут я не понял, о чём вы.

Благодаря вашему примеру я вскрыл баг в движке анализа, из-за которого имплицитные композитные типы для строк таблиц ((tracks.*)::tracks) не вытаскивались. Исправлю это в приоритете.

Исправлено в v0.4.1.

Спасибо за поднятую тему!

Вообще, на будущее была задумка реализовать проверки health-check. Startup probe проверял бы состояние на старте приложения и рапортовал бы тому же Kubernetes, что на приложение нельзя пускать трафик, когда его ожидания от схемы БД расходятся с реальностью. Ну и Liveness probe проверял бы то, что вы описали: продолжает ли схема в БД быть совместимой с точки зрения данного приложения.

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

Если миграции написаны на DDL, то это чистый SQL-first и это именно то, что я пропагандирую в данной статье и то, на что опирается pGenie.

Если не пользоваться миграциями, и ходить менять схему БД руками, то теряется фиксация структуры БД и источником истины о её структуре становится её актуальное состояние, что переменно и устанавливает инфраструктурную зависимость корректности вашей интеграции с данной БД на её состояние. Следствием с высокой вероятностью станет хрупкость интеграции и частые сбои независимо от того, на какой инструментарий вы будете опираться.

Большое спасибо за подробное обсуждение!

Хоть на некоторые вопросы у нас и разошлись взгляды, ваша обратная связь указала на множество аспектов для улучшения пользовательского опыта, и я планирую это постепенно внедрять.

Спасибо за совет! Но, кажется, имеет место недопонимание.

Из доков:

pGenie writes a signature file the first time a query is analysed. On subsequent runs it reads the existing file, re-resolves the signature from the database, and validates the two against each other. It never silently overwrites your signature files. To regenerate a signature file from scratch, delete it and re-run pgn generate.

Иными словами, если файл уже существует, pGenie его не перезаписывает, а использует как ориентир для сравнения. Если данные в нём не совместимы с наблюдениями по схеме, то выдаётся ошибка. Это и есть механизм защиты от дрейфа схемы. Чтобы файл перегенерировался пользователю его надо удалить.

Комментарии при замене, конечно, в нынешней реализации остаются на усмотрение пользователя, а не автоматизированы, как вы предлагаете.

Спасибо. Всё понятно. Один только вопрос остался. Можете пояснить, почему предпочтение sqlx над tokio-postgres?

За подробный фидбек я вам премного благодарен!

Представьте есть select * from tracks; и есть with… select (*)::tracks as row, 0 as result_code; Дальше по правилам именований на расте структура для строки трэка должна быть Track без s на конце + второй кверик должен вернуть что нибудь типа struct ComplexSelectItem { row: Track, result_code: i32}

Это вы отличный пример дали!

Вас устроит писать select (tracks.*)::tracks from tracks вместо select * from tracks чтобы получать переиспользуемый тип в результате запроса? Потому что в таком случае противоречие, как будто, пропадает, так как результат будет мапиться в один растовский тип, переиспользуемый во множестве запросов.

Переименование композитов можно реализовать в конфиге генератора. На первой итерации это может быть достаточно простой доработкой, изолированной только на генератор для Rust. Скорее всего, сам за реализацию этого я возьмусь не сразу, так как есть много приоритетных задач. Однако PR принимается, если кто-то захочет этим заняться.

Благодаря вашему примеру я вскрыл баг в движке анализа, из-за которого имплицитные композитные типы для строк таблиц ((tracks.*)::tracks) не вытаскивались. Исправлю это в приоритете.

Насколько я понимаю, если расширить pgn режимом работы с живой БД напрямую (без докера), то и вопросы к способу выведения схемы отпадут. Верно?

Дальше вопрос по композитам, продолжение вопроса переименований, но в общем виде. Допустим мой кверик возвращает строку целиком + еще один скалярный флаг/енум. У меня есть такие кверики и я бы хотел что бы часть результирующего композита, которая строка, получила тип в расте тот же что я задал для обычного селекта * для этой таблицы.

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

Вот пример объявления композита в миграции. А вот пример запроса, который засовывает часть колонок к этот композит и дополняет ещё одной скалярной колонкой. Похоже на то, что вы описали?

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

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

Я имел в виду усложнение системы pGenie, а не вашего проекта. Чтобы тут нормальное решение задизайнить надо проблему глубоко проработать, а дальше реализовывать это потребует изменений в логике, контракте кодогенератора, и изменения кодогенератора. Поэтому это дорого и ключевым вопросом становится: насколько оно оправдано. Пока что вы первый человек с таким запросом.

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

Вы тут смотрите на систему в моменте времени. Да, в моменте два запроса могут возвращать одинаковый набор колонок. Давайте теперь взглянём в динамике. Разные запросы развиваются независимо и с течением времени возникнет потребность в один из связанных запросов добавить колонку. И тогда вам придётся менять код запросов, которые к этому изменению никакого отношения не имеют, увеличивая размер задачи. Таким образом, с упрощением системы в моменте, закладывается усложнение задач поддержки в будущем. Иначе говоря, происходит накопление технического долга.

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

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

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

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

Я предлагаю своё видение оптимального сгенерированного кода и подкрепляю публикациями вроде обсуждаемой. Тем не менее, согласятся с этим видением не все, и это нормально. Для несогласных есть возможность форкнуть кодогенератор и реализовать своё видение. В эпоху LLM это просто. Для совсем не согласных есть альтернативные инструменты и подходы, и это тоже вполне нормально.

Моё предложение о пилоте всё ещё в силе.

Спасибо за развернутый фидбек! Очень ценно.

Классный термин вы ввели: “Reverse ORM”. Ёмко! Не встречал.

А можно ли подружить ваш пигини с sqlx?

Не проблема! Хотел бы только узнать, почему предпочитаете его tokio-postgres?

Спрашиваю потому, что изначально колебался для какой библиотеки писать первый генератор. В итоге выбрал tokio-postgres, потому что он более легковесный и сфокусирован на PostgreSQL. Но с другой стороны для него явно не хватает решения с одной кассой. С пулом, prepared statements, транзакциями и нормальной эргономикой. deadpool-postgres не дотягивает. Я подумывал под это дело расширить генератор, чтобы опционально с батарейками код выдавал или библиотеку расширительную выпустить, но тут надо принимать решение с пользователями. Отсюда и мои вопросы :)

Я уже понял что придется писать свой генератор, вопрос на сколько это сложно?

Несложно. Процесс устаканился. Уже есть 3 генератора как пример. Берёте входные данные как из того же демо-проекта. Руками пишете код, который хотели бы, чтобы генератор на эту модель выдавал. Передаёте это всё в LLM, а дальше напильником доводите до ума. Практика показывает, что нескольких итераций хватает для чего-то, чем можно начинать пользоваться.

Соответственно, если задача - расширить существующий генератор, то это ещё проще. Форкаете, командуете LLM изменить генератор так, чтобы он выдавал то, что вы хотите.

Полагаю, что получилось так просто благодаря тому, что для генераторов использовался Dhall, который строго типизирован и очень ограничен, что даёт LLM внятную структуру и резко сужает пространство для бреда. При этом язык достаточен для написания любого генератора.

Если хотите расширить сам инструмент, приглашаю в Github Discussions. Если понадобится помощь с приватным кодом, обращайтесь по линии консалтинга. Я в РФ.

Допустим у меня один сэлект возвращает всю строку целиком. По моим конвенциям названия таблиц множественное число, а в расте я хочу что бы названия типов были единственного числа, значит мне нужно переименовывать пгшные типы по правилам в общем случае и я бы хотел вручную задать имя типа в расте не по правилам когда надо. Генератор такое умеет?

Названия таблиц pGenie вообще не касаются. Из названий его заботят только три категории: название запроса, его параметров и колонок в его результатах. Это предопределяет структуру генерируемого кода.

Так, в примере генерируемого запроса из демо-проекта, на исходный запрос /queries/select_album_by_format.sql создаётся модуль statements::select_album_by_format, в котором его параметрам соответствует тип данных Input, а его результатам - Output, который является алиасом к вектору над типом OutputRow, который объявляется в том же модуле.

Такая структура кода не типична для кодогенераторов, однако она воплощает архитектурный принцип low-coupling/high-cohesion. Запросы не связаны между собой содержащим модулем (они объявляются в отдельных модулях), а содержимое каждого модуля запроса максимально сплочённо (все детали, относящиеся к одному запросу, находятся в одном месте). Помимо предсказуемости и удобства навигации это ещё ускоряет компиляцию, так как компилятор может компилировать модули параллельно, а изменения в одном запросе не влияют на другие. Когда запросов накопятся сотни, это будет иметь значение.

Тут вытекает вопрос: а что из этого вы хотели бы переименовывать?

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

Генераторы конфигурируемы. Генератор сам определяет контракт требуемой им конфигурации. Так что, как минимум, вы можете передавать общие правила или те же мапы. Вот пример конфигурации специфичной для генератора java.

Либо сигнатуры должны описывать генератор специфичные секции либо это должен быть еще один слой поверх сигнатур. Короче не похоже что без больших изменений это возможно - а надо.

Сигнатуры пока не содержат возможности передавать генераторо-специфичные настройки, но потенциально в будущем такая возможность может появиться. Нужен только востребованный кейс.

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

Это уж очень тонкая настройка пошла. Так ли она важна практически? Реализовывать дорого как в плане дизайна, так и усложнения системы, а практическая польза в чём?

Похоже, это ещё и вступает в противоречие с одним из инсайтов из обсуждаемой статьи:

Попытки переиспользовать “типы строк” между разными запросами только создают скрытые зависимости и закладывают будущие поломки из разряда “меняем один запрос, ломается другой”.

Сейчас правило простое: у каждого запроса свои типы. Чего предлагаемое усложнение нам даст с практической точки зрения?

Миграции - у нас сложные миграции не чистый скул, скул + код. Есть миграции на тайпскрипте и недавно начали писать на расте. Как подружить ваш пигини с такими миграциями? Думаю никак, тут у вас ошибка проектирования генератор не должен требовать каких либо миграций вообще. Это не его дело, ему нужна тупо конекция к аптудэйт базе, а как она мигрируется это не его забота. Малотого анализ работает не совсем одинаково на пустой и не пустой базе, но это слабый аргумент т.к. к прод базе конечно никто ради анализа не полезет, но все равно очень удобно иметь не пустую базу (у нас даунсайз прода в отдельном контейнере и мигрируется также как и прод) и дебажить кверики на ней. Да и генерация быстрее не надо мигрировать каждый раз чтобы сигнатуры посчитать.

Можно написать простой скрипт, который проиграет во временной БД ваши миграции, используя ваше текущее решение. Дальше сделать дамп получившейся схемы в миграцию /migrations/1.sql. Дальше запускается pGenie.

Для разработки это может стать билд-скриптом или инструкцией Makefile. В CI/CD это настроить тоже не проблема. Заоптимизировать тоже пространство есть: обновлять /migrations/1.sql только при изменении ваших текущих миграций.

Я это все спрашиваю не просто так, у нас есть похожий генератор, только проще. Ограничен пг -> раст. Мы используем sqlx, тоже есть много чего, анализ индексов телеметрия (у вас кстати нет), композиты, нулаблы, форс нот нулл в результатах, компайл тайм генерация. Короче разница с вашим проектом в простоте https://github.com/thepartly/automodel

Познакомлюсь с вашим проектом плотнее, спасибо за ссылку.

Соглашусь, телеметрию стоит, как минимум, опционально встроить в генератор. Ну это просто вопрос доработки генератора.

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

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

Скорее, не линтер, а тайпчекер. То есть, он не антипаттерны выявляет, а несоответствия того, как вы БД используете с тем, что у вас в схеме. В этом процессе оно неизбежно выводит типы, и как следствие имеет всё, что нужно для кодогенерации, а потому и генерирует код. Ну а дальше включаются дополнительные фичи вроде файлов сигнатур, которые фиксируют типы ваших запросов в коде и добавляют новые свистелки вроде указания того, является ли запрос идемпотентным, что открывает возможность для авторетраев. Короче говоря, потенциала там много. Гляньте learn-pgenie-in-y-minutes для быстрой экскурсии.

Линтеры SQL есть существующие, поэтому в эту поляну пока лезть не буду.

Возможно, у нас возникло недопонимание. Попытаюсь распутать.

Я не понял, что вы имели в виду под “рефлектами” в контексте статьи.

Обычные запросы и более понятны (не надо гадать, во что они там трансформируются промежуточным слоем) и легче могут быть оптимизированы или вообще заменены.

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

В чем проблема использовать БД как фронт использует бэк, делая запросы к хранимкам (если уж там есть сложная логика)? В таком случае при миграциях приложение вообще трогать не нужно будет, если старый вид данных, конечно, можно как-то получить. Достаточно изменить хранимку в БД - т.е. при bзменении БД вы остаетесь в парадигме лишь этой БД.

Здесь противоречия нет. С помощью хранимок действительно можно создать API БД, скрыв сами таблицы от клиентов как чёрный ящик. Вы действительно получите возможность обеспечить обратную совместимость и избегать изменения в клиентских приложениях при миграциях. У хранимок есть свои проблемы, но это вполне рабочая схема и она применяется много где. Очень хороший доклад в этом году на эту тему был от @mgramin на PGConf.

Однако с хранимками всё так же остаётся открытым вопрос интеграции со стороны языков приложений. Вам всё ещё необходимо писать и поддерживать в приложении код, который вызывает эти хранимки, передаёт в них параметры и разбирает результаты.

Эту часть pGenie может взять на себя и сделать надёжной. С точки зрения pGenie SELECT call_my_stored_procedure($param1, $param2) - обычный запрос с параметрами и результатом, а потому он всё так же может сгенерировать для него обвязочный код и обеспечить проверки в CI/CD на то, что ваши миграции действительно оставляют ваши процедуры обратно-совместимыми с кодом приложений.

Если интересно, уже была близкая по смыслу ветка обсуждения.

Information

Rating
745-th
Location
Москва, Москва и Московская обл., Россия
Date of birth
Registered
Activity

Specialization

Бэкенд разработчик, Системный инженер
Ведущий
Haskell
Rust
Java
PostgreSQL