Привет, я Максим, в Selectel я занимаюсь фронтенд-разработкой «Облачной платформы». В этом проекте уже не один год. Вместе с развитием функциональности облака усложняется код, который я пишу. Поиск эффективных решений рефакторинга кода — одна из задач, которую я постоянно держу в голове. Поэтому метапрограммирование рассматриваю как одну из возможностей, доступных для улучшения взаимодействия с кодом.
Иногда проверка различных идей носит чисто экспериментальный характер. Так, на волне нового релиза TypeScript я решил попробовать типизировать DSL запросов к MongoDB (синтаксис запросов довольно прост, но при этом функционален и широко известен).
Под катом — результат. Я бы написал «не пытайтесь повторить это в своем рабочем проекте», но вдруг какие-то мысли и способы применения TypeScript окажутся полезными и вам.
TypeScript 4.1
В версии TypeScript 4.1, вышедшей в ноябре 2020 года, добавили поддержку шаблонов литералов. В мире JavaScript есть множество применений этой возможности TS, но в основном шаблоны литералов будут полезны в ситуациях, которые могут возникнуть при принятии различных соглашений. Например, когда примеси модифицируют имена свойств объекта стандартным образом.
Если вы еще не знакомы с новыми возможностями, рекомендую взглянуть на описание к релизу TS 4.1. Помимо поддержки шаблонов литералов, в новой версии сняли многие ограничения на рекурсию в условных типах. Это может быть удобно при сглаживании массивов или выводе результата из цепочки обещаний (promise chain).
Идея, которая у меня возникла, может быть выражена вопросом: если у нас есть шаблоны литералов, которые позволяют накапливать информацию, а также условия и рекурсия, которые позволяют эту информацию сглаживать, можем ли мы покрыть типами какой-нибудь простенький DSL с собственной нотацией?
В этой статье я представлю некоторые идеи, применяя которые можно покрыть типами язык запросов к MongoDB. Если точнее, я покажу, как добавить произвольный контекст свойствам, объявленным в точечной нотации.
Замечу, что разработчики языка в описании релиза предупреждают, что вычисление рекурсивных типов может потребовать значительных вычислительных ресурсов и к подобным приемам стоит подходить очень аккуратно. Поэтому не могу сказать, что представленные ниже приемы подходят для применения в боевых условиях, но в качестве теста ряда идей вполне подойдут.
Перед тем как погрузиться, очень желательно иметь представления о способах модификации типов (есть перевод).
Основы
Литеральным типом может быть представлена строка с известным значением. В TS 4.1 добавили средства для их конкатенации. Выглядит это примерно так:
Также добавили еще несколько методов для изменения регистра букв, чтобы можно было делать преобразования типа 'value' → 'getValue'. Но гораздо интереснее то, что вывод типов в условных выражениях работает и с шаблонами литералов. К примеру, если мы хотим получить из строки кортеж, выглядеть это будет так:
С помощью ключевого слова infer, размещенного в условии, мы сообщаем компилятору о необходимости вывести тип в ходе внутреннего цикла вычислений. Если компилятору удалось это сделать, то условие будет считаться выполненным и типы станут доступны для дальнейшего использования.
Тип never заставляет компилятор отбросить дерево решений, в котором строка не совпала с заданным шаблоном. Чтобы разобрать строку произвольной длины, необходимо воспользоваться рекурсией. Важно учитывать, что существует ограничение на вложенность рекурсии. При этом ограничение работает не идеально, так как мне удавалось несколько раз зациклить языковой сервер TS. К тому же эти вычисления будут чувствительны к входящим данным, поэтому важно тщательно исследовать приемлемость подобных конструкций, особенно для библиотечного кода. Но сами вычисления описываются тривиальным образом:
В этих результатах мы видим, как именно TypeScript обходит дерево решений. Остается только научиться сглаживать решение, и мы получим мощный инструмент для вычислений.
В TypeScript 4.0 для сглаживания кортежей добавили spread-оператор, удобнее всего его будет применить одновременно с разбором строки. Для этого достаточно немного модифицировать блок формирования результата:
Возможно, здесь меня кто-нибудь поправит, но, обладая этим набором возможностей, нам становятся доступны любые ацикличные вычисления, и это хорошо подходит для разбора контекстно зависимых грамматик. При этом кортежи — это не единственная комбинаторная возможность TS, поэтому парсеры времени компиляции выглядят потенциально полезными и для решения практических задач.
Валидация объектов, описанных в точечной нотации
В очередной раз проанализировав проблемы с данными в текущем проекте (информации много: около 100 моделей, число переходов между которыми растет экспоненциально), вспомнил про язык запросов MongoDB. Он выглядит простым как для описания запросов, так и для формирования функции запроса к произвольному хранилищу. Из существующих клиентских решений нашел только minimongo, и оно явно не удовлетворяет требованиям настоящего. В итоге решил немного поэкспериментировать с типизацией DSL от MongoDB.
Запросы к MongoDB отправляются в JSON-формате, но главным препятствием для статической проверки типами становится точечная нотация (dot notation). Например, у нас есть коллекция с такими моделями:
Мы можем запросить выборку этих документов, проверяя критерии выбора в нескольких уровнях вложенности. Например, так:
В данном случае база данных вернет ошибку, потому что оператор $text можно применять только к строкам. В идеале хотелось бы иметь средство, которое предупредит эту ошибку еще до нажатия кнопки «Сохранить» в редакторе.
Для всех, кто работал с TS, должна быть достаточно ясна проблема описания типа объектов подобной конфигурации. Некоторая функциональная обвязка, позволяющая через pipe/chain формировать итоговый запрос, в этом случае выглядит несколько более предпочтительной. Кроме того, с ее помощью будет гораздо проще выставить ограничения типов. Однако сохранение декларативности языка запросов может тоже иметь свои плюсы — например, это практически свободный доступ к уже существующей документации, с разбором множества ситуаций. В любом случае, я хочу больше сосредоточиться на самой задаче метаописания некоторого языка средствами TypeScript, без учета целесообразности прочих вариантов.
Язык запросов MongoDB в самом общем случае представляет из себя смешивание (merge) дерева хранимого документа и операторов доступа к узлам этого дерева. Также допускается точечная нотация для сквозного доступа через несколько уровней вложенности.
Операторы всегда начинаются с символа $, и каждый оператор может иметь свою структуру и типы принимаемых параметров. Есть классические логические операторы $not, $and, $or, операторы сравнений $eq, $gt, $lt, оператор поиска по тексту $text и еще огромное множество других — с агрегациями, проекциями, вычислениями и всем чем угодно. Поэтому, конечно, я не буду здесь рассматривать полное подмножество языка, а только самую базовую его часть.
Начать описание типов для некоторого подмножества языка можно с того, что заранее известно. Определим пространство операторов.
Из этого описания можно сделать вывод, что все, что нам необходимо, — это подложить к операторам контекст, который мог бы автоматически вычисляться и менять свое состояние в зависимости от ввода. Так как документы могут иметь древовидную структуру, то как минимум для формирования контекста нам потребуется метод рекурсивного обхода дерева по заданному пути.
В общем смысле путь обхода формируется через конкатенацию ребер дерева. Для нас в роли ребер выступают имена свойств объектов, а конкатенация — это последовательная запись имен, разделенных точкой (root.node.leaf). В моем случае еще используется специальный управляющий символ $, указывающий на переход к элементу массива.
В оригинальных запросах подобный символ используется только в проекциях. При поиске этот переход обычно просто подразумевается, без явного указания. Но вот убрать указатель большого труда не составит, а особые условия для обхода массива придется определить в любом случае.
Задача с обходом дерева по заданному пути выглядит достаточно очевидной, поэтому с нее и начнем. Кстати, нашел на Хабре хороший материал по этой теме, с разбором множества неочевидных моментов.
В примере выше, по сути, происходит то же самое, что и при формировании кортежа из строки, с тем лишь отличием, что на каждом шаге отбрасывается родительский контекст за его ненадобностью. В остальном происходит практически линейное движение по строго заданной последовательности. Таким образом мы сможем выяснить контекст запроса как для параметров первого уровня, так и для сквозных параметров.
Теперь давайте определимся с интерфейсом создания запроса:
Здесь проблема в том, что контекст у нас определяется искомым документом — именно для него и формируется запрос. При этом сам запрос может вообще не повторять структуру искомого документа. Единственное, из чего я исходил, — то, что тип итогового аргумента всегда должен вычисляться статически и он должен соответствовать любым ограничениям, которые порождаются искомым контекстом.
В данном случае я не нашел оптимального способа проверки этих ограничений. То есть, наверное, можно сначала описать тип документа, потом тип запроса, а затем проверить их соответствие друг другу. Но в этом мало смысла: предполагается, что документов будет намного меньше относительно запросов, а задача покрытия типами как раз и заключается в том, чтобы получить интерфейс валидации запроса, а не предопределенного типа запроса.
Была гипотеза, что нужного поведения можно попробовать добиться через ThisType<⁄T> или другие вложенные контексты. Но в этом случае я все равно не вижу, каким образом можно будет поднять ошибки валидации к месту объявления запроса. Пока я вижу только то, что валидация должна порождаться контекстом, и это принципиальное ограничение. Более внятного обоснования у меня нет, так что было бы интересно послушать вас. Может, у вас, читатели Хабра, есть какие-то теоретические обоснования или опровержения этого?
Так или иначе, путь от контекста к запросу выглядит более прозрачным. Хотя не могу сказать, что это то, что хочется делать на практике, поскольку потребуется определение всех возможных переходов внутри контекста. Для этого необходимо рекурсивно распространить функцию обхода с аккумуляцией всех совершенных переходов. В результате получится список путей, для которых определение контекста уже не будет проблемой.
По своей сути этот обход является сглаживанием, аналогичным тому, что выполняет spread-оператор, о котором я писал в начале статьи. Еще проще это можно назвать flatMap.
Применяя к каждому из полученных путей некоторый контекст, проходящий через модификатор QueryContextTraverse, мы можем организовать валидацию входящих данных, определяемую входящим контекстом. Еще это можно объяснить так: входящие данные в таком случае всегда будут являться подмножеством всех возможных запросов для заданного контекста. Выше уже был определен метод find, который принимает в аргументы тип QueryContextualFilter.
Собственно, осталось только описать эту связь между запросом и контекстом:
Теперь валидация запроса не должна стать проблемой.
Возвращаемся к примеру с моделью. В итоге мы должны получить нечто вроде этого:
Или вот этого:
Посмотреть полный код и то, как он работает, можно здесь.
Плюсы и минусы подобных решений
Начну с минусов:
- Разобраться в этих вычислениях непросто. В них все логично, но для быстрого погружения, нужен специфический опыт.
- Ввиду сложности вычислений время компиляции может сильно зависеть от входных данных. Лично мне не до конца ясно, каким образом компилирует TypeScript и как под него писать оптимизированный генерирующий код. Так что вполне вероятно, что по мере добавления новых контекстных данных время компиляции будет заметно увеличиваться.
А теперь к плюсам:
- В первую очередь это, конечно, внешние интерфейсы. Проверка задается прям в сигнатуре функции, и это очень удобно при росте количества запросов. Добавим к этому рефакторинг и все прочее, что нам обычно дают типы.
- Особенности определения операторов обращения к данным. Такая модель может достаточно долго расширяться без потери наглядности. Плюс TS позволяет комбинировать подобные интерфейсы самыми разнообразными способами. Поэтому пока мне кажется, что внедрение типов для тех же агрегаций не должно стать существенной проблемой.
- Методы обхода деревьев получились во многом универсальными. Также удалось выяснить, что нет существенных ограничений при движении по дереву в любом из направлений. А значит, можно будет работать сразу с несколькими контекстами — например при объявлении переменных в теле запроса или при связывании нескольких коллекций. Здесь, конечно, необходимы еще различные проверки этих возможностей, но, в любом случае, кажется мне разрешимым.
В завершение могу отметить, что не обладаю каким-то систематизированным опытом функционального программирования. Поэтому буду благодарен, если кто-нибудь укажет на подводные камни и на то, с чем следует ознакомиться, приступая к решению подобных задач.
Комментарии (8)
DarthVictor
16.07.2021 12:39+3Не совсем по теме шаблонов литералов, но для тренировки написания типов могу посоветовать этот репозиторий https://github.com/type-challenges/type-challenges
RokeAlvo
29.07.2021 17:57Разобраться в этих вычислениях непросто.
я тупой...
второй день уже смотрю на это
у меня задача выглядит так: есть json, хочется иметь тип который содержит все пути к примитивам:
const data = { a1: 'it a1', b1: { a2: { a3: true }, b2: 44 } } type TData = "a1" | "b1.a2.a3" | "b1.b2"
пока решил с помощью генерации (js скрипт, который собирает пути и сохраняет их в файл .ts) - но хотелось бы с помощью вывода типов это сделать. Может кто подскажет?
nin-jin
Cтоит глянуть этот воркшоп. Там разбираются многие техники для программирования на типах.
А в этом выступлении вкратце рассказывается, как поднимать ошибки к месту их возникновения.