white petaled flower near window during daytime

Фото Larisa Birta на Unsplash


Для разработки очередного Telegram-бота мне нужно было выбрать формат данных CallbackQuery — это запрос, отправляемый боту при нажатии на кнопку инлайн-клавиатуры.

Notification at the top

Дефолтный выбор, JSON, не подходит из-за ограничения в 64 байта, имена полей отнимают место у полезной нагрузки. Поэтому я остановился на CSV из-за его компактности и распространенности. Это текстовый формат, что удобно для отладки и диагностики.

Однако можно использовать и другие форматы, например base64, protobuf или кастомный. Самый гибкий вариант — положить данные в базу, и в кнопке передавать только их идентификатор. Но тогда для обработки любого нажатия на кнопку понадобится сделать лишний запрос в БД.

В коде бота все возможные действия при нажатии на инлайн-кнопки можно представить в виде sealed-иерархии кейс-классов и объектов (ADT):

sealed trait CallbackData

case class BuyIceCream(flavor: String, quantity: Int) extends CallbackData
case object Exit extends CallbackData

Так для обработки полученного CallbackData можно использовать паттерн-матчинг, и компилятор выдаст предупреждение, если мы забыли обработать одно из действий. Это не подойдёт для ботов с большим количеством действий, сопоставление получится слишком длинным, но для небольшого бота оказалось весьма удобным:

callbackData match {
  case BuyIceCream(flavor, _) => reply(s"Enjoy your $flavor ice cream!")
  case Exit => reply("Goodbye")
}

Из Scala-библиотек мне понравилась kantan.csv. Она позволяет производить сериализацию/десериализацию кейс-классов в/из CSV, например так:

import kantan.csv._
import kantan.csv.ops._

implicit val buyIceCreamRowEncoder: RowEncoder[BuyIceCream] =
  RowEncoder.caseOrdered(BuyIceCream.unapply _)
implicit val buyIceCreamRowDecoder: RowDecoder[BuyIceCream] =
  RowDecoder.ordered(BuyIceCream.apply _)

BuyIceCream("vanilla", 1).writeCsvRow(rfc)
"vanilla,1".readCsv[List, BuyIceCream](rfc).head.toOption.get
// val res0: String = vanilla,1
// val res1: BuyIceCream = BuyIceCream(vanilla,1)

Однако в ней нет инструментов для работы с sealed-иерархиями. Модуль generic-extras библиотеки Circe позволяет мапить ADT в JSON, добавляя в JSON-объекты дискриминатор — поле с именем типа наследника. Я решил реализовать аналогичное поведение для kantan.csv, используя в качестве дискриминатора первую колонку с именем типа класса: BuyIceCream,vanilla,1.

kantan.csv уже умеет мапить индивидуальные кейс-классы, нужно только помочь ему выбрать нужного наследника трейта на основании дискриминатора. Это как раз область применения библиотеки Magnolia. Описание с сайта: "Magnolia is a generic macro for automatic materialization of typeclasses for datatypes composed from case classes (products) and sealed traits (coproducts)".

Следуя простому туториалу, объявил два derivation object'а для тайпклассов RowEncoder и RowDecoder из kantan.csv. Для сериализации краткое имя класса (typeName.short) добавляется в начало списка колонок из полей кейс-класса:

import kantan.csv.RowEncoder
import magnolia._

import scala.language.experimental.macros

object MagnoliaRowEncoder {
  type Typeclass[T] = RowEncoder[T]

  def combine[T](ctx: CaseClass[Typeclass, T]): Typeclass[T] =
    (d: T) =>
      ctx.parameters.foldLeft(Seq.empty[String]) {
        (acc, p) => acc ++ p.typeclass.encode(p.dereference(d))
      }

  def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] =
    (d: T) =>
      ctx.dispatch(d) { sub =>
        sub.typeName.short +: sub.typeclass.encode(sub.cast(d))
      }

  implicit def deriveRowEncoder[T]: Typeclass[T] = macro Magnolia.gen[T]
}

При десериализации из наследников CallbackData выбирается класс, чье краткое имя совпадает со значением первой колонки:

import kantan.csv.{DecodeError, RowDecoder}
import magnolia._

import scala.language.experimental.macros

object MagnoliaRowDecoder {
  type Typeclass[T] = RowDecoder[T]

  def combine[T](ctx: CaseClass[Typeclass, T]): Typeclass[T] =
    (e: Seq[String]) =>
      ctx.constructEither { p =>
        p.typeclass.decode(Seq(e(p.index)))
      }
        .left
        .map(_.head)

  def dispatch[T](ctx: SealedTrait[Typeclass, T]): Typeclass[T] =
    (e: Seq[String]) =>
      if (e.isEmpty)
        Left(DecodeError.OutOfBounds(0))
      else {
        ctx.subtypes
          .find(_.typeName.short == e.head)
          .map(_.typeclass.decode(e.tail))
          .getOrElse(Left(DecodeError.TypeError(s"Invalid type tag: ${e.head}")))
      }

  implicit def deriveRowDecoder[T]: Typeclass[T] = macro Magnolia.gen[T]
}

Теперь можно энкодить и декодить CallbackData:

import MagnoliaRowEncoder._
import MagnoliaRowDecoder._

BuyIceCream("vanilla", 1).asInstanceOf[CallbackData].writeCsvRow(rfc)
// res0: String = BuyIceCream,vanilla,1
"BuyIceCream,vanilla,1".readCsv[List, CallbackData](rfc).head.toOption.get
// res1: CallbackData = BuyIceCream(vanilla,1)

Так несколько строчек кода на Magnolia позволили мне использовать ADT CallbackData для работы с CSV со всеми вытекающими бонусами исчерпывающего (exhaustive) паттерн-матчинга.

Здесь можно поиграться с кодом из поста.

Поиск