Я уже около года занимаюсь выведением в опенсорс общего кода, который используется у нас, в Gaijin Entertainment — порой, это бывает непросто. Как с точки зрения выбора — у нас около двухсот пакетов которые считаются обобщенными, так и со стороны облагораживания кода до состояния "и людям не стыдно показать". Но, Я сюда не жаловаться пришел, а делиться опытом?

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

Я не буду представляться и рассказывать про себя, этого в избытке есть на моих GitHub, Telegram и LinkedIn. Но мои тесные взаимоотношения с опенсорсом начались в далеком 2014м, тогда же когда Я начал и статьи писать, пока ещё только на хабр. Для статей причиной был принцип Фейнмана — "если хочешь понять сам — объясни это другим". Ну а с опенсорсом.. тщеславие, да.. куда уж без него?

Зачем это всё автору?

Нет, не мне — речь про того от чьего имени мы публикуем код и кем он впоследствии поддерживается. Пункт про опыт неприменим к компаниям, но остальное общее.

Здесь все сводится к четырем причинам:

  • Нужен опыт: вывести код в опенсорс — это не въевшийся нам "git push origin main". Это README который люди читают, CHANGELOG который люди ищут, семантическое версионирование которое люди ожидают, и ишью от людей которые используют твой код способами о которых ты и подумать не мог. Это в первую очередь попытка написать код для людей, а не для себя. А это далеко не то же самое что писать код по ТЗ. Здесь требуются, и впоследствии развиваются, навыки системного дизайна. Причем экстремальной его версии, когда пользователь, а значит уровень его экспертизы неизвестны.

  • Хочется популярности — ну да, банально. Но в резюме ссылка на живой проект даже с двумя сотнями звёзд стоит дороже чем "имею опыт работы с микросервисами". У меня, вот, есть проект с 2к звезд и почти миллионом загрузок ежемесячно — знаете как приятно писать это? ? И для людей и для компаний опенсорс, по факту, является способом продвижения и в этом нет абсолютно ничего плохого. Опенсорс, во многом, рекламная площадка получше этих ваших линкединов.

  • Экономия: да-да, именно экономия. Когда имеющийся в компании или в личном проекте код выносится в опенсорс — автор получает бесплатный аудит кода от людей которые его тоже будут использовать. Они заинтересованы в том чтобы код работал хорошо и быстро, и если находится баг — люди о нем сообщают, далеко не всегда, но всё же. Совсем идеальный случай — когда люди ещё и оформляют PR с исправлениями. В глобальном смысле это экономит время всем — пользователям на разработку, автору — на поддержку.

  • Альтруизм: предположим, что Я согласился что так бывает?. Шутка. Когда Я проталкивал затею про паблишинг части нашего кода, один из доводов которые Я приводил — "мы пользуемся тоннами опенсорсного кода — надо возвращать миру должок". Даже если в конце концов в мире будет один единственный пользователь — как минимум мой сон от этого будет крепче.

Но стоит понимать, что пункты два и три достижимы лишь в случае если код окажется востребован и популярен. Рассчитывать на это не приходится, популярными становятся, единицы — спросите у Ситника, рассчитывал ли он на популярность postcss в момент его создания? (сейчас postcss скачивают 104 миллиона раз в неделю)

Мы же начали публиковать golib, по сути, по всем причинам, кроме опыта — он у меня уже был, Я знал куда лезу. Даже если пресловутой "популярности" пакет не получит — мне пока ещё приятно создавать "идеальный" код, а компания, как минимум, получает рефакторинг и более подробную документацию.

Choose your fighter

Выбор того что конкретно выводить в опенсорс — простой и сложный одновременно.

Если ты новичок и в целом хочешь понять каков опенсорс на вкус — тут и думать нечего. Берёшь технологию которая нравится, открываешь GitHub Issues и выбираешь что приглянется. Главное — уведоми мейнтейнеров что ты занялся решением проблемы. А то, по моему опыту, нередки ситуации что за решение не очень сложных задач берутся сразу несколько человек и на выходе имеем несколько конкурирующих PR.
Очень неприятно потом сообщать добрым самаритянам, что выбрали не их решение.

Я же говорю о ситуации когда и опыт уже имеется, и вроде время свободное есть, и в "одном месте" свербит выложить что-то в опенсорс?.
Отталкиваться всегда стоит от решения собственных проблем. Едва ли существуют успешные решения которые начинались по принципу "сделаю чтобы было". Если решение не закрывает реальную потребность оно изначально обречено стать очередным заброшенным репозиторием с десятком коммитов и README.md состоящим из ToDo: write readme.
Может быть код этого проекта даже делает что-то полезное, но будучи ненужным даже автору — зачем он остальным? Как остальные его найдут?

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

В качестве примера посмотрите на ситуацию с JS "фреймворками" для рендеринга приложений. Они, в одно время, натурально задолбали каждую неделю выпускать очередного "убийцу" react (сейчас эстафету передозировки инфошумом перехватил ✨AI✨). Ну и посмотрите на состояние всех "убийц" большой тройки (react, vue, angular), вышедших за последние лет 5 — многие действительно классные. Да только они все откусили мизерную долю аудитории и так и остаются нишевыми.

Для таких ситуаций, по идее, правильной будет попытка как-то менять эти проекты — открывая ишью с обсуждениями, отправляя пулл-реквесты с предложениями, тем самым подталкивая к дальнейшему развитию.
Скорее всего, это займет сильно меньше времени чем создание полноценного конкурента. Да, можно столкнуться с нежеланием авторов что-либо менять, но это ожидаемый риск, который следует принять.
В конце концов, если не получилось доработать существующее — будет веская причина сказать "ну, Я сделал всё что смог" и таки пойти пилить своё решение.

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

Но продвижение — за скобками текущей дискуссии. В свое время Андрей Ситник достаточно подробно рассказывал свой опыт с postcss и autoprefixer поэтому этого вопроса Я касаться не буду. Продвижение опенсорса совершенно дикий зверь, не поддающийся никакому прогнозированию.

Так что же выбрал Я?

Наш слоняра

Название этой главы — небольшой каламбур, потому что в действо вовлечен PostgreSQL. А ещё это глава, в которой статья делает неожиданное сальто и приобретает глубоко-технический характер.

Пару недель назад Я релизнул очередное обновление golib и пришла пора выбирать нового кандидата для паблишинга. Как Я уже упомянул самый лучший вариант — решать, в первую очередь, свои проблемы. И во время очередного код-ревью такая "проблема" нашлась, избавив меня от мук выбора.

Несколько лет назад Я написал простенький билдер параметров SQL запросов, для ситуаций когда параметры, вернее их наличие, зависит от значений.
Это не билдер запросов, и уж тем более не ORM — Я люблю мои запросы сырыми. Имеющийся пакет — просто достаточно удобный интерфейс, позволяющий не мучиться с конкатенацией и использующий pgx в части именованных параметров.

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

"Ladies and gentlemen, we got him!" — кандидат на паблишинг найден. Поработать напильником там и сям, придумать как предоставить доступ к стейту, но не дать разработчикам завязать на это логику и можно выкатывать.

Но, по уже сложившейся для golib традиции Я не просто копирую код, а делаю полноценный рерайт пакета (слово рерайт вам ещё успеет приесться?). У меня есть понимание того что уже есть и понимание проблем которые нужно решить. И Я начинаю разрабатывать с нуля, очень редко подглядывая в существующий код.

А любая разработка, по-хорошему, должна начинаться не с написания кода, а с написания диз-дока c тезисным описанием состоящим из трех "где мы сейчас", "куда мы хотим прийти" и "как мы это сделаем". Прелесть такого подхода заключается в том что его можно применять вообще к чему угодно, хоть к разработке пакета на го, хоть к ground-up разработке SaaS системы.

Получилось вот такое:

# Issues  
  
- Positional args (`$1`, `$2`) require manual renumbering when adding/removing conditions  
- Args slice must stay in sync with placeholder indices — one mismatch breaks everything  
- `WHERE` and `AND` keywords must be conditionally inserted based on filter count  
- String concatenation mixes SQL with Go logic, prone to syntax errors  
- Same filter logic copy-pasted across queries, bugs fixed inconsistently  
- Hard to unit test query building without executing against DB  
  
# Goals  
  
- Named parameters (`@name`) auto-converted to positional — no manual index tracking  
- Clause groups auto-joined with `AND`  
- Non-empty condition groups expandable into query via placeholder substitution  
- SQL template stays readable and valid for syntax highlighting  
- Clause groups reusable across multiple queries  
- Query building logic testable without DB execution  
- Ability to assert placeholder values in tests  
- Values opaque to callers — prevents building fragile logic on internal representation  
  
# How to solve  
  
- `Builder` holds condition groups and named values  
- `ConditionGroup` collects conditions, joins with `AND` on build  
- `$group` placeholder expands to built group (empty if no conditions)  
- `"PREFIX"$group` inserts prefix only when group non-empty  
- `@param` converted to positional `$N` during rewrite using `pgx.NamedArgs`  
- Groups detachable from builder — helper functions return reusable groups  
- Expose condition strings and param names for test assertions via standalone functions

Написал Я всё это и минут на пять повис глядя на pgx.NamedArgs — что-то тут не так ? Особенно, учитывая что билдер лежит в пакете sql, но рерайтит запросы он, получается, только под pgx.
А у нас ведь не только, PostgreSQL используется, но и MySQL с SQLite в разных проектах, и там старая добрая конкатенация.
Непорядок! Можно, и нужно, сделать коллегам (да и себе) жизнь проще!
Поэтому в список фич добавилась возможность рерайтить запросы под любой движок, имея единый верхнеуровневый синтаксис.

И вот, мы пришли к иллюстрации классического изречения "попытка обновить один пакет привела к обновлению вообще всего". Теперь Я не только добавляю пару функций, но и пишу рерайтер SQL запросов?

To the drawing board!

Рерайтер

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

Задача, структурно, делится на два этапа — выделение лексических конструкций (плейсхолдеров) и замена плейсхолдеров на позиционные параметры. То есть можно явным образом выделить:

  • Лексер — разбивает исходный запрос на чередующиеся части исходного SQL и именованные плейсхолдеры.

  • Рерайтер — заменяет именованные плейсхолдеры на позиционные и склеивает запрос обратно.

С лексером, на первый взгляд, всё тривиально — берёшь регулярку @\w+ и разделяешь строку по вхождениям. Но кабы не так!

Во-первых, существуют комментарии:

  • /* @param */ — блочный, многострочный комментарий.

  • -- @param — однострочный коммент.

  • # @param — тоже однострочный коммент, но его нет в PostgreSQL. Все что находится внутри комментариев, понятное дело, обрабатывать не нужно.

Строковые литералы являются не меньшей "угрозой" чем комментарии, потому что 'email@name.com' — не содержит, по факту, в себе плейсхолдера. В отличие от комментариев, начало и конец которых легко определить из-за однозначности синтаксиса, строки определять очень "больно".
Причина — экранирование:

  • 'It''s nice' — экранирование через удвоение кавычки, работает везде.

  • 'It\'s nice' — бэкслеш, MySQL поймёт, Postgres — нет.

  • E'It\'s nice' — C-style escaping, только PostgreSQL.

  • $$here 'anything' goes$$ — dollar quoting, тоже только Postgres.

К сожалению, написать универсальный лексер из-за разнообразия не выйдет — придётся разделять на стандартный SQL и PostgreSQL форматы. Вернее, написать-то можно, но не имеет смысла обрабатывать лексические конструкции из SQL когда мы рерайтим запрос для PostgreSQL. И наоборот, соответственно, тоже.

А ещё, не выйдет описать всё это регулярками. В go неполный синтаксис RegExp — отсутствует lookbehind, а без него невозможно обработать экранирование надежно. Да и даже если бы lookbehind был — мне не по себе становится только от мысли о монструозности регулярки которая должна получиться.

Поэтому лексер будет полноценной стейт-машиной, с по-символьным сканированием. Во всяком случае Я так это вижу на текущий момент.

Что же касается рерайтера — там всё хоть и проще, но тоже придётся разделять имплементации. Синтаксис плейсхолдеров разный для PostgreSQL и SQL: в первом случае используются нумерованные плейсхолдеры ($250), а для простого SQL — просто позиционные (?).

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

А ещё, удобно (для пользователя) будет дать возможность настраивать символ, который будет использоваться для начала именованного плейсхолдера, для pgx это @, для драйвера SQLite это :. В качестве дефолта Я склоняюсь к :, потому что именно этот плейсхолдер поддерживается DataGrip и он подсвечивается соответствующим образом.

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

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

С рерайтером разобрались, а что по поводу групп?

Clause group

Так как адекватно перевести на русский эту фразу не выйдет — далее Я буду называть clause group просто группой.

Прародитель групп у меня появился в далеком 2017 году (ну или где-то там) — это был просто хелпер, который склеивал воедино условия через оператор AND. Этого хватало почти на все случаи жизни, кроме ситуаций, когда операция склейки приводила к созданию пустой строки.
Так как в те времена Я писал на PHP и JS — на помощь приходили тернарные выражения. Не эталон красоты, но, что называется, "достаточно хорошо" чтобы не доставлять проблем.

С переходом на GO, проблема встала ребром — тернарок нет и склеивание финального запроса превращалось в полотно if-ов. Порой, в сложных запросах, доходило до невозможности понять где и что происходит, потому что начало запроса оказывалось за пределами экрана. И родилась идея написать пакет который позволил бы давать группам имена и иметь единственную строку, без конкатенаций, с возможностью переиспользовать группы если это необходимо.

Проблем здесь три:

  • Необходимо придумать такой синтаксис, чтобы группу можно было использовать с разными операторами, то есть должно быть можно вставить группу и с оператором WHERE и дополнить статические условия "приклеив" их через указанный пользователем оператор. Сложности добавляет то, что этот оператор должен исчезать в случае если группа пустая. А ещё этого оператора может не быть вовсе.

  • Идентификатор группы в запросе должен быть однозначно отличим от плейсхолдеров.

  • Первые два пункта, в идеале, должны быть синтаксически совместимы с SQL, чтобы DataGrip не гундел подчеркивая наш кастомный синтаксис.

То есть надо подобрать синтаксис который одновременно совместим с рерайтером, который мы описали ранее, не будет частью синтаксисов в которые мы целимся (SQL и PostgreSQL) и будет в целом совместим с хайлайтерами синтаксиса и в частности с DataGrip. А ещё должно быть можно опционально указать оператор.

/thinking-foot-meme.jpg

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

Например, при нашем дефолтном символе : мы можем обязать имя плейсхолдера начинаться с буквы, а для групп — будем использовать комбинацию :# ну а дальше всё те же правила. Оператор, в таком случае пишется слитно, перед маркером группы, при этом можем обязать оператор быть капсом, для уменьшения шанса на ошибки (да будет холивар про операторы SQL написанные капсом?).

Таким образом получится:

  • WHERE:#filters — преобразуется в WHERE <filters-content>, если группа непустая.

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

  • :#filters — просто в контент фильтров, если группа непустая.

  • пустая группа без оператора так же превращается в ничто.

  • если группы с таким именем не существует — просто не заменять. Или паниковать, но это можно сделать конфигурируемым поведением билдера.

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

Имплементацию можно сделать такую, что каждая группа будет иметь изолированный набор именованных значений, но это уменьшает переиспользование для PostgreSQL. Это, по идее, "безопаснее" с точки зрения поведения — не нужно думать про вероятность пересечения имен значений двух разных групп в пределах одного билдера. А ещё можно будет создавать группы в отрыве от билдера.

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

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

Кажется, всё что можно было учесть заранее учли — можно начинать кодить.


А тут у нас "рекламная пауза" — литературная часть статьи заканчилась и начинается код. С кодом мы уже будем разбираться во второй части.

И, конечно же, подписывайтесь на телегу — Я туда, бывает, что-то умное пишу?

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