特級シリアライズライブラリ、winery 1.0解禁

fumieval.hatenablog.com

あれから9ヶ月…wineryバージョン1.0をついにリリースした。

前回までのあらすじ

データの保存や通信に直列化は不可欠の概念である。 binaryなどの直列化ライブラリは、レコードのフィールド名などの情報が欠けており、構造が変わると互換性を持たせることができない。 一方、JSONやCBORなどのフォーマットで愚直にフィールド名などを残すと極めて冗長になり、時間・空間効率が悪い。 コード生成が前提のProtobufなどはHaskell既存のデータ構造との相性がよくない。 そんな現状に殴り込みをかけたのがwineryだ。値を「スキーマ」と「データ」に分割して保存することによって、冗長性を避けつつ、メタデータを保持させることができる。wineryは最強のライブラリとなりうるか…?

特徴と特長

JSON, MessagePack, CBORなど、多くのフォーマットでは値にフィールド名などの情報を付属させる。

[{"id": 0, "name": "Alice"}, {"id": 1, "name": "Bob"}]

wineryが違うのは、それらメタデータをデータ本体から分離し、一箇所にまとめて保存することにある。これにより、冗長性はなくなり、しかも要素がwell-typedであることを保証する。

0402 0402 0269 6410 046e 616d 6514  [{ id :: Integer, name :: Text }]
0200 0541 6c69 6365 0103 426f 62    [(0, "Alice"), (1, "Bob")]

メタデータのおかげでデシリアライザに互換性を持たせることも可能となる。もちろん、目的に応じてメタデータを省き、binaryやcerealと同じように使うこともできる。 整数のエンコードにはVLQを採用しているため、binaryやcerealよりも短くなりやすい。

使い方

まずSerialiseインスタンスを定義する。DerivingViaを使って簡単にインスタンスを導出できる。この導出機構は再帰的なデータ型にも対応している。

{-# LANGUAGE DerivingVia, DeriveGeneric, OverloadedStrings, ApplicativeDo #-}
import Control.Applicative
import Data.Winery
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)

data User = User
  { first_name :: !Text
  , last_name :: !Text
  , email :: !Text
  } deriving (Show, Generic)
  deriving Serialise via WineryRecord User

WineryRecordはどのようなインスタンスにするか選ぶためのラッパーだ。目的に応じてWineryProduct (フィールド名なし), WineryVariant(コンストラクタ名あり)と使い分けよう。

あとはserialise :: Serialise a => a -> ByteStringdeserialise :: Serialise a => ByteString -> Either WineryException aで自由にシリアライズ・デシリアライズができる。

> serialise (User "Fumiaki" "Kinoshita" "fumiexcel@gmail.com")
"\EOT\EOT\ETX\nfirst_name\NAK\tlast_name\NAK\ENQemail\NAK\aFumiaki\tKinoshita\DC3fumiexcel@gmail.com"

> deserialise @User "\EOT\EOT\ETX\nfirst_name\NAK\tlast_name\NAK\ENQemail\NAK\aFumiaki\tKinoshita\DC3fumiexcel@gmail.com"
Right (User {first_name = "Fumiaki", last_name = "Kinoshita", email = "fumiexcel@gmail.com"})

互換性

レコードにフィールドを追加したときや、バリアントからコンストラクタを削除したときなどに古いデータとの互換性が失われる。そんな場合のための処理をコンポーザブルに記述できる仕組みがwineryには備わっている。

UserにRoleというフィールドを追加したい場合を考えよう。

data Role = Admin | Moderator | Member
  deriving (Show, Generic)
  deriving Serialise via WineryVariant Role

data User = User
  { first_name :: !Text
  , last_name :: !Text
  , email :: !Text
  , role :: !Role
  } deriving (Show, Generic)

データにroleが欠けている場合の振る舞いも、ApplicativeDo記法を用いてカスタマイズができる。なんとメールアドレスがexample.comで終わっていれば自動で昇格するといった芸当も可能だ。

instance Serialise User where
  bundleSerialise = bundleRecord $ const $ buildExtractor $ do
    f <- extractField "first_name"
    l <- extractField "last_name"
    e <- extractField "email"
    r <- const <$> extractField "role"
      <|> pure (\x -> if T.isSuffixOf "example.com" x then Moderator else Member)
    return $ User f l e (r e)

RoleからModeratorを削除した場合も簡単に対応できる。

instance Serialise Role where
  bundleSerialise = bundleVariant $ const $ buildExtractor
    $ ("Admin", \() -> Admin)
    `extractConstructor` ("Moderator", \() -> Member)
    `extractConstructor` ("Member", \() -> Member)
    `extractConstructor` extractVoid

パフォーマンス

どんなに便利でも遅くては仕方がない。広く使われているbinary, cereal, aeson, serialiseと比較するためのテイスティング・セッションを行った。

課題となるのは以下のデータ型だ。それぞれの方法でインスタンスを導出し、1000要素のリストのシリアライズ・デシリアライズをする。

data Gender = Male | Female deriving (Show, Generic)

data TestRec = TestRec
  { id_ :: !Int
  , first_name :: !Text
  , last_name :: !Text
  , email :: !Text
  , gender :: !Gender
  , num :: !Int
  , latitude :: !Double
  , longitude :: !Double
  } deriving (Show, Generic)

{-
1,Shane,Plett,splett0@free.fr,Male,-222,53.3928271,18.3836801
2,Mata,Snead,msnead1@biblegateway.com,Male,-816,51.5141668,-0.1331854
3,Levon,Sammes,lsammes2@woothemes.com,Male,485,51.6561,35.9314
...
-}

結果は以下の通りだ。wineryがダントツで速いだけでなく、生成されるバイト列も最も短い。

encode 1 encode 1000 decode length
winery 0.28 μs 0.26 ms 0.81 ms 58662
cereal 0.82 μs 0.78 ms 0.90 ms 91709
binary 1.7 μs 1.7 ms 2.0 ms 125709
serialise 0.61 μs 0.50 ms 1.4 ms 65437
aeson 9.9 μs 9.7 ms 17 ms 160558

総評

互換性と拡張性、あらゆるデータ型に対応できる柔軟な導出メカニズム、そして卓越したパフォーマンスと簡潔な表現を提供するwinery 1.0は、ここ数年で最高の出来栄えと言えるだろう。Hackageへ急げ!

報告はGitHubもしくはHaskell-jp Slackまで。