Фото Larisa Birta на Unsplash
Для разработки очередного Telegram-бота мне нужно было выбрать формат данных CallbackQuery — это запрос, отправляемый боту при нажатии на кнопку инлайн-клавиатуры.
Дефолтный выбор, JSON, не подходит из-за ограничения в 64 байта, имена полей отнимают место у полезной нагрузки. Поэтому я остановился на CSV из-за его компактности и распространенности. Это текстовый формат, что удобно для отладки и диагностики.
Однако можно использовать и другие форматы, например base64, protobuf или кастомный. Самый гибкий вариант — положить данные в базу, и в кнопке передавать только их идентификатор. Но тогда для обработки любого нажатия на кнопку понадобится сделать лишний запрос в БД.
В коде бота все возможные действия при нажатии на инлайн-кнопки можно представить в виде sealed-иерархии кейс-классов и объектов (ADT):
1
2
3
4
sealed trait CallbackData
case class BuyIceCream(flavor: String, quantity: Int) extends CallbackData
case object Exit extends CallbackData
Так для обработки полученного CallbackData можно использовать паттерн-матчинг, и компилятор выдаст предупреждение, если мы забыли обработать одно из действий. Это не подойдёт для ботов с большим количеством действий, сопоставление получится слишком длинным, но для небольшого бота оказалось весьма удобным:
1
2
3
4
callbackData match {
case BuyIceCream(flavor, _) => reply(s"Enjoy your $flavor ice cream!")
case Exit => reply("Goodbye")
}
Из Scala-библиотек мне понравилась kantan.csv. Она позволяет производить сериализацию/десериализацию кейс-классов в/из CSV, например так:
1
2
3
4
5
6
7
8
9
10
11
12
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) добавляется в начало списка колонок из полей кейс-класса:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 выбирается класс, чье краткое имя совпадает со значением первой колонки:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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:
1
2
3
4
5
6
7
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) паттерн-матчинга.
Здесь можно поиграться с кодом из поста.