先日@110146氏とごはんを食べていたのだが、そのときにBorerというライブラリがおもしろいという話を聞いたので、試しに使ってみることにした。他にもSmithyやDeep Java Libraryといったライブラリの話も聞いたので、順に書いているというわけ。
CBOR
CBORとは何かというと、シリアライゼーションのためのバイナリフォーマットだ。シーボアと発音するらしい。
もともとバイナリ・シリアライゼーションではMessagePackという技術が存在していたのだが、これの成果物を利用した派生的後継(後方非互換)としてCBORが作成され、MessagePackプロジェクトと無関係な人物がそのままRFC8949としてCBOR標準化に成功するという、半ば強引・政治的な駆け引きの結果、バイナリ・シリアライゼーション・フォーマットとして標準的な地位を獲得しつつある。
techinfoofmicrosofttech.osscons.jp
まぁ成立経緯はなにやらキナ臭いのだが、標準化されているだけあってさすがに規格はしっかりしていて、CDDLというスキーマ定義言語も用意されていてこれもRFC化されている。
既にCBORが使われている箇所としてFIDOの一部の処理(Client To Authenticator Protocol)でCBORを使っているし、BlueskyのAT Protocolでも使われているとのこと。JWTのCBOR版としてCWT RFC 8392も用意されている。
筋も良さそうで、Protobufほどの大がかりさが必要ない局面でこれから広まっていくのではないだろうか。
手際よくMIMEにも載っており、MIMEのメディアタイプはapplication/cbor
。
機能面では、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で実装されている
- Pure ScalaなのでScala.jsにそのまま利用可能
- Scala Nativeにはまだ対応していないが、Issueはある An ode to borer, or: Please add support for scala-native · Issue #633 · sirthias/borer · GitHub
- コアモジュールは何にも依存していない
- 導出モジュールが提供されている
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などのツーリングについては以下の記事を参考。
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を読み書きする機能が用意されている。
このリンクの通り、かなりパフォーマンスも良いというオマケ付きだ。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に変換してどのくらい小さくなるか試していた。
元の話題はこれ。
以下の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 00 ┊ 01 87 87 4d 76 30 83 18 │ │00000010│ 64 60 1b 00 00 01 87 87 ┊ 4d 76 30 83 18 64 60 1b │ │00000020│ 00 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 65 ┊ 18 64 65 6c 61 62 65 6c │ │00000010│ 60 69 63 72 65 61 74 65 ┊ 64 41 74 1b 00 00 01 87 │ │00000020│ 87 4d 76 30 a3 65 70 72 ┊ 69 63 65 18 64 65 6c 61 │ │00000030│ 62 65 6c 60 69 63 72 65 ┊ 61 74 65 64 41 74 1b 00 │ │00000040│ 00 01 87 87 4d 76 30 a3 ┊ 65 70 72 69 63 65 18 64 │ │00000050│ 65 6c 61 62 65 6c 60 69 ┊ 63 72 65 61 74 65 64 41 │ │00000060│ 74 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でも利用できるし、フロントエンドとの通信に使うと劇的に通信量を抑えられる可能性がある。