Время прочтения: 6 мин.

В данном посте я показываю способ обработки json строк с помощью языка программирования scala, когда схема файла заранее неизвестна или может быть изменена.

ВВЕДЕНИЕ

При работе с базами данных довольно часто возникает ситуация, когда столбец содержит статический json файл. Json формат основан на коллекции пар «ключ-значение» и может хранить различные типы данных, такие как строки, числа, логические значения, объекты и массивы. Если структура прописана, то чтобы распаковать эти данные по столбцам достаточно воспользоваться встроенной в spark функцией — from_json() или обратиться через ‘название поля’.’название ключа’.

Но что делать, если json является динамическим? Динамические json отличаются от статических json тем, что их структура, вложенность и длина могут сильно различаться между разными json. Мне стало интересно разобраться, как можно облегчить нахождение определенных ключей в нескольких динамических json. В данном посте я хочу поделиться своим вариантом обработки json строк на языке программирования scala. Результатом работы является датафрейм или файл, в котором хранится путь к тегу, его ключ и значение для одного заданного json файла.

1 АЛГОРИТМ РЕШЕНИЯ ЗАДАЧИ

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

Рассмотрим основные компоненты дерева, которые я использовала при написании данного материала.

  • Узел (Node): объект или элемент, хранящий какие-либо данные и имеющий одного или несколько детей (подчиненных узлов). Каждый узел, кроме корневого, имеет родительский узел.
  • Корень (Root): верхний узел дерева, не имеющий родительского узла.
  • Лист (Leaf): узел, не имеющий детей (потомков) — самый нижний уровень иерархии.
  • Путь (Path): последовательность узлов, соединенных ребрами от корня к конечному узлу.

Разберем на примере задачу нахождения пути.

Рассмотрим json:

[{
 "id": 1,
 "first_name": "Jeanette",
 "last_name": "Penddreth",
 "email": "jpenddreth0@census.gov",
 "gender": "Female",
 "ip_address": "26.58.193.2"
}, {
 "id": 2,
 "first_name": "Giavani",
 "last_name": "Frediani",
 "email": "gfrediani1@senate.gov",
 "gender": "Male",
 "ip_address": "229.179.4.212"
}, {
 "id": 3,
 "first_name": "Noell",
 "last_name": "Bea",
 "email": "nbea2@imageshack.us",
 "gender": "Female",
 "ip_address": "180.66.162.255"
}, {
 "id": 4,
 "first_name": "Willard",
 "last_name": "Valek",
 "email": "wvalek3@vk.com",
 "gender": "Male",
 "ip_address": "67.76.188.26"
}]

Представим его в виде дерева:

Рисунок 1. Дерево, построенное на примере

Обойдем дерево в глубину.

Рисунок 2. Обход дерева в глубину

Получаем следующие пути:

Таблица 1. Ожидаемый датафрейм при обработке json

2 ОПИСАНИЕ ОСНОВНЫХ ФУНКЦИЙ СКРИПТА

Для запуска кода я использую spark-shell. Для этого необходимо поставить spark, например, воспользовавшись этой инструкцией.

Для определения пути к текущему узлу необходимо обрабатывать 3 случая:

  1. путем для корневого узла является сам ключ,
  2. путь для массива записывается в формате:

‘путь до родительского узла'[‘индекс в массиве’],

  1. путь в остальных случаях записывается в формате:

‘путь до родительского узла’/’ключ текущего узла’.

private def buildPath(path: String, key: String): String = {
 var newPath: String = null
 if (path.isEmpty) return key
 if (!key.isEmpty && key.charAt(0) == '[') newPath = path + key
 else newPath = path + "/" + key
 newPath
}

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

  1. Если перед нами массив, то вызываем метод parseArray.
private def parseArray(outArray: java.util.List[Tuple3[String, String, String]], arrayNode: ArrayNode, path: String, key: String): Unit = {
 val newPath = buildPath(path, key)
 // для пустого массива добавляем значение '[]'
 if (arrayNode.size == 0) {
   outArray.add(new Tuple3[String, String, String](newPath, key, "[]"))
   return
 }
 // обрабатываем все значения из массива
 for (i <- 0 until arrayNode.size) {
   addKeys(outArray, arrayNode.get(i), newPath, "[" + i + "]")
 }
}

2. Если перед нами json объект, то вызываем метод parseObject.

private def parseObject(outArray: java.util.List[Tuple3[String, String, String]], objectNode: ObjectNode, path: String, key: String): Unit = {
 val newPath = buildPath(path, key)
 // для пустого объекта добавляем значение '{}'
 if (objectNode.size == 0) {
     outArray.add(new Tuple3[String, String, String](newPath, key, "{}"))
     return
 }
 // обрабатываем дочерние узлы
 val iterator = objectNode.fields
 while (iterator.hasNext) {
   val entry = iterator.next
   val entryKey = entry.getKey
   addKeys(outArray, entry.getValue, newPath, entryKey)
 }
}

3. Для остальных случаев используем метод обработки значений (листьев дерева json)

private def parseValue(outArray: java.util.List[Tuple3[String, String, String]], valueNode: ValueNode, path: String, key: String): Unit = {
 val newPath = buildPath(path, key)
 // если с плавающей точкой число, складываем как double
 if (valueNode.isFloatingPointNumber) outArray.add(new Tuple3[String, String, String](newPath, key, String.valueOf(valueNode.asDouble)))
 // если булевое, то как булевое
 else if (valueNode.isBoolean) outArray.add(new Tuple3[String, String, String](newPath, key, String.valueOf(valueNode.asBoolean)))
 // если целое, то как целое
 else if (valueNode.isIntegralNumber) outArray.add(new Tuple3[String, String, String](newPath, key, String.valueOf(valueNode.asLong)))
 // в иных - как строку, заменяя пустые строки на null
 else {
   val text = valueNode.asText
   outArray.add(new Tuple3[String, String, String](newPath, key, if (text == null || text.trim.isEmpty) "null"
   else text.trim))
 }
}

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

3 ЗАПУСК СКРИПТА И РЕЗУЛЬТАТЫ

Запускаем в консоли spark-shell и вставляем наш скрипт.

Запустим json, который рассмотрели в примере выше:

Теперь запустим  json большего размера — 13 мб.

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