Часть 4. Implicit значения и преобразования. Тайпклассы. Чтение и запись JSON в play-json.

Страничка курса: https://maxcom.github.io/scala-course-2020/

План

  1. Imlicit значения
  2. Передача контекста и конфигурации;
  3. “pimp my library”.
  4. Тайпклассы
  5. Сериализация и type classes на примере Play-JSON
  6. Задание: разбор JSON из API vk.com.

"Неявные" значения и преобразования

  • Уникальная конструкция, существующая только в Scala.
  • Множество разных применений
  • В Scala 3 многие применения получили свой синтаксис

Неявные значения для передачи контекста

Дополнительный блок параметров функции


def getUser(id: Int)(implicit session: DatabaseSession): User
					

значения которого можно явно не задавать

Без implicit значения пришлось бы

  • Явно передавать значения
  • Передавать неявно через thread local и "магию" фреймворков

Значение должно существовать в области видимости


DB localTx { implicit session ⇒ 
  getUser(10)
}					
					

Где это применяется? Для передачи контекста

  • Сессия/транзакция БД
  • Конфигурация
  • Свойства пользователя (права, язык, таймзона и т.п.)
  • ...

implicit значения ищутся в

  • Объявлениях в текущем контексте
  • В import'ах - сначала в точных, потом в wildcard
  • В companion object связанных типов

Неоднозначности в поиске - ошибка компиляции

Неявные классы

Преобразование одного класса в другой


implicit class RichString(str: String)
					

автоматическое преобразование там где необходимо

"Pimp my library" - прием из динамических языков, например Ruby

В Ruby код может "улучшить" любой класс, даже стандартную библиотеку

Это называется "Monkey patch", и является опасной техникой

Опасности:

  • Нарушение инкапсуляции
  • Проблемы при обновлении - неявные зависимости на реализацию
  • Конфликты разных "улучшателей"
  • Сложности отладки

Implicit class - безопасная замена этой техники

Пример - добавляем метод в String


implicit class RichString(str: String) {
  def letters = str.count(_.isLetter)
}

"scala 2.12".letters // = 5
					

В отличие от monkey patching

  • Разрешается в процессе компиляции
  • Не влияет на существующий код

Более правильная версия примера:


implicit class RichString(val str: String) extends AnyVal {
  def letters = str.count(_.isLetter)
}
					

класс существует только в compile time

Технику применяют для "красоты", например:


import scala.concurrent.duration._

val timeout: Duration = 5 minutes
// замена Duration(5, TimeUnit.MINUTES)
					

еще пример - specs2

Тайпклассы

Typeclass - шаблон проектирования, построенный на использовании implicit преобразований.

Рассмотрим реализацию .sum для List[Int]:


def sum(list: List[Int]): Int = list.foldLeft(0)(_ + _)
					

Как её обобщить на наш собственный тип значений?


case class BusValue(amount: Int)
					

Нужно два элемента:

  • Нулевой элемент
  • Функция "сложения"

Проблема с ООП:

  • Нужно как-то из типа добраться до "нуля"
  • Нужен trait для метода сложения

//  упрощенная реализация на основе исходников Cats
def combineAll[A](fa: Seq[A])(implicit m: Monoid[A]): A =
  fa.foldLeft(m.empty) { (acc, a) =>
    m.combine(acc, a)
  }
					

implicit val busMonoid: Monoid[BusValue] =
  new Monoid[BusValue] {
    override val empty = BusValue(0)

    override def combine(x: BusValue, y: BusValue): BusValue =
      BusValue(x.amount + y.amount)
  }
					
Требования к моноиду:

 // ассоциативность
combine(x, combine(y, z)) = combine(combine(x, y), z)
					

combine(x, empty) = combine(empty, x) = x
					

Примеры:

  • Числа и операция сложения. empty = 0
  • Числа и операция умножения. empty = 1
  • Коллекции. empty = пустая коллекция

Примеры:


import cats.implicits._

// pimp my library
Vector(BusValue(1), BusValue(9000)).combineAll
					

Для Seq не работает!

NonEmptyList - не моноид, а Semigroup
Красивая операция "сложения":

BusValue(1) |+| BusValue(9000)
					
Более полезное применение:

val one = Map("first" -> BusValue(1), "second" -> BusValue(2))
val two = Map("second" -> BusValue(3), "third" -> BusValue(3))

one |+| two
// Map("first" -> BusValue(1), 
//   "second" -> BusValue(5), 
//   "third" -> BusValue(3))
					
Как это работает?

// неявное преобразование
implicit def monoidForMap[K, V]
              (implicit ev: Monoid[V]): Monoid[Map[K, V]] =
                 new MapMonoid[K, V]
					

Немного другой синтаксис:


// context bounds
implicit def monoidForMap[K, V: Semigroup]: Monoid[Map[K, V]] =
  new MapMonoid[K, V]
					

Тайплассы можно объявить для более интересных конструкций.

Функтор - абстракция контейнера с операцией map

источник: Functors and Applicatives


trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}
					

Требования к реализации функтора:


fa.map(f).map(g) = fa.map(v => g(f(v)))
					

fa.map(identity) = fa
					

После перерыва рассмотрим некоторое практическое применение.

Перерыв 5 минут

JSON

Текстовый формат обмена данными.

Пришел на замену XML, который оказался слишком сложным и слишком медленным.

Использует синтаксис объявления данных JavaScript


{
   "firstName": "Иван",
   "lastName": "Иванов",
   "address": {
       "streetAddress": "Московское ш., 101, кв.101",
       "city": "Ленинград",
       "postalCode": 101101
   },
   "phoneNumbers": [
       "812 123-1234",
       "916 123-4567"
   ]
}					
					

Формально описан в rfc8259

Весь стандарт - 16 страниц текста, включая оглавления, ссылки и т.п.

Json легко читается (если отформатировать)

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

Формат JSON был разработан
Дугласом Крокфордом.

Видео: The JSON Saga

Используем библиотеку Play JSON.

Часть play-framework; можно использовать отдельно.

Документация:

Подключаем библиотеку в проект


libraryDependencies += 
  "com.typesafe.play" %% "play-json" % "2.8.1"
					

JsValue - алгебраический тип

  • JsArray
  • JsBoolean (JsTrue/JsFalse)
  • JsNumber
  • JsObject
  • JsString
  • JsNull

Запись JSON


import play.api.libs.json._

val v: JsValue = JsObject(Seq(
  "id" -> JsNumber(1),
  "name" -> JsString("Vasya"),
  "marks" -> JsArray(Seq(JsNumber(2), JsNumber(3)))
))

Json.stringify(v) // компактный формат
Json.prettyPrint(v) // с форматированием			
					

Большие структуры так выводить не удобно.

Нужно разбить создание JSON на компоненты и комбинировать их.

Какие есть варианты?

Pattern matching?

Не походит - мало операций,
много вариаций данных.

Объектный полиморфизм? Возможно


trait Writable {
  def toJson: JsValue
}					
					

Однако:

  • trait не добавить к стандартным коллекциям
  • trait не добавить к сторонним классам
  • смешивается основная логика и доп. функции

Reflection? Да, это "Java-way".

В runtime запрашиваем структуру объекта средствами JVM и выводим её.

Однако:

  • Не типобезопасно: можно вывести что-то не то, например сессию БД или внутренний пароль
  • Медленно
  • Трудности с настройкой формата вывода
  • Нельзя использовать контекст при выводе

Решение - тайпкласс, как еще один вариант полиморфизма.


// упрощенный код
trait Writes[A] {
  def writes(o: A): JsValue
}
					

использование


def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = 
  tjs.writes(o)
					

Для примитивных типов готовая реализация


Json.toJson("some string")					
					

реализация


// из play-json
implicit object StringWrites extends Writes[String] {
  def writes(o: String) = JsString(o)
}
					

Для Seq нужно преобразование


implicit def traversableWrites[A](implicit w: Writes[A]): 
    Writes[Traversable[A]] = {

  Writes[Traversable[A]] { as =>
    val builder = mutable.ArrayBuilder.make[JsValue]()
    as.foreach { a =>
      builder += w.writes(a)
    }
    JsArray(builder.result())
  }
}

Json.toJson(Seq("some string", "second string"))
					

Выводим case-класс

import play.api.libs.json._

case class User(id: UUID, name: String, title: Option[String])

object User { // объявляем в companion object
  // OWrites вместо Writes
  // используем Writes.apply
  implicit val writes: OWrites[User] = OWrites { user ⇒
    Json.obj( // сам использует Writes для значений
      "id" -> user.id,
      "name" -> user.name,
      "title" -> user.title 
    )
  }
} 

Для простых случаев есть макрос


object User {
  implicit val writes: OWrites[User] = Json.writes[User]
}					
					

Writes - не всегда простое преобразование


// writes зависит от языка пользователя
implicit def writes(implicit lang: Lang): OWrites[User]
					

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


// один Writes для всего ADT
object Expr {
  implicit val writes: Writes[Expr] = Writes {
    case Number(value) ⇒ 
      JsNumber(value)
    case Plus(lhs, rhs) ⇒ 
      Json.obj("op" -> "plus", "left" -> lhs, "right" -> rhs)
    case Minus(lhs, rhs) ⇒ 
      Json.obj("op" -> "minus", "left" -> lhs, "right" -> rhs)
  }
}
					

Итого мы получили:

  • Специализированный код для каждого типа
  • Типобезопасность
  • Код записи JSON отделен от данных
  • Возможности по кастомизации, использование контекста

Чтение JSON и валидация

Тайпкласс Reads для чтения


trait Reads[A] {
  def reads(json: JsValue): JsResult[A]
}
					

JsResult - алгебраический тип

  • JsSuccess - успех
  • JsError - ошибка; внутри список ошибок с путями

val v: JsValue = Json.arr("str1", "str2")

v.validate[Seq[String]] 
// JsSuccess(List("str1", "str2"))

v.as[Seq[String]] // значение или Exception
// List("str1", "str2")
					

Case class: плохой вариант


case class User(id: UUID, name: String, title: Option[String])

object User {
  implicit val reads: Reads[User] = Reads[User] { json ⇒
    JsSuccess(User(
      id = (json \ "id").as[UUID],
      name = (json \ "name").as[String],
      title = (json \ "title").asOpt[String]
    ))
  }
}
					

Почему плохой?

  • Exception вместо JsError
  • Диагностика только по
    номерам строк из Exception

JsResult - монада


implicit val reads: Reads[User] = Reads[User] { json ⇒
  for {
    id <- (json \ "id").validate[UUID]
    name <- (json \ "name").validate[String]
    title <- (json \ "title").validateOpt[String]
  } yield {
    User(id, name, title)
  }
}
					

Исключений больше нет,
но диагностика еще хуже

Проблема: при операции поиска теряется путь

Вместо работы над значениями будем конструировать функцию


val v: JsValue = ???
val r: JsLookupResult = v \ "id" 
// результат поиска

val path: JsPath = __ \ "id"
// путь, не привязанный к значению
					

Строим Reads


// метод JsPath
def read[T](implicit r: Reads[T]): Reads[T]
					

Использование


val idReads: Reads[UUID] = (__ \ "id").read[UUID]
					

при ошибке путь будет добавлен в JsError

Reads - тоже монада


implicit val reads: Reads[User] = 
  for {
    id <- (__ \ "id").read[UUID]
    name <- (__ \ "name").read[String]
    title <- (__ \ "title").readNullable[String]
  } yield {
    User(id, name, title)
  }
					

Чего мы получили:

  • JsError, никаких исключений
  • В ошибке закодирован путь

Что не так?

Монада - абстракция цепочки вычислений.

У нас же все вычисления независимые,
кроме шага сборки.

Еще плохо - ошибка только первая;
надо выдать все ошибки

Монада тут - плохая абстракция.

На помощь приходит
аппликативный функтор

Это функтор с дополнительными операциями

  • pure - создание функтора
    
    def pure[A](a: A): F[A]
    					
  • ap - применяет функцию в функторе к значению в функторе
    
    def ap[A, B](ff: F[A => B])(fa: F[A]): F[B]
    					

Функция ap:

источник: Functors and Applicatives

Вместо ap можно определить product - комбинирует два функтора в функтор от пары


def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
					

Реализации функторов должны следовать нескольким законам.

Подробнее см. The underrated applicative functor и Applicative (Cats).

Reads - не только монада, но и аппликатив.

При сборке ошибки "складываются".

Синтаксис:


import play.api.libs.json._
import play.api.libs.functional.syntax._
					
val combined: Reads[(UUID, String, Option[String])] = (
  (__ \ "id").read[UUID] and
  (__ \ "name").read[String] and
  (__ \ "title").readNullable[String]).tupled

val userReads: Reads[User] = 
  combined.map((User.apply _).tupled)  
					

Более удобный синтаксис:


val userReads: Reads[User] = (
  (__ \ "id").read[UUID] and
  (__ \ "name").read[String] and
  (__ \ "title").readNullable[String])(User.apply _)
					

Итого что мы получили:

  • Удобный язык описания парсинга
  • JsError для ошибок, никаких исключений
  • Все ошибки с путями
  • Всё множество ошибок, а не только первая

Полезные инструменты:

Используем готовые Reads


Reads.of[Int].map(Number.apply)
					

Валидация значений:


import play.api.libs.json.{JsonValidationError ⇒ JsErr}

Reads.of[Int].filter(JsErr("must be positive"))(_ > 0)
					

Разбор алгебраического типа


import play.api.libs.json.{JsonValidationError ⇒ JsErr}

sealed trait Numbers
object One extends Numbers
object Two extends Numbers
object Three extends Numbers

val reads: Reads[Numbers] = 
  Reads.of[String].collect(JsErr("bad number")) {
    case "one"   ⇒ One
    case "two"   ⇒ Two
    case "three" ⇒ Three  
}
					

Домашнее задание

Создаем Reads для vk.com API

  • newsfeed.get - получение списка новостей
  • users.get - получение данных пользователя

newsfeed.get

  • post_id - идентификатор записи
  • source_id - автор (>0 пользователь, <0 - группа)
  • text - текст
  • likes / count - число лайков

документация на newsfeed.get

users.get

  • id - идентификатор пользователя
  • first_name - имя
  • last_name - фамилия
  • photo_100 - ссылка на фото пользователя

документация на users.get

Код добавляем в наш большой проект.

Тема семинара

Практика по использованию play-json.

Напоминаю: