Современный JSON процессор

Хотели бы вы анализировать ndjson в терминале вот таким образом?

where { bool("muted") } max { long("size") } top 3

{"id":4880123,"size":245,"muted":true}
{"id":2392636,"size":107,"muted":true}
{"id":15843320,"size":59,"muted":true}

В работе мы нередко используем выгрузки в формате ndjson. Однако их анализ затруднен из-за того что стандартным инструментом для этого является jq с его чудовищным синтаксисом порождающим такое:

cat file.ndjson | jq '.tickets | map(select(.assigned_displayname=="MyNameOnApi"))' | jq '.[] | "\(.id) \(.title) \t \(.description)\n"' | sed #some after jq prettifying 

Так как мне это не нравится, решил сделать современный json-процессор с удобочитаемым DSL. Что уже умеет этот инструмент?

Прежде всего, в наличии есть пять sql-подобных управляющих конструкций для обработки:

where { /* условие фильтрации */ }
order { /* выражение для задания порядка */ }
min   { /* выражение для поиска минимальных значений */ }
max   { /* выражение для поиска максимальных значений  */ }
top   ( /* число, лимит для выборки */ )

Все они могут быть использованы как по одиночке, так и скомбинированы друг с другом в любом порядке и с любым количеством повторов. Внутри используется обычный Kotlin, поэтому в выражениях вы также ничем не ограничены. Управляющие конструкции скомбинированные вкупе с выражениями образуют запрос который немедленно вернет вам набор json'ок. Сами запросы также можно комбинировать друг с другом, тогда они будут обработаны за один проход и вы получите несколько выборок в результате.

Примеры запросов

Постарался подчеркнуть читаемость:

max { long("size") } top 3 where { bool("active") }

top(5) where { !bool("active") } min { int("some") }

top(5) min { time("first") to time("last") } // 5 минимальных промежутков

where { get("arr")int(0) > 5 }

where { !get("broken") } top 3 min { get(4)get("nested")bool("flag") }

Если вы присмотрелись к этому примеру внимательнее, то заметили что в самих выражениях тоже используется лаконичный синтаксис доступа к json полям. Это стало возможно благодаря определенным хелперам выражений, которые являются ничем иным как расширениями для jackson:

// уже знакомые по jackson методы, возвращают ноду:
get(name: String)
get(idx: Int)

// о назначении дополнительных расширений комментариев не требуется:
bool(name: String)
bool(idx: Int)
int(name: String)
int(idx: Int)
double(name: String)
double(idx: Int)
string(name: String)
string(idx: Int)
time(name: String)
time(idx: Int)

Где скачать?

Из зависимостей вам потребуется лишь Docker. Установка не требуется. Просто запустите контейтер с утилитой, указав вашу директорию и файл в ней. Например, для директории ~/Desktop и файла example.ndjson, запуск выглядит следующим образом:

docker run -v ~/Desktop:/opt -it demidko/analyze example.ndjson

Вот и все! Мы попали в шелл для json и можем вводить любые запросы на ваш вкус.

Пара слов о внутренней реализации

Глядя на этот синтаксис можно было подумать что внутри творится ад dsl-строения, но на самом деле все гораздо проще. Запросы являются валидным подмножеством Kotlin и используют определенные прямо в исходном коде Kotlin функции. Запускается эта радость через известный jsr223 на котором подробно останавливаться нет смысла.
А вот про реализацию самих запросов стоит рассказать, остановлюсь на примере из кода:

typealias Query = (MutableList<JsonNode>, JsonNode) -> Unit

class Action(val action: MutableList<JsonNode>.(JsonNode) -> Unit) :
  Query by { list, el ->
    list.action(el)
  }
  
infix fun Action.top(limit: Int) = Action {
  action(it)
  while (size > limit) {
    removeLast()
  }
}

fun order(comparator: Comparator<JsonNode>) = Action {
  add(it)
  sortWith(comparator)
}

infix fun Action.where(filter: JsonNode.() -> Boolean) = Action {
  if (it.filter()) {
    action(it)
  }
}

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

Исходный код лежит на GitHub

Всем удачного дня! Буду рад услышать критику и предложения.

@Reformat
01.07.2021 06:15 UTC
Первоисточник

Комментарии

@MentalBlood
01.07.2021 06:41 UTC
0

Вот тут


where { get("arr") int (0) > 5 }

не совсем понятно, что значит int (0) > 5. Нашел только


int(name: String)
int(idx: String)

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

@Reformat
01.07.2021 06:46 UTC
0
Этот код возвращает вложенный объект «arr»:
get("arr")

Так как это тоже нода, для нее применяются все те же хелперы, например, получили нулевой элемент-число объекта «arr» и проверили что он больше пяти:
get("arr").int(0) > 5

Однако Kotlin позволяет опционально опускать точку и получить ровно тоже самое:
get("arr")int(0) > 0

Насчет того что в документации idx должно быть числом, да, вы правы, уже исправил.
Формальная грамматика Kotlin доступна здесь: kotlinlang.org/docs/reference/grammar.html
@antirek
01.07.2021 06:45 UTC
+2
такое ощущение, что это более компактный sql ))
@dididididi
01.07.2021 07:26 UTC
+1
Такое ощущуение, что это очередной 1005000-ый sql))) Когда ж им надоест?
@masai
01.07.2021 06:53 UTC
+1

Просто для сравнения можете привести код, эквивалентный примеру с jq?

@MKMatriX
01.07.2021 07:56 UTC
+1
У них не получится, первое действие в jq было выбрать ключ tickets тут такого пока нет) Хотя почитал комменты, видимо аналог это действия get(«tickets»)
03.07.2021 21:13 UTC
0
ниже привел пример
@Reformat
03.07.2021 21:09 UTC
0
Легко! Вот так
cat file.ndjson | analyze 'where{text("assigned_displayname")=="Матвей"} select{int("id")+"\t" +text("title")+"\t"+text("description")}'

Или так
cat file.ndjson | analyze 'where{text("assigned_displayname")=="Матвей"} select{"${int("id")}\t${text("title")}\t${text("description")}"}'
04.07.2021 10:29 UTC
0
Все еще пропущен первый get tickets и есть мелкие ошибки) Но вижу) Правда не очень понятно зачем text(), но видимо это подсказка преобразователю. В целом не плохо, если нравится mysql. Я же mysql не люблю, поэтому предпочту более краткий синтаксис без слов select/where. Впрочем взять с гитхаба и сократить их в символы, например $_?, всегда можно, но вряд ли захочу.

И тут id это не инт, а текст. По формату json что-то в " это текст, даже если это целое-положительное. Поэтому лучше распознание такого куска добавить по умолчанию, т.е. все в кавычках это текст, а числа без них это числа с плавающей точкой. Всякие 0xf можно опустить XD тогда ваш код сократится до вменяемого

analyze 'get("tickets") where {"assigned_displayname"=="Матвей"} select {"${"id"} ${"title"}\t${"description"}"}'


Если еще убрать необходимость писать все ключи в кавычках, то начнет напоминать jsqry где выбрать ключ это просто написать его, отфильтровать это написать фильтр в [квадратных скобках], а преобразовать вывод или вашим языком select пишется в {фигурных скобках}.
04.07.2021 10:54 UTC
0
text(«field») отдаст нам объект String из jvm + будут достпны все его методы.
obj(«field») отдаст нам просто json-ноду jackson + будут доступны ее методы.
04.07.2021 11:02 UTC
0
Действительно, в таком примере первый select/.map выглядят избыточно. Однако только до тех пор пока мы выбираем лишь одну ноду из родительского json. Представьте что вам нужно взять не только tickets а еще и пару его соседей, к примеру объект security_info. С kq это так:

.map{get("tickets") to get("security_info")} 
// сделали пару из билета и секьюрных данных


Можно создать почти любой композитный объект из jvm.
04.07.2021 12:41 UTC
+1
Это прикольно, но с помощью fx (js) это тоже можно сделать, {...this.tickets, ...this.security_info}. Не сказать что это часто нужно и зачастую это свидетельство о том что структуру json придумывал человек редко пользующийся js, но это можно сделать.

А про text и obj, вы не очень поняли. В json по синтаксису сразу понятно где текст, где число, где массив, где хешмап. Поэтому лучше по умолчанию делать такие преобразования. А если как в моем примере id в json это строка, то можно уже преобразовывать явно. Т.е. видим «123» или «foo» это строка по умолчанию, так как взято в кавычки, но первое мы можем явно преобразовать к, например, int. Это я к тому, что в самом json всегда явно указаны типы, поэтому указывать их всякий раз при парсинге нет необходимости. Ну и да, cheatography.com/gaston/cheat-sheets/json в помощь, хотя там далеко не все что в современном json есть. Т.е. строки могут быть в одинарных кавычках, числа начинаться с 0x, 0o, 0b и т.д., ключи объектов далеко не всегда берут в кавычки и в крайних случаях может дойти до того, что все это объекты)
04.07.2021 13:44 UTC
0
Да, хорошая идея сделать неявную типизацию
@Daemon_Hell
01.07.2021 07:32 UTC
+4
Возможно я покажусь ретроградом — но зачем простую утилиту упаковывать в docker?
@Reformat
01.07.2021 07:36 UTC
0
Изначально делал ее для запуска в k8s поде. + Чтобы не грузить мануал установкой jdk.
01.07.2021 19:55 UTC
+2
может тогда взять GraalVM и завернуть в небольшой самодостаточный бинарник?
01.07.2021 19:56 UTC
0
Да, идея хорошая, надо попробовать
03.07.2021 21:03 UTC
0
Весь день проводил эксперименты с graalvm native-image. Результаты неутешительные, при сборке в нативный файл теряются некие зависимости и в результате при старте приложение ругается что не может обнаружить класс KotlinJsr223DefaultScriptEngineFactory, чего нет с self-executable jar. Так что пока слишком сырая технология.
@dagrotar
01.07.2021 07:36 UTC
+3
Советую взглянуть на такое решение.
github.com/borkdude/jet/blob/master/doc/query.md

Единственное «НО»- язык запросов clojure.
Как следствие этого мощные возможности по работе с данными, но необходимость минимально погрузится (к слову сказать погружение должно пройти быстро и легко).
Зато получаем ЛЕГКИЙ, ПРОСТОЙ и МОЩНЫЙ язык для запросов и трансформаций.

Простой пример
$ echo '{:a 1 :b 2 :c 3}' | jet --query '(select-keys [:a :b])'
{:a 1, :b 2}
$ echo '{:a {:b 1}}' | jet --query '[:a :b]'
1


Пример использования одного из JQ example (https://stedolan.github.io/jq/tutorial/)
$ curl -s 'https://api.github.com/repos/stedolan/jq/commits?per_page=5' | \
jet --from json --keywordize --to edn --pretty --query '
(map
   {:message [:commit :message]
    :name [:commit :committer :name]
    :parents [:parents (map :html_url)]})'
@MKMatriX
01.07.2021 07:36 UTC
+3
Окей так как в примере идет мой код, написанный на коленке за пять минут вставлю свои пять копеек.

1) Что там в примере.
Это не очень дописанный баш скрипт для работы с api ubugtrack.com цель достать тикеты на меня. Полная версия
bug () {
        local bug
        bug=$(curl https://ubugtrack.com/api/$UBUGTRACK_API/project/3758/tickets/\?filter_status\=1 | jq '.tickets | map(select(.assigned_displayname=="Матвей"))' | jq '.[] | "\(.id) \(.title) \t \(.description)\n"' | sed -e 's/"\(.*\)"/\1/' -e 's/\\t/\t/' | fzf --delimiter='\t' --preview='echo -e {2}' --with-nth=1 | sed 's/^\([0-9]\+\).*/\1/')
        if [ -n "$bug" ]
        then
                echo "You selected bug $bug"
        fi
}


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

2) В чем проблема.
В том что xkcd.ru/927 вы просто создали еще одну вещь со своими правилами. Для меня jq просто первое что пришло на ум когда в баше захотелось работать с json. Если бы действительно припекло я бы использовал ноду.

3) Что хочется.
Не очередной велосипед со своим синтаксисом, и не бойлерплейт код для парсинга json из ноды. Я хорошо знаю синтаксис js. Основной его минус при быстрой работе с json это то, что для обектов вида {5: {}, 20: {}} аналоги map достаточно многословны, например через Object.entries(json).map(([key, value]) => /* code */) ну или хотя бы Object.values. С этим приходится мириться, но это приятней чем изучить еще один синтаксис ради синтаксиса.

4) Что есть.
habr.com/ru/post/525808 Более приятный и близкий к js синтаксис, с явной примесью scala. Плюсом то, что прочитав примеры я понимаю что они делают, в отличии от написанного собой же кода на jq. Минус это все же немного другой синтаксис, хоть может он даже и лучше.
habr.com/ru/post/347808 То о чем я говорил, js сейчас знает куча программистов, и хоть он и подрос, он все же скриптовый язык, подходящий для написания коротких скриптов.

5) Я все же хочу написать свой велосипед.
Флаг в руки) Это полезно и развивает. Однако к моему примеру вряд ли ваш велосипед подойдет, ибо первое что я делаю это беру из пришедшего {tickets: [{...},...], ...} только tickets. И сходу не заметил у вас этого. Ну и во вторых я вряд ли буду использовать для скриптов строгую типизацию. Разве что если она действительно даст огромный выигрыш в затратах ресурсов. Хотя конечно для файлов размером с гигабайт есть vim, sed, grep, head, tail, и вроде больше в моем арсенале для них ничего. Сомневаюсь что ваш или приведенные мною парсеры на ура с ними справятся)

6) Бонусом как выглядел бы мой пример если бы я не поленился загуглить названия утилит, про существование которых знал
#Оригинал
jq '.tickets | map(select(.assigned_displayname=="Матвей"))' | jq '.[] | "\(.id) \(.title) \t \(.description)\n"'

#fx
fx 'this.tickets.filter(t => t.assigned_displayname == "Матвей").map(t => `${t.id} ${t.title} \t ${t.description}`)'
# для fx мне не потребовался дополнительный пайп, и до кучи я смог написать всю строчку без подглядывания в документацию

#jsqry
jsqry 'tickets [_.assigned_displayname == "Матвей"] {`${_.id} ${_.title} \t ${_.description}`}'
# тут пришлось чуть больше смотреть на примеры, но все же это самый краткий вариант


Еще один бонус fx в том что можно вернуть не только json, что может существенно сократить последующие преобразования. Для jsqry нашел лишь те способы преобразования что идут из коробки. А ну да jsqry поддерживает срезы в стиле Python [-2:] что тоже прикольно.
@antirek
02.07.2021 09:40 UTC
-1
для написания скриптов на js есть zx github.com/google/zx
02.07.2021 12:32 UTC
0
Крута, но все же первое что приходит на ум это баш) Да и его приходится знать чтобы читать чужие скрипты) Проблема json в том, что awk/grep с ним не очень хорошо работают.
@MKMatriX
02.07.2021 12:35 UTC
0
Наверное я оставлю эту ссылку здесь. coderoad.ru/1955505/%D0%A0%D0%B0%D0%B7%D0%B1%D0%BE%D1%80-JSON-%D1%81-%D0%BF%D0%BE%D0%BC%D0%BE%D1%89%D1%8C%D1%8E-%D0%B8%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BC%D0%B5%D0%BD%D1%82%D0%BE%D0%B2-Unix В ответах много чего, вплоть до того что например в powershell проблемы с json нет)
@Coocos
01.07.2021 08:02 UTC
0
Тот случай, когда статья на хабре больше кодовой базы проекта =)
@mytecor
01.07.2021 19:55 UTC
-1

Поздравляю, вы изобрели AQL

@kemsky
04.07.2021 21:43 UTC
+1

А что, если просто использовать powershell?

PS C:\Projects\Temp> Get-Content 'test.ndjson' | ConvertFrom-Json | where {$_.size -eq '245'}

4880123 245 True