日持ちする直列化のためのライブラリ「winery」

人類は、酒と共に発展してきたと言っても過言ではない。穀物や果実などを酒に変換することにより、糖を除く栄養を保ったまま、高い保存性を持たせることができる。酒は人々の喉を潤し、時に薬として使われた。

プログラミングにおいても、終了したら消えてしまうデータを、保存性の高いバイト列に変えたい場面がよくある。そのような操作を直列化(シリアライズ)と呼び、いくつかのアプローチが存在する。

コード生成タイプ

Protocol Bufferscap'n'protoなど

データの構造を記述する言語(スキーマ)から、データ構造およびシリアライザ・デシリアライザをコードごと生成する。幅広い言語で使える一方、作れる構造が限られており、定義済みの構造にも使えないので、Haskellのような言語とは相性があまりよくない。

互換性を保つ機能が充実していることが多い。

汎用フォーマットタイプ

CBORMessagePackJSONXMLなど

数値や文字列などを自由に組み合わせられる表現を用いる。腐りにくく、表現の自由度も高いが、レコードの配列などをエンコードするとフィールド名の分だけ冗長になってしまう。また、既存のデータ型との相互変換において互換性を保つには自分で処理を書く必要がある。

固定タイプ

Marshal (OCaml)、binary (Haskell)など

何のメタデータも持たせず、値の集まりを直接エンコードする。バイト数を短縮でき、型さえ合っていれば正しくデコードできるが、型が変わってしまうと元のデータは使い物にならなくなってしまう。そのため、複雑な構造の長期保存には向かない。

winery

これらのアプローチの欠点を克服するため、wineryという新しいライブラリを開発した。

基本のインターフェイスはbinaryと同様、至ってシンプルである。Serialiseクラスのインスタンスなら、 serialiseで値をByteStringエンコードし、deserialiseでデコードできる。

instance Serialise a where
  schemaVia :: Proxy a -> [TypeRep] -> Schema
  toEncoding :: a -> Encoding
  deserialiser :: Deserialiser a

serialise :: Serialise a => a -> ByteString
deserialise :: Serialise a => ByteString -> Either StrategyError a

ジェネリクスを用いて、レコードや和型にインスタンスを与えることもできる。以下のように書けばレコードのためのインスタンスを生成できる。gdeserialiserRecordにJustを与えれば、デフォルト値を指定することもできる。

instance Serialise Foo where
  schemaVia = gschemaViaRecord
  toEncoding = gtoEncodingRecord
  deserialiser = gdeserialiserRecord Nothing

このように宣言を省略した場合、任意の和型に対応したものが作られる。

instance Serialise Foo

wineryがbinaryと違うのは、データだけでなく、そのスキーマも直列化するという点だ。serialiseスキーマを付属させることにより、生成元のプログラムが変わったり失われたりしても、データがどのような構造を持っているか知ることができる。フィールド名などのメタデータを一箇所に集約することによって、CBORやJSONなどの持つ冗長性を解決している。

|スキーマのバージョン|スキーマ|データ|

以下はwineryのスキーマ型の定義である。Serialiseインスタンスジェネリクスによって導出されている。

data Schema = SSchema !Word8
  | SUnit | SBool | SChar
  | SWord8 | SWord16 | SWord32 | SWord64
  | SInt8 | SInt16 | SInt32 | SInt64 | SInteger
  | SFloat | SDouble
  | SBytes | SText
  | SList !Schema | SArray !(VarInt Int) !Schema
  | SProduct [Schema] | SProductFixed [(VarInt Int, Schema)]
  | SRecord [(Text, Schema)]
  | SVariant [(Text, [Schema])]
  | SFix Schema
  | SSelf !Word8
  deriving (Show, Read, Eq, Generic)

SFixSSelfはやや奇妙に映るかもしれない。これは、再帰的なデータ型のためのもので、SFix不動点を束縛し、SSelf nは直近n番目の不動点を参照する。このFooBarのような複雑に絡み合った再帰も、wineryならば難なく直列化できる。

data Foo = Foo | FooBar Foo Bar
data Bar = BarFoo Foo | BarBar Bar

スキーマとデータを分別して使うことももちろん可能だ。serialiseOnlyは、データのみを直列化する。

serialiseOnly :: Serialise a => a -> ByteString

スキーマschema関数で取得できるので、そのままserialiseすれば良い。

schema :: Serialise a => proxy a -> Schema

データを復元する際は、まずdeserialiseSchemaを取り出したのち、getDecoderでデシリアライザを生成する。対応していないスキーマの場合はエラーになり、成功した場合はByteStringから値を復元する関数が得られる。スキーマを解釈する関数と、デコードする関数が分離されているため、一度スキーマを与えれば、多数のデータを高速に処理することができる。

getDecoder :: Serialise a => Schema -> Either StrategyError (ByteString -> a)

互換性

レコードからのフィールドの削除と、バリアントへのコンストラクタの追加は自明に互換性を保てる。それ以外の場合、自分でデシリアライザを組み立てることも可能である。

Deserialiserが基本となる構造である。Alternativeインスタンスなので、複数の値を取り出したり、スキーマに対応できなかった場合に代わりを用意するといった合成可能性を持つ。

deserialiser :: Serialise a => Deserialiser a

レコードから値を取り出すにはextractFieldを使う。

extractField :: Serialise a => Text -> Deserialiser a

コンストラクタを抽出したい場合、extractConstructorを使う。 gdeserialiserVariant <|> (\() -> Unknown) <$> extractConstructor "Obsolete"のように、 ジェネリックな実装と組み合わせることによって今は無きコンストラクタにも対応することができる。

extractConstructor :: Serialise a => Text -> Deserialiser a

検査性

wineryコマンドラインツールを使えば、元のプログラムなしにデータをいい感じに成形して閲覧できる。どんなデータもデシリアライズできるTerm型を内部で利用している。

$ winery --print-schema < test.winery
μ { foo :: ( Nothing | Just Integer ), bar :: [ByteString], nodes :: [Self 0] }
{ foo = Nothing
, bar = ["hello"]
, nodes = [{ foo = Just 42, bar = ["world"], nodes = [] }] }

将来的には、jqのような豪華なDSLを用意する予定だ。雑に(しかしながらコンパクトかつ高速に)ダンプした情報をwineryコマンドで整形し、UNIXのコマンドや表計算ソフトで解析するといった斬新な戦法も可能になるだろう。

隠し能力

binaryと違い、wineryのデシリアライザはステートレスである。その心は、値を取り出すために、バイト列を全て走査する必要がない。正格でない構造なら、巨大なデータの一部を必要に応じて取り出すという使い方もできる。

まとめ

wineryはbinaryに似た簡単なインターフェイスを持ちながら、シリアライズした情報の可用性を飛躍的に高める。まだまだ荒削りではあるが実用にも耐え、定番ライブラリであるbinaryaesonの代替として高いポテンシャルがあると信じている。もしバグや要望などがあれば、ぜひGitHubに投稿願いたい。