1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

msgpack4zのcodecを自動導出する

Posted at

個人的に作っているWebアプリで、せっかくなのであまり例のない「REST APIでメッセージ交換にmsgpackつかってみるやつ」をやっています。
軽く全体像を説明すると、サーバ側がRust、Webフロント側がScala.jsという趣味全開の構成をしたものになっています。

どちらの言語にもmsgpackのライブラリが存在するのでmsgpack自体を扱うことにそこまで苦労はないのですが、Rust側で使用しているrmp-serdeのフォーマットとScala側で使用しているmsgpack4z-coreで用意されているフォーマット(Codec)がいくつか違っていたりしてそのまま使用することができないという問題にぶち当たりました。
また、msgpack4z-coreにはいくつか標準的な型向けのCodecは存在しますが、自前で用意した型向けには都度型の構造に合わせたCodecインスタンスを用意する必要があります。さらに、Scala3以降のenum型(ADT)をそのまま扱えるCodecはライブラリには存在しません(見た限りでは)。

というわけで、Scala3のType Class Derivationの機能を活用して、rmp互換のCodecをcase classおよびenumから自動導出するということをやりました。

対象読者について

Scala 3で新たに導入されたusing/givenに関する話なので、これらのキーワードが前提知識となります。
ちょっとだけ他の言語がわかる人に平たく言うと、型クラスの使用およびインスタンス定義です。詳しくは公式ドキュメントなどを読んで下さい。

rmpとmsgpack4zのフォーマットの違い

rmp(Rust MessagePack)とmsgpack4zはいくつか標準で提供しているフォーマットが異なります。

例えば、Option<i32>Option[Int])は以下のように異なるエンコーディング結果となります。

rmp(json等価表記) msgpack4z(json等価表記)
None null { "None": null }
Some(x) x { "Some": x }

また、rmpではデフォルトでコンパクトなCodecを採用しています。これはどういうことかというと、構造体のフィールド名がなくなりタプルと等価の表現になったり、パラメータなしのenum variant(enum case)が単一の文字列と等価になったりします1。更に細かい話として、タプル構造体と通常の構造体でフィールドが1つ以下の場合のエンコーディングが異なります
具体的にどう変化するかは次の表を参考にしてください。

#[derive(Serialize)]
pub struct Identified(i32);

#[derive(Serialize)]
pub struct Identified2 { pub id: i32 }

#[derive(Serialize)]
pub struct Template {
    pub a: i32,
    pub b: &'static str
}

#[derive(Serialize)]
pub enum Command {
    NoAction,
    Push(i32),
    Update(i32, i32)
}
rmp(json等価表記)
Identified(2) 2
Identified2 { id: 2 } [2]
Template { a: 1, b: "test" } [1, "test"]
Command::NoAction "NoAction"
Command::Push(3) { "Push": 3 }
Command::Update(1, 2) { "Update": [1, 2] }

msgpack4zではどちらのフォーマットを採用するかが選べるので(MapでしっかりやるならCaseMapCodec、ArrayでいいならCaseCodec)、どちらにするかは通信先と予め決めておく必要があります。

例えば、rmp側は前述の通りArrayでエンコーディングしてくるので通信先がrmpを使っている場合はCaseCodecを使うことになります。

目標とする形

最終的には次のような記述でmsgpackのエンコーディング(パッキング)/デコーディング(アンパッキング)を行えるようにすることを目指します。

final case class Template(
  val a: Int,
  val b: String
) derives MsgpackCodec

enum Command derives MsgpackCodec:
  case NoAction
  case Push(val value: Int)
  case Update(val index: Int, val newValue: Int)

Scala 3のメタプログラミング

givenインスタンスの自動生成(Type Class Derivation)

Scala 3では、型定義にderives Traitをつけることによってトレイト(型クラス)のgivenインスタンスを自動生成することができます。
内部仕様としてはTrait.derivedが呼び出されており、derivedをトレイトのcompanion objectもしくはTrait.typeのextensionに定義することで自作トレイトでも同じことができるようになります。

derivedgivenでもinline defでも定義できますが、givenの場合は意図していないgivenインスタンスも生えることになってちょっとややこしいことになるので個人的にはinline defでの定義をおすすめします。

derivedの実装方法にはMirrorを使う方法とマクロを使う方法があります。今回はMirrorを使う方法で実装します。

inline definline match

Scala 3にはinlineというキーワードがあり、D言語のstatic if static foreachみたいな感じでコンパイル時に計算される処理を記述できます。
たとえばジェネリック型に対する処理などは、コンパイル後には型変数は消去されてしまうので通常は記述できませんが、コンパイル時であればそのあたりの情報は残ったままなのでその情報を使った処理を記述することができます。

Mirror

Scala 3には代数的データ型(enumおよびcase class)の構造を表す情報としてscala.deriving.Mirrorがあり、これを使うことでコンパイル時に型の構造を得ることができます。

Scalaにおける、というより似たような型の概念を持つ言語であれば大半同じような話ですが、代数的データ型は大きく「直積型(Product)」と「直和型(Sum)」の2種類に分類されます。
直積型は平たく言うと構造体やタプルのことで、純粋にひとつの型で複数の値を同時に持つものです。
直和型は直積型が集合したような形をしていて、ひとつの型で複数の直積型データを並行して表現することができます。

// 直積型 ProductTypeという型でxとyのふたつの数値を保持できる
case class ProductType(val x: Int, val y: Int)
// 直和型 SumType型の値はSumType.Product1かSumType.Product2のどちらかになる
enum SumType:
  case Product1(x: Int, y: Int)
  case Product2(z: Int)

Scala 3ではコンパイル時にこういった型の構造を取得していろいろなことを行うことができて、今回のType Class Derivationもこの機能をつかうもののひとつになります。
scala.deriving.Mirrorcase class enumおよびenum caseのそれぞれひとつずつに対してコンパイラが自動で定義し、Mirror.Of[T]summonInlineするかusing経由で取得できます。

実際にType Class Derivationを書いて自動導出する

ここまでで前提知識は揃ったので実際にderivedを書いていきます。
中身はこれから作るとして、ひとまず全体像を把握するため外側のderivedを仮定義します。対象型のMirrorをusingで受け取り、inline matchでProductの場合とSumの場合で分岐してそれぞれの構造用のCodecを生成するコードを書いていきます。

extension (c: MsgpackCodec.type)
  inline def derived[T](using m: Mirror.Of[T]): MsgpackCodec[T] = inline m match
    case p: Mirror.ProductOf[T] => ???
    case s: Mirror.SumOf[T] => ???

直積型のCodecを導出する

まずは簡単な直積型(Product)のcodecを導出してみます。今回はタプル構造体については考えずに通常の構造体の場合のみを考えます(アプリで通信層にタプル構造体をつかってないためです)。
Scala 3ではMirrorによって任意のcase classとタプルを相互に変換することが可能なため、タプルのCodecを利用して導出する方法を取ります。

final case class ProductClass(
  val x: Int,
  val y: Double,
  val name: String
)

// ProductClassは以下のタプル型と相互変換できます
type ProductClassTuple = (Int, Double, String)

で、やることですが、「任意の型を別の型に変換することでmsgpackにできるCodec」がmsgpack4zにビルトインで提供されているので、直積型に関してはそれを使うだけで基本的におしまいです。

    case p: Mirror.ProductOf[T] =>
      MsgpackCodec.from[p.MirroredElemTypes, T](
        p fromProduct _.asInstanceOf[Product],
        x => Some(Tuple.fromProduct(x.asInstanceOf[Product]).asInstanceOf[p.MirroredElemTypes]),
      )

ここで、Mirror.Product.MirroredElemTypesが直積型の各フィールドの型のタプルとなっています。

変換できたのは良いのですが、msgpack4zにはジェネリックなTupleCaseCodecが存在しません。それは自前で用意します。

inline given [T <: Tuple]: MsgpackCodec[T] =
  val subcodecs = summonAll[Tuple.Map[T, MsgpackCodec]].toList.asInstanceOf[List[MsgpackCodec[?]]]
  MsgpackCodec.codecTry(
    (packer, x) =>
      packer packArrayHeader x.asInstanceOf[Product].productArity
      for (c, e) <- subcodecs zip x.asInstanceOf[Product].productIterator do c.pack(packer, e.asInstanceOf)
      packer.arrayEnd()
    ,
    unpacker =>
      val len = unpacker.unpackArrayHeader()
      val elements =
        if len >= subcodecs.length then for c <- subcodecs yield c.unpack(unpacker).throwLeft
        else throw NotEnoughArraySize(subcodecs.length, len)
      unpacker.arrayEnd()
      Tuple
        .fromProduct(new Product:
          def canEqual(that: Any): Boolean = true
          def productArity: Int = subcodecs.length
          def productElement(n: Int): Any = elements(n)
          override def productIterator: Iterator[Any] = elements.iterator
        )
        .asInstanceOf[T],

まず最初にタプルの要素のCodecをすべて用意します。タプルには型レベルMapが標準ライブラリで提供されているので、それを使って要素をそれぞれMsgpackCodec[T]に変換してsummonAllで一括でインスタンスを召喚します2。タプルのままでは取り回しづらいのでtoListでリストにします。
MsgpackCodec.codecTryはCodecをフルスクラッチで作成できるメソッドです。通常のCodecはアンパック時のエラーをscalazのEither\/)型で返す必要がありますが、MsgpackCodec.codecTryはアンパック時の例外をうまくラップしてくれるので普通に例外が使えて便利です。
パッキングは今回は配列データとして行うので、配列ヘッダを出力→各要素を出力→配列フッタを出力、でOKです。タプルの各要素はProductを経由してProduct.productIteratorで取得できます。
アンパッキング時はデータの長さ判定が入っていますが概ねパッキング時の逆操作をしています。for...yieldの出力は当たり前ですがタプルではないので、出力シーケンスを雑にProductに偽装してTuple.fromProductでタプル化します。

コード中にしれっと登場しているthrowLeftはいわゆるOption.getEither版で、Leftの場合は中身を例外として送出する補助関数です。
.toEither.toTry.getと書いても同じ挙動になりますが、2回変換が入るのとちょっと長ったらしいのでエイリアス的な形で用意しています。

extension [R, L <: Throwable](e: \/[L, R]) def throwLeft = e.fold(e => throw e, identity)

直和型のCodecを導出する

直積型のCodecはMsgpackCodec.fromのおかげでほとんど説明することもないくらい簡単でしたが、直和型は少し厄介です。いきなり両方考えると大変なのでまずはパッキングの方から考えていきます。

パッキング処理

パッキングではenum caseの名前と内部の値を一要素のマップデータとしてパックします。ただしenum caseがもつ値がない場合はただの文字列になります。これをそのまま実装しようとすると以下のようになります。

(packer, x) =>
  val name = ???
  val product = x.asInstanceOf[Product]
  if product.productArity == 0 then
    packer packString name
  else
    packer packMapHeader 1
    packer packString name
    ???
    packer.mapEnd()

enum caseは必ずProductのサブクラスになるので3asInstanceOf[Product]キャストしてしまってOKです。

さて、2つ未実装ポイントが残っています。

  1. xはこの時点ではenum型以外の何物でもなく、具体的にどのenum caseが来るかは実行時にしかわからない。enum caseの具体名を取得するにはMirrorが必要だが、enum caseのMirrorをコンパイル時に召喚することができないので名前を取得することができない。
  2. 上記と同じく、コンパイル時点では具体的なenum caseがわからないのでCodecインスタンスを召喚することができず、パラメータのパッキング処理を書くことができない。

どちらも具体的なenum caseのMirrorをコンパイル時に取得することができないことが原因となっています。
たしかに具体的な型をコンパイル時に取得することはできないわけですが、代わりに実行時にはenum case序数(ordinal)を入手することができます。そのため、コンパイル時にすべてのenum caseに対する各種情報をインデックス可能な形で生成しておくことで実行時に序数を使って具体的なenum caseに合った各情報を利用することができます。

まずenum caseの名前ですが、これはenum case自身のMirrorが持っているほか、親となるenumのMirrorがタプルとして持っています。この型Mirror.Sum.MirroredElemLabels依存型とよばれるもので、型レベルでenum caseのラベル名の文字列データをそのままもっています。

enum SumType:
  case Product1(x: Int, y: Int)
  case Product2(z: Int)
// これのMirrorは以下(一部)
new Mirror.Sum:
  type MirroredElemLabels = ("Product1", "Product2")
  ...

依存型のタプルから実際の値のタプルを生成するにはscala.compiletime.constValueTupleを使います。また、本来はタプルに対してapplyをすることでタプルの要素を取得することができるのですが、今回は文脈的にインライン展開されてしまうようでconstValueTupleに対して引数適用しようとしてるとみなされてエラーになってしまうのでtoListでリストに変換します。ついでにうまく型計算を畳み込んでくれないのでList[String]に型強制します。

val names = constValueTuple[s.MirroredElemLabels].toList.asInstanceOf[List[String]]

続いてenum caseのCodecです。こちらは今回はタプル構造体の場合のみを考えます(例によってアプリ側でそれ以外使ってないからです)。
通常の構造体向けのCodecはそのまま使えないので別途Codecを用意します。

private inline def enumElementCodec[T](using p: Mirror.ProductOf[T]): MsgpackCodec[T] =
  inline erasedValue[p.MirroredElemTypes] match
    case _: EmptyTuple =>
      MsgpackCodec.codec(
        (_, _) => (),
        _ =>
          \/-(p.fromProduct(new Product:
            def canEqual(that: Any): Boolean = true
            def productArity: Int = 0
            def productElement(n: Int): Any = ???
          )),
      )
    case _: (t *: EmptyTuple) =>
      MsgpackCodec.from[t, T](
        x => p.fromProduct(Tuple1(x)),
        x => Some(x.asInstanceOf[Product].productElement(0).asInstanceOf[t]),
      )(
        summonInline[MsgpackCodec[t]]
      )
    case _: (t *: ts) =>
      MsgpackCodec.from[p.MirroredElemTypes, T](
        p fromProduct _,
        x => Some(Tuple.fromProduct(x.asInstanceOf[Product]).asInstanceOf[p.MirroredElemTypes]),
      )

それぞれ要素数が0, 1, 2以上の場合の処理になっています。
scala.compiletime.erasedValueは指定された型の消去された値を得る関数です。消去された値については詳しく説明ができないんですが、幽霊型の値版みたいな存在だと認識しています。実行時には存在せずコンパイル時にのみ存在する、かつ読み取りができない(具体的な値を持たない)値です。
matchを使うには何かしらの値が必要があるので「値はつかわないけど型のマッチングだけしたい」場合に消去された値を仮の値として使うことができます。
要素数が0の場合はなにもしないCodecを、1の場合は最初の要素のCodecをそのまま使う形の定義を、2以上の場合は先に紹介したProductとおなじ定義となっています。

つづいてこれをenum caseそれぞれについて実体化させるのですが、ここでは地道に再帰関数を書いていきます。
本当はTuplemapがあるのでそれとerasedValueの組み合わせでいけるはず、つまり次のような形で書けるはずなんですが、謎のエラーが取れず4安定しなかったので自前で書くことにしました。

ほんとうはうごくはず
inline def defineAllEnumElementCodecs[Ts <: Tuple]: List[MsgpackCodec[?]] =
  erasedValue[Ts].map { [t] => (_: t) => enumElementCodec[t](using summonInline[Mirror.ProductOf[t]]) }.toList.asInstanceOf

で、やりたいこととしては先のコードの通りただのmapなのでそのままシンプルな再帰関数に分解します。

inline def defineAllEnumElementCodecs[Ts <: Tuple]: List[MsgpackCodec[?]] = inline erasedValue[Ts] match
  case _: EmptyTuple => Nil
  case _: (t *: ts)  => enumElementCodec(using summonInline[Mirror.ProductOf[t]]) :: defineAllEnumElementCodecs[ts]

ここまでくれば必要なものは揃ったので最初のコードの不足分を補ってパッキングは完成です。

+ val names = constValueTuple[s.MirroredElemLabels].toList.asInstanceOf[List[String]]
+ val allCodecs = defineAllEnumElementCodecs[s.MirroredElemTypes]
(packer, x) =>
-   val name = ???
+   val name = names(s ordinal x)
  val product = x.asInstanceOf[Product]
  if product.productArity == 0 then
    packer packString name
  else
    packer packMapHeader 1
    packer packString name
-     ???
+     allCodecs(s ordinal x).pack(packer, x.asInstanceOf)
    packer.mapEnd()

アンパッキング処理

アンパッキングでは、enum caseが持つ要素数が0かそうでないかで表現が変わるという点を考慮する必要がありますが、それ以外は現時点で揃っているもので簡単に構成できます。特に追加で説明するものもないのでコードから載せます。

unpacker =>
  unpacker.nextType() match
    case MsgType.STRING =>
      val name = unpacker.unpackString()
      val ordinal = names indexOf name
      if ordinal < 0 then throw Error(s"No variant named ${name} in enum")
      val e = allCodecs(ordinal).unpack(unpacker)
      e.throwLeft.asInstanceOf[T]
    case MsgType.MAP =>
      unpacker.unpackMapHeader()
      val name = unpacker.unpackString()
      val ordinal = names indexOf name
      if ordinal < 0 then throw Error(s"No variant named ${name} in enum")
      val et = allCodecs(ordinal).unpack(unpacker)
      val e = et.throwLeft
      unpacker.mapEnd()
      e.asInstanceOf[T]
    case _ => throw Error("invalid encoding of enum")

unpacker.nextType()で次に来る要素が何であるかを取得し、String(要素がない場合)かMap(要素がある場合)かで処理を分けています。
enum caseの各要素は先のCodecを使ってそのままアンパッキングすればOKで、あとはそれをもとのenum型に強制しておしまいです。ただ先のコードで生成したCodecを使うには序数が必要なのでnamesから探し出します。

今回はenum caseの数が少ない(せいぜい20とか)ので雑にindexOfで線形検索していますが、50個とか100個とかになってきたらMapでインデックス作ったほうが良いかもしれないですね。

これでSumに対するCodecを作ることができました。

全体

以上で当初の目的であった代数的データ型に対するCodecの自動導出プログラムができあがりました。
Mirrorを使うことでこうした型構造に依存したプログラムを書くことが可能になり、うまく使えば工数の大幅な節約ができます。
あとは型から値をつくるところなんかはちょっとしたパズルをやっている感覚で作っていてとても楽しかったです。
ただまあ一般的なコードとはちょっと違って、ありとあらゆる型の構造を考慮したりしないといけなくて若干複雑になるので何も考えず量産するとメンテ不能な怪物が出来上がる上に改修失敗するとプロジェクト全体に被害が及ぶのがこの手の自動生成プログラムの恐ろしいところでしょうか。まあでも適切な間隔で作ったりコメントや関数を駆使してメンテナンス性をある程度確保するようにすればよさそうですね(これは自動導出プログラムに限った話ではないですが)。

最後に全体を載せておしまいにします。

extension (c: MsgpackCodec.type)
  inline def derived[T](using m: Mirror.Of[T]): MsgpackCodec[T] = inline m match
    case p: Mirror.ProductOf[T] =>
      MsgpackCodec.from[p.MirroredElemTypes, T](
        p fromProduct _.asInstanceOf[Product],
        x => Some(Tuple.fromProduct(x.asInstanceOf[Product]).asInstanceOf[p.MirroredElemTypes]),
      )
    case s: Mirror.SumOf[T] =>
      val names = constValueTuple[s.MirroredElemLabels].toList.asInstanceOf[List[String]]
      val allCodecs = defineAllEnumElementCodecs[s.MirroredElemTypes]
      MsgpackCodec.codecTry(
        (packer, x) =>
          val name = names(s ordinal x)
          val product = x.asInstanceOf[Product]
          if product.productArity == 0 then
            packer packString name
          else
            packer packMapHeader 1
            packer packString name
            allCodecs(s ordinal x).pack(packer, x.asInstanceOf)
            packer.mapEnd()
        ,
        unpacker =>
          unpacker.nextType() match
            case MsgType.STRING =>
              val name = unpacker.unpackString()
              val ordinal = names indexOf name
              if ordinal < 0 then throw Error(s"No variant named ${name} in enum")
              val e = allCodecs(ordinal).unpack(unpacker)
              e.throwLeft.asInstanceOf[T]
            case MsgType.MAP =>
              unpacker.unpackMapHeader()
              val name = unpacker.unpackString()
              val ordinal = names indexOf name
              if ordinal < 0 then throw Error(s"No variant named ${name} in enum")
              val et = allCodecs(ordinal).unpack(unpacker)
              val e = et.throwLeft
              unpacker.mapEnd()
              e.asInstanceOf[T]
            case _ => throw Error("invalid encoding of enum"),
      )
  1. ここではSerdeのデフォルト挙動であるExternally Taggedを前提にして話します

  2. これは完全に余談ですが、givenインスタンスを取り出す操作を「召喚」って呼ぶのはだいぶ厨二心をくすぐられますね

  3. パラメータを持たないenum caseは実はSingletonになるのですが、これはProductのサブトレイトなので同じように扱えます

  4. コンパイルメッセージいわくtAnyになっているようでした 推論がまだうまくいってない......?

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?