Зачем нужен Thrift и основы работы с ним на Scala

28 сентября 2015

Thrift — это такая штука для сериализации данных. Вы описываете схему данных в специальном формате. Из этого описания генерируются классы. Эти классы легко сериализуются и десериализуются. При этом схему можно изменять (например, добавлять-удалять поля в классах) так, что данные, сериализованные по старой схеме, будут успешно десериализованы по новой. Одну и ту же схему можно использовать в проектах на разных языках, и они будут успешно друг с другом взаимодействовать. Плюс к этому еще накручена возможность объявлять исключения и генерировать код для RPC. В этой заметке мы разберемся, как работать с Thrift на языке Scala.

Во-первых, нам понадобятся следующие зависимости:

"org.apache.thrift" % "libthrift" % "0.9.2",
"com.twitter" %% "scrooge-core" % "3.20.0",

Во-вторых, в project/plugins.sbt дописываем:

addSbtPlugin("com.twitter" %% "scrooge-sbt-plugin" % "3.16.3")

… а в build.sbt:

com.twitter.scrooge.ScroogeSBT.newSettings

Таким образом мы подключили к проекту Scrooge-плагин и можем генерировать Scala-классы из thrift-файлов командой:

sbt scrooge-gen

Более того, генерация будет происходить автоматически при сборке проекта, в том числе при выполнении команды sbt assembly.

Далее создаем src/thrift/game.thrift следующего содержания:

// include "ololo.thrift"

namespace java me.eax.examples.thrift.game

// exception Ololo { ... }

// service Ololo { ... }

enum Weapon {
    Sword = 1
    Bow = 2
}

struct WarriorInfo {
    1: optional Weapon weapon
    2: required i64 arrowsNumber
}

enum Spell {
    Fireball = 1
    Thunderbolt = 2
}

struct MageInfo {
    1: required set<Spell> spellbook
    2: required i64 mana
}

union ClassSpecificInfo {
    1: WarriorInfo warrior
    2: MageInfo mage
}

struct Hero {
    1: required string name
    2: required i64 hp
    3: required i64 xp
    4: ClassSpecificInfo classSpecificInfo
}

Мне нужно было придумать пример, демонстрирующий использование required и optional полей, enum’ов, контейнеров типа set и map, а также, как при помощи Thrift получить алгебраические типы. В итоге придумались такие вот классы для RPG-игры. Мне кажется, тут все довольно очевидно, так что двигаемся дальше.

Примеры использования сгенерированных классов:

  val spellbook = Set(Spell.Thunderbolt, Spell.Fireball)
  val mage = Hero(
    name = "afiskon", hp = 25L, xp = 1024L,
    ClassSpecificInfo.Mage(MageInfo(spellbook, mana = 100L))
  )

  val warrior = Hero(
    name = "eax", hp = 50L, xp = 256L,
    ClassSpecificInfo.Warrior(WarriorInfo(Some(Weapon.Sword), 0L))
  )

Для тестирования сериализации и десериализации мной были написаны тесты с использованием ScalaCheck. Вот один из них:

forAll { (data1: Hero) =>
  val bytes = {
    val out = new ByteArrayOutputStream()
    data1.write(new TBinaryProtocol(new TIOStreamTransport(out)))
    out.toByteArray
  }

  val data2 = {
    val stream = new ByteArrayInputStream(bytes)
    Hero.decode(new TBinaryProtocol(new TIOStreamTransport(stream)))
  }

  data1 shouldBe data2
}

Заметьте, что ScalaCheck откуда-то знает, как генерировать случайных героев. Это потому что для класса Hero мной специально был написан генератор. Генераторы в ScalaCheck пишутся очень просто. Выглядит это примерно так:

implicit lazy val arbHero: Arbitrary[Hero] = Arbitrary(
  for {
    name <- Arbitrary.arbitrary[String]
    hp <- Gen.posNum[Long]
    xp <- Gen.posNum[Long]
    classSpecificInfo <- Arbitrary.arbitrary[ClassSpecificInfo]
  } yield Hero(name, hp, xp, classSpecificInfo)
)

Генераторы для ClassSpecificInfo.Mage, ClassSpecificInfo.Warrior и, так сказать, «корневого» ClassSpecificInfo выглядят аналогично.

Обратите внимание, что выше приводится тест для TBinaryProtocol. Всего Thrift поддерживает четыре так называемых протокола сериализации:

  1. TBinaryProtocol — обычный бинарный протокол;
  2. TCompactProtocol — компактный бинарный протокол;
  3. TJSONProtocol — очень сложно читаемый JSON;
  4. TSimpleJSONProtocol — легко читаемый JSON, но без десериализации;

Использование этих протоколов выглядит аналогично. Например:

def heroToJson(hero: Hero): String = {
  val out = new ByteArrayOutputStream()
  hero.write(new TSimpleJSONProtocol(new TIOStreamTransport(out)))
  new String(
    ByteBuffer.wrap(out.toByteArray).array(),
    StandardCharsets.UTF_8
  )
}

Еще одна интересная фишка Thrift — возможность вручную закодировать список, map или set объектов:

forAll { (data1: List[Hero]) =>
  val bytes = {
    val out = new ByteArrayOutputStream()
    val proto = new TBinaryProtocol(new TIOStreamTransport(out))
    proto.writeListBegin(new TList(TType.STRUCT, data1.size))
    data1.foreach(_.write(proto))
    proto.writeListEnd()
    out.toByteArray
  }

  val data2 = {
    val stream = new ByteArrayInputStream(bytes)
    val proto = new TBinaryProtocol(new TIOStreamTransport(stream))
    val listInfo = proto.readListBegin()
    val res = {
      for(_ <- 1 to listInfo.size) yield Hero.decode(proto)
    }.toList
    proto.readListEnd()
    res
  }

  data1 shouldBe data2
}

Вот, по большому счету, все, что я хотел рассказать сегодня про Thrift. Более подробную информацию вы найдете по следующим ссылкам:

А что вы используете для сериализации и десериализации?

Дополнение: Заметки Сериализация и десериализация в/из Protobuf на C++ и Сериализация в языке Go на примере библиотеки codec также могут быть вам интересны.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.