Данный пост входит в серию статей на тему работы с JSON в Scala и в нем будет рассмотрена работа с библиотекой Play Json (github, документация). Код основного примера и каркаса приложения можно посмотреть в репозитории на github.
Данная библиотека была выделена из веб-фреймворка Play, который входит в Typesafe Reactive Platform и для парсинга JSON строк использует java-библиотеку Jackson. Для того, что бы начать работать с библиотекой play json необходимо указать ее в качестве зависимости в вашем файле build.sbt:
1 2 3 4 5 |
|
И написать 2 строчки импорта:
1 2 |
|
Рассматривать возможности данной библиотеки я буду на основе примера описанного в заглавном посте серии, с которым необходимо ознакомится для полного понимания сути происходящего. Поэтому для начала, рассмотрим то, что предлагает нам библиотека Play Json, а затем как это использовать в рамках нашего примера.
Play Json: Основные методы
Для представления типов данных JSON в пакете play.api.libs.json
существуют следующие типы данных:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Как видно из листинга, все эти типы данных расширяют класс JsValue
, который является супертипом для всех других JSON типов. Именно этот тип данных кодирует обобщенное понятие JSON, поэтому им мы и конкретизируем абстрактный член типа в трейте JsonLibrary
.
Чтобы реализовать основную функциональность нашего трейта, посмотрим что нам предлагает содержащий статические методы объект Json
:
1 2 |
|
Итак, для того чтобы разобрать строку представляющую json, необходимо просто передать ее в качестве параметра методу parse
, обратно мы получим значение типа JsValue
.
1 2 |
|
Для того, чтобы конвертировать JsValue
обратно в строковое представление, необходимо передать значение этого типа в метод stringify
.
С оставшимися двумя методами для сериализации/десериализации все немного сложнее:
1 2 |
|
Для того, что бы конвертировать значение JsValue
в класс нашей модели (десериализовать), мы передаем это значение в метод fromJson
и на выходе получаем значение типа JsResult[T]
, где T
это тип модели. Конвертировать значение типа JsValue
в другой тип можно так же воспользовавшись методом validate
трейта JsValue
, который так же возвращает значение типа JsResult[T]
:
1
|
|
Так что же представляет из себя тип JsResult
? JsResult[T]
это монадический тип, который может быть либо JsSuccess[T]
содержащий результат конвертации, в том случае если конвертация была удачной, либо JsError[T]
в обратном случае и содержать список всех ошибок встреченных при конвертировании. И так как это монадический тип, он содержит соответствующие методы (flatMap, map, …). Вкратце, в случае успешного конвертирования JsResult
будет содержать экземпляр класса нашей модели, в обратном случае набор ошибок с указанием на каком этапе конвертирования эти ошибки встретились. Мы еще рассмотрим JsResult
более подробно позже, пока же достаточно знать, что мы можем конвертировать значение это типа в значение типа Option[T]
при помощи метода asOpt
.
1 2 |
|
Для сериализации класса в JSON передаем экземпляр класса в качестве параметра методу toJson
и на выходе получаем значение JsValue
.
Вся загвоздка с методами сериализации/десериализации в том, что у них есть дополнительный неявный список параметров, в котором передаются экземпляры классов Reads[T]
и Writes[T]
соответственно. Именно эти классы знают как правильно конвертировать типы Scala в JsValue
и обратно. В Play json уже содержатся неявные (де)сериализаторы для основных типов данных Scala (DefaultWrites
и DefaultReads
). Но про то, как конвертировать экземпляры наших классов Play Json ничего не известно. Поэтому мы должны написать соответствующие неявные (де)сериализаторы самостоятельно и обеспечить их присутствие в области видимости там где они потребуются. Прежде чем это сделать, давайте посмотрим на код трейта PlayJson
реализующий интерфейс JsonLibrary
:
1 2 3 4 5 6 7 8 9 10 |
|
В этой реализации нет ничего особенного, мы просто используем методы описанные выше. Единственная неоговоренная строка - import PlayJson._
. Она производит импорт содержимого объекта-компаньена PlayJson
. В нем мы определим все необходимые неявные значения для конвертирования экземпляров наших кейс классов, данным импортом мы вводим их в область видимости, что бы они могли быть подхвачены методами нуждающимися в них. Что же содержит объект PlayJson
? На самом деле совсем немного строк кода. Но для того, что бы написать их придется изучить немного теории.
Play Json: Reads, Writes, Format и JsPath
Итак мы выяснили, что для того, чтобы конвертировать JsValue в другой тип Scala и обратно нам необходимо предоставить в область видимости соответствующие значения Reads[T]
и Writes[T]
где T
класс модели. Как мы увидим позже, эти конвертеры можно объединить. Но начнем рассматривать все по порядку, а для этого нам нужно познакомиться еще с одним парнем - JsPath
.
JsPath
JsPath это набор узлов которые надо обойти в структуре JsValue
, чтобы получить значение. Попросту говоря, JsPath
представляет собой путь до конкретного значения в json объекте, это практически тоже самое что и XPath
для XML. Давайте посмотрим на определение JsPath
:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Как видно из листинга JsPath
это список узлов пути List[PathNode]
и набор операций над этим списком. Методы \
и \\
имеют две версии. Это нужно для того, что бы можно было сформировать путь используя как строки (JsPath \ "key1"
), так и символы (JsPath \ 'key1
). Оба этих метода и первый метод apply
формируют новый путь добавляя узлы соответствующих типов (они все расширяют PathNode
) к исходному пути. Вторая версия apply
применяет значение типа JsValue
к сформированному пути, т.е. берем первый узел в пути и из переданного json-значения выбираем все дочерние ключи соответствующие этому узлу. Получается список значений JsValue
(отобранные ключи). Затем берется следующий узел и из полученного списка выбирается все дочерние ключи, соответствующие новому узлу и так далее. Когда узлы в пути закончатся, то итоговый список значений JsValue
как раз и будет результатом. Давайте посмотрим на примере и сформируем такой путь:
1 2 |
|
А теперь попробуем применить к этому пути различные json объекты:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Итак, для того что бы сформировать путь, нам надо начать с объекта JsPath
, который представляет корневой элемент пути и продолжить добавляя к нему соответствующие узлы, при помощи описанных выше методов. Для большего удобства и лучшего визуального выделения в объекте пакета json для объекта JsPath
определен алиас: __
так что путь из предыдущего примера можно переписать так:
1
|
|
Теперь, когда мы научились формировать путь, можно перейти к написанию непосредственно конвертеров. Reads
Конвертеры Reads[T]
используются для десериализации из JsValue
в какой-нибудь другой тип T
, например в класс модели Listing
. Reads[T]
можно комбинировать и вкладывать друг в друга, что бы получить более сложные конвертеры Reads[T]
. Например для того, что бы получить десериализатор кейс класса Counters
, нам необходимо объединить четыре десериализатора из JsValue
в Int. А для того, что бы написать десериализатор для класса Link нам придется вложить десериализатор класса Counters
. Давайте, напишем десериализатор для класса Counters
. Так как все поля этого класса имеют стандартный тип Scala, это будет довольно просто и библиотека Play Json предлагает несколько способов сделать задуманное. Во первых, мы можем воспользоваться JsPath
и его методами read
и readNullable
:
1 2 |
|
Данные методы принимают в качестве параметра неявный конвертер типа Reads[T]
и применяют его к значению извлеченному по указанному пути. Метод readNullable
полезен в случае если значение по указанному пути равно null
или не найдено, в таком случае он вернет None
. Например следующий код:
1 2 |
|
Применит к значению ключа before неявный конвертер в тип String, который предоставляется Play Json:
1 2 3 4 5 6 7 8 |
|
Итак, мы можем извлекать и конвертировать отдельные значения. Для того, что бы преобразовать эти отдельные значения в более сложный тип, например класс Counters
необходимо скомбинировать конвертеры при помощи операции and
или ~
(по сути and
просто вызывает ~
):
1 2 3 4 5 6 7 8 9 10 |
|
Как видно из листинга результатом такого комбинирования является экземпляр класса FunctionalBuilder[Reads]#CanBuild4[Int,Int,Int,Int]
, не будем останавливаться на нем подробно. Можно воспринимать этот результат как промежуточный при построении более сложных конвертеров Reads
. Главное знать, что таких типов FunctionalBuilder[Reads]#CanBuildX
существует вплоть до X=22, то есть мы можем скомбинировать до 22 конвертеров Reads
(известное ограничение в Scala) и если значению такого типа передать в его метод apply функцию формирования из отдельных значений экземпляр модели (метод apply
у кейс класса), то мы получим составной конвертер для нашей модели:
1 2 |
|
Более кратко все это можно записать следующим образом:
1 2 3 4 5 6 |
|
Понимая принцип создания сложных конвертеров из комбинации простых, последний листинг выглядит довольно простым. Однако библиотека Play Json предоставляет еще более простой способ. Создать десериализатор для вашей модели можно так же при помощи удобного метода reads объекта Json. Данный метод использует макросы введенные в Scala 2.10:
1 2 |
|
Вот и все. Одна строка. Но у этого метода есть ряд ограничений:
- нельзя перегружать метод apply
у кейс класса в объекте компаньене, потому что макрос не сможет выбрать между несколькими методами apply
- функции apply
(и unaply
в случае write
) должны иметь соответствующие входные/выходные типы. У кейс классов это предоставляется автоматически, а для трейтов необходимо будет написать соответствующие методы apply
и unaply
. Обратите внимание как обрабатывается модель Listing
, ниже
- Json макрос умеет обрабатывать следующие обобщенные типы Option
, Seq
, List
, Set
и Map[String, _]
. В случае остальных придется отказаться от использования макросов.
Наименование полей данных в модели должно соответствовать наименованиям полей в json объекте. Давайте теперь напишем десериализаторы для остальных моделей, начнем с модели для ссылок:
1 2 3 4 5 |
|
Для поля title
мы воспользовались неявным конвертером предоставляемым библиотекой. А для конвертирования поля url
, я воспользовался удобным методом map и преобразовал конвертер строки в конвертер класса URL
. Поля содержащиеся в классе Counters
, иерархически находятся на одном уровне с полями title
и url
, поэтому конвертеру мы указываем просто путь (__ \ "data")
, и так как ранее мы уже написали неявный конвертер для типа Counters
никаких преобразований больше не требуется. Хотя, отдельный неявный конвертер для Counters
можно было и не писать, а просто передать явно в качестве параметра:
1 2 3 |
|
Теперь настала очередь десериализатора для последней модели:
1 2 3 4 5 |
|
Здесь стоит отметить два момента. Во-первых, конвертер для поля children
. Так как значением этого поля является массив объектов, соответствующих модели Link
в нашем представлении, мы конвертируем его в коллекцию ссылок Seq[Link]
. Это работает так как в play json уже есть неявный сериализатор для коллекций. Во-вторых, это способ формирования экземпляра нашей модели. Из-за того, что модель Listing
имеет поле id
, которое отсутствует в json, но должно быть задано при создании экземпляра, мы не можем использовать функцию apply
модели. Вместо этого мы передаем лямбда выражение, формирующее функцию принимающую три параметра и возвращаю экземпляр класса Lisitng
. Более развернуто это можно записать следующим образом:
1
|
|
Итак, с десериализаторами мы закончили переходим к написанию сериализаторов.
Writes
С сериализаторами все будет немного проще. Дело в том, что реализация сериализатора практически не отличается от десериализатора. Начнем с модели Counters
. Когда мы писали для нее десериализатор, то в конце концов воспользовались методом reads[T]
объекта Json, основанным на макросах. Как вы наверно догадались, существует такой же метод и для сериализатора:
1 2 |
|
Не будем реализовывать его в качестве отдельного неявного значения, а просто передадим его в качестве параметра при реализации сериализатора для модели Link
:
1 2 3 4 5 |
|
Итак, перечислим основные отличия. Для поля url мы создаем новый конвертер Writes[URL]
и реализуем логику конвертирования в методе writes, так как к экземпляру класса Write[T]
метод map не применим. Для того, что бы сформировать из экземпляра нашей модели, несколько значений (те члены класса которые необходимо сериализовать), мы используем функцию unapply, но так как результат этой функции будет Option[(String, URL, Counters)]
вместо (String
, URL
, Counters
) мы трансформируем ее при помощи функции unlift
.
И в заключении сериализатор для модели Listing
:
1 2 3 4 5 |
|
Единственное отличие здесь это то, что вместо метода unapply
мы передаем лямбду возвращающую только три поля и игнорирующую поле id
, которое не требует сериализации.
И вот наконец-то мы разобрались с сериализаторами и десериализаторами и казалось бы на этом можно ставить точку. Однако Play Json предоставляет возможность объединить написание сериализатора и десериализатора в одном классе.
Format
Format[T]
это просто смесь из конвертеров Reads[T]
и Writes[T]
. Для того, что бы создать Format[T]
можно воспользоваться, например, методом на основе макросов:
1 2 |
|
Или можно воспользоваться уже имеющимися конверторами Reads и Writes:
1
|
|
Также можно написать конвертеры с нуля, воспользовавшись комбинаторами:
1 2 3 4 5 |
|
Для поля title
используется конвертер по умолчанию. Для поля url
мы преобразовываем стандартный конвертер Format[String]
в конвертер Format[URL]
воспользовавшись методом inmap
и передав в него функции для создания URL
из строки и обратно. И так как Format[Link]
объединяет в себе сериализатор и десериализатор, результатом объединения путей будет тип FunctionalBuilder[OFormat]#CanBuild3[String,URL,Counters]
, в функцию apply которого необходимо передать две функции для построения объекта модели из отдельных значений его полей и обратно. В создании Format[Listing]
тоже нет ничего неожиданного:
1 2 3 4 5 |
|
Итак, разобравшись с теорией, вернемся к объекту PlayJson нашего приложения. Вот его реализация:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Обратите внимание на порядок объявления неявных значений. Так как linkFormat
используется при создании listingFormat
, в таком виде он обязательно должен предшествовать созданию listingFormat
. Иначе, при выполнении тестов, вы получите не очень информативное исключение времени исполнения - java.lang.NullPointerException
Тесты
После того как мы разобрались как сериализовывать/десериализовывать данные при помощи библиотеки Play Json. Давайте реализуем и запустим тесты. Для этого просто создадим две спецификации, расширив соответствующие спецификации для тестов функциональности и тестов производительности:
1 2 3 |
|
и
1 2 3 |
|
Запустим и посмотрим на результаты: