Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

RFCで標準化されたバイナリ・シリアライゼーション・フォーマットのCBORとライブラリのBorerを試してみた

先日@110146氏とごはんを食べていたのだが、そのときにBorerというライブラリがおもしろいという話を聞いたので、試しに使ってみることにした。他にもSmithyやDeep Java Libraryといったライブラリの話も聞いたので、順に書いているというわけ。

CBOR

CBORとは何かというと、シリアライゼーションのためのバイナリフォーマットだ。シーボアと発音するらしい。

cbor.io

もともとバイナリ・シリアライゼーションではMessagePackという技術が存在していたのだが、これの成果物を利用した派生的後継(後方非互換)としてCBORが作成され、MessagePackプロジェクトと無関係な人物がそのままRFC8949としてCBOR標準化に成功するという、半ば強引・政治的な駆け引きの結果、バイナリ・シリアライゼーション・フォーマットとして標準的な地位を獲得しつつある。

www.geekpage.jp

techinfoofmicrosofttech.osscons.jp

まぁ成立経緯はなにやらキナ臭いのだが、標準化されているだけあってさすがに規格はしっかりしていて、CDDLというスキーマ定義言語も用意されていてこれもRFC化されている。

既にCBORが使われている箇所としてFIDOの一部の処理(Client To Authenticator Protocol)でCBORを使っているし、BlueskyのAT Protocolでも使われているとのこと。JWTのCBOR版としてCWT RFC 8392も用意されている。

zenn.dev

筋も良さそうで、Protobufほどの大がかりさが必要ない局面でこれから広まっていくのではないだろうか。

手際よくMIMEにも載っており、MIMEのメディアタイプはapplication/cbor

www.iana.org

機能面では、JSONよりも型が多彩で、例えばバイト文字列とUTF-8が強制されるテキスト文字列が区別されていたり、MessagePackと比べてもCBORのほうが多機能になっている。例えばタギングという機能によりYAMLのようなCBOR内リファレンスが可能だ。Protobufと比べると、RFCで標準化されており、リファレンス機能があるのが強みになるだろう。

CBORの便利そうなところ

  • JSONの完全なスーパーセットになる
  • バイナリなので容量が削減できる
  • 大きな整数をそのまま扱えるのでJSONみたく文字列化しなくてよい
  • UTF-8文字列をそのまま格納できる
  • NaNやInfがある
  • 整数とfloatの区別がある
  • バイト列がある
  • Decimalがある

Borer

さて、そんなCBORのScala実装がBorerだ。Borerには以下のような特長がある:

  • Pure Scalaで実装されている
  • コアモジュールは何にも依存していない
  • 導出モジュールが提供されている
    • borer-derivationを使うと、既存のcase classなどからCBORの型定義が導出され、そのままCBORに変換したり、CBORから戻したりできるようになる。
    • 本体とモジュールが分離されているので、不要な場合は成果物をコンパクトにできる。
  • No Reflectionである
    • 実行時に型情報を要求せず、コンパイル時に静的に型を決定できる。
    • 実行時に安全にcase classとの変換が可能。
  • Circeなどとの互換性モジュールが存在する
    • derivationはCirceに任せつつ、フォーマットとしてはCBORを使うといった振舞いが可能。
  • たいていのScalaの型をCBORに変換できる Types Supported Out-of-the-Box · borer
    • BigInt
    • Option
    • Map
    • Either
    • Enum
    • Tuple

Scala Scriptを書いてさっそく試してみた。

Scala Scriptなどのツーリングについては以下の記事を参考。

blog.3qe.us

Borerを試す

素朴な変換

データをCborにエンコード・デコードするにはCborオブジェクトを使う。

エンコード

Cbor.encodeを利用することで、配列や文字列などはCBORに変換できる。出力は.toByteArrayを呼ぶことでArray[Byte]として得られる。

//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3

import io.bullet.borer.Cbor

val orig = Seq[Int](0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

val bytes: Array[Byte] = Cbor.encode(orig).toByteArray
//  => Array[Byte](-97, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1)

BigIntといった型もなんなく扱うことができる:

val bigint = Seq[BigInt](
  BigInt("100000000000"),
  BigInt("200000000000"),
  BigInt("300000000000"),
)

Cbor.encode(bigint).toByteArray
// => Array(-97, 27, 0, 0, 0, 23, 72, 118, -24, 0, 27, 0, 0, 0, 46, -112, -19, -48, 0, 27, 0, 0, 0, 69, -39, 100, -72, 0, -1)

Eitherでも試してみる:

import io.bullet.borer.Codec.ForEither.default

val ei: Either[String, String] = Right("This is right")

Cbor.encode(ei).toByteArray
// => Array(-95, 1, 109, 84, 104, 105, 115, 32, 105, 115, 32, 114, 105, 103, 104, 116)

デコード

Cbor.decodeを利用することで、CBORから各種の型に変換できる。結果は.to[型].valueで受け取る。

//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3

import io.bullet.borer.Cbor

val gotBytes: Array[Byte] = Array[Byte](-97, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1)

val decoded = Cbor.decode(gotBytes).to[Seq[Int]].value
// => Seq[Int](0, 1, 2, 3, 4, 5, 6, 7, 8, 9)

型安全にする

.toByteArray.valueの代わりに、Tryを返す.toByteArrayTry.valueTryが用意されている。

//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3

import io.bullet.borer.Cbor

val gotBytes: Array[Byte] = Array[Byte](-97, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1)

val decoded = Cbor.decode(gotBytes).to[Seq[String]].valueTry
// =>  Failure(io.bullet.borer.Borer$Error$InvalidInputData: Expected String or Text-Bytes but got Int (input position 1))

型安全にするかどうか選べるのが便利だなと思った。

Case classを変換してもらう

Case classやEnumによる複雑なADTのエンコーダ/デコーダを自動的に用意してもらうには、borer-derivationモジュールを使う。givenと一緒にderiveEncoder / deriveDecoderを呼び出せば、そのcase classなどの変換に必要な仕組みが勝手に用意される。

今回はPersonというcase classを変換してみる:

//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3
//> using dep io.bullet::borer-derivation::1.10.3

import io.bullet.borer.{Cbor, Encoder, Decoder}

// Case classをエンコードする作法としてMapを使うかArrayを使うか選べる。今回はMapを使う
// Arrayはサイズが小さくなるが互換性にシビアになる
import io.bullet.borer.derivation.MapBasedCodecs._

case class Person(name: String, age: Int, interested: Seq[String])

// 全自動でエンコーダを導出してもらう
given Encoder[Person] = deriveEncoder

val me = Person("windymelt", 30, Seq("scala", "tea"))

val encoded = Cbor.encode(me).toByteArray
// => Array[Byte](-93, 100, 110, 97, 109, 101, 105, 119, 105, 110, 100, 121, 109, 101, 108, 116, 99, 97, 103, 101, 24, 30, 106, 105, 110, 116, 101, 114, 101, 115, 116, 101, 100, -97, 101, 115, 99, 97, 108, 97, 99, 116, 101, 97, -1)

// 全自動でデコーダを導出してもらう
given Decoder[Person] = deriveDecoder
val decoded = Cbor.decode(encoded).to[Person].value
// => Person(windymelt,30,List(scala, tea))

BorerでJSONを読む

便利機能として、BorerはJSONを読み書きする機能が用意されている。

sirthias.github.io

このリンクの通り、かなりパフォーマンスも良いというオマケ付きだ。Jsoniterほどではないが、Circeより速い。

特定の型ではない汎用型にデコードする

特定の型によらず、ただのCBORのASTが欲しいときは、io.bullet.borer.Dom.Element型に向けてdecodeするとよい。

//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3

import io.bullet.borer.Cbor
import io.bullet.borer.Dom._

val cbr = Array[Byte](???)

val dom = Cbor.decode(cbr).to[Element].value

CBORをデバッグする

BorerにはCBORをプリントデバッグする機能がある。decodeするときに.withPrintLogging()を挟むだけでよい。

val dom = Cbor.decode(cbr).withPrintLogging().to[Element].value

すると自動的にpretty printされる。

    1| *[
    1|     [
  1/3|         100
  2/3|         ""
  3/3|         1681602213424L
    1|     ]
    2|     [
  1/3|         100
  2/3|         ""
  3/3|         1681602213424L
    2|     ]
    3|     [
  1/3|         100
  2/3|         ""
  3/3|         1681602213424L
    3|     ]
    1| ]
    2| END

CBORでどのくらい小さくなる?

先日、JSONをMessagePackに変換してどのくらい小さくなるか試していた。

blog.3qe.us

元の話題はこれ。

blog.utgw.net

以下のJSONデータをCBORにして、何バイトくらいになるか試してみよう。

[
  {"price":100,"label":"","createdAt":1681602213424},
  {"price":100,"label":"","createdAt":1681602213424},
  {"price":100,"label":"","createdAt":1681602213424},
]
//> using scala 3.3.0
//> using dep io.bullet::borer-core::1.10.3
//> using dep io.bullet::borer-derivation::1.10.3
//> using dep com.lihaoyi::os-lib::0.9.1

import io.bullet.borer.{Cbor, Encoder, Decoder}

import io.bullet.borer.derivation.ArrayBasedCodecs._

case class Payment(price: Int, label: String, createdAt: BigInt)

// 全自動でエンコーダを導出してもらう
given Encoder[Payment] = deriveEncoder

val records = Seq(
  Payment(100, "", BigInt("1681602213424")),
  Payment(100, "", BigInt("1681602213424")),
  Payment(100, "", BigInt("1681602213424")),
)

val encoded = Cbor.encode(records).toByteArray
os.write(os.pwd/"data.cbor", encoded)

これを実行すると以下のようなCBORが得られる:

% hexyl -C data.cbor
┌────────┬─────────────────────────┬─────────────────────────┐
│00000000│ 9f 83 18 64 60 1b 00 0001 87 87 4d 76 30 83 18 │
│0000001064 60 1b 00 00 01 87 87 ┊ 4d 76 30 83 18 64 60 1b │
│0000002000 00 01 87 87 4d 76 30 ┊ ff                      │
└────────┴─────────────────────────┴─────────────────────────┘

サイズは41バイトになった。

% wc -c data.cbor
41 data.cbor

Base64に入れても56バイトと、かなりエコ。というのもラベル情報がCBORにはなくてcase classが配列にエンコードされているため。

% wc -c <(cat data.cbor| base64 -w0)
56 /proc/self/fd/11

Array形式ではなくMap形式にすると、もうちょっとサイズが大きくなる:

% hexyl -C data.cbor
┌────────┬─────────────────────────┬─────────────────────────┐
│00000000│ 9f a3 65 70 72 69 63 6518 64 65 6c 61 62 65 6c │
│0000001060 69 63 72 65 61 74 6564 41 74 1b 00 00 01 87 │
│0000002087 4d 76 30 a3 65 70 7269 63 65 18 64 65 6c 61 │
│0000003062 65 6c 60 69 63 72 6561 74 65 64 41 74 1b 00 │
│0000004000 01 87 87 4d 76 30 a3 ┊ 65 70 72 69 63 65 18 64 │
│0000005065 6c 61 62 65 6c 60 6963 72 65 61 74 65 64 41 │
│0000006074 1b 00 00 01 87 87 4d ┊ 76 30 ff                │
└────────┴─────────────────────────┴─────────────────────────┘
% wc -c data.cbor
107 data.cbor

Base64に入れると144バイトになった。

% wc -c <(cat data.cbor| base64 -w0)
144 /proc/self/fd/11

データ形式が変わらないことが確実な環境であれば、case classをArrayにエンコードすることで大幅にデータサイズを抑えられることは覚えておきたい。BorerはScala.jsでも利用できるし、フロントエンドとの通信に使うと劇的に通信量を抑えられる可能性がある。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?