case classからXMLを生成するScalaのマクロを作る

はじめに

Play Frameworkにはcase classからJSONへ変換するためのインスタンスを自動で作るマクロがある。一方でなんらかの型をXMLへ変換する機能はない。そこでこの記事では、まずPlayがどのようにcase classをJSONへ変換しているのかを軽く解説してそれのXML版を作り、最後にScalaのマクロを用いてcase classからXMLを生成するためのインスタンスを自動生成する。

Playが型AをJSONへ変換するとき

Playが型AをJSONへ変換する際にはWrites[A]という型クラスのインスタンスを要求する。このWritesは次のような型クラスになっている。

https://github.com/playframework/playframework/blob/master/framework/src/play-json/src/main/scala/play/api/libs/json/Writes.scala

trait Writes[-A] {
  /**
   * Convert the object into a JsValue
   */
  def writes(o: A): JsValue

  /**
   * Transforms the resulting [[JsValue]] using transformer function
   */
  def transform(transformer: JsValue => JsValue): Writes[A] = Writes[A] { a => transformer(this.writes(a)) }

  /**
   * Transforms resulting [[JsValue]] using Writes[JsValue]
   */
  def transform(transformer: Writes[JsValue]): Writes[A] = Writes[A] { a => transformer.writes(this.writes(a)) }
}

transformwritesから作られているので、ひとまずここでは無視すると、型クラスWrites[A]のインスタンスは次のようなメソッドを持つ。

def writes(o: A): JsValue

このwritesは引数として型Aの値oを取り、JSONを表す型JsValueを返しているので、writesは型AからJSONへ変換する関数であるといえる。例えば、Playでよく使うJson.toJsonという関数は次のようになる。

https://github.com/playframework/playframework/blob/master/framework/src/play-json/src/main/scala/play/api/libs/json/Json.scala#L118

def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = tjs.writes(o)

このように、implicitパラメーターでWrites[T]のインスタンスを受け取って、それを用いてJSONへの変換を行う。 これをそっくりXMLへ移植すればよい。

AをXMLへ変換する型クラスXmlWrites[A]

JSONへ変換する際に用いた型クラスWrites[A]とほとんど同じXmlWrites[A]を次のように定義する。ScalaにはXMLを表す型があらかじめ用意されているので、それをそのまま用いればよい。

trait XmlWrites[-A] {
  def writes(o: A): scala.xml.NodeSeq
}

そして、Json.toJsonのような関数を定義する。

object Xml {
  def toXml[W](o: W)(implicit X: XmlWrites[W]): scala.xml.NodeSeq = X.writes(o)
}

これは次のように使うことができる。

case class Test(a: String, b: Int)

implicit val testWrites: XmlWrites[Test] = new Writes[Test] {
  def writes(o: Test): NodeSeq =
    
      {o.a}
    
    
      {o.b.toString}
    
}

Xml.toXml(Test("hoge", 123))

やや冗長だが、これで一応動作はする。

マクロを用いたインスタンスの自動生成

PlayのWritesにはマクロを使ってcase classのWritesインスタンスを自動生成することができる。例えば先ほどのTestWritesは次のように書ける。

case class Test(a: String, b: Int)

implicit val testJsonWrites: Writes[Test] = Json.writes[Test]

Json.toJson(Test("hoge", 123))

このように一行でインスタンスを生成できる。Playの実装を調べるとScalaのマクロを使ってインスタンスを生成していたので、こちらの記事を参考にしつつ、case classのインスタンスを自動生成するマクロを次のように実装する。

object Xml {
  def toXml[W](o: W)(implicit X: XmlWrites[W]): scala.xml.NodeSeq = X.writes(o)

  def xmlWrites[A]: XmlWrites[A] = macro XmlMacroImpl.impl[A]
}
// macroでcase classのXmlWritesインスタンスを自動導出する
// http://matsu-chara.hatenablog.com/entry/2015/06/21/110000
object XmlMacroImpl {
  def impl[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[XmlWrites[A]] ={
    import c.universe._

    // case classのクラス名
    val caseClassSym: c.universe.Symbol = c.weakTypeOf[A].typeSymbol
    if (!caseClassSym.isClass || !caseClassSym.asClass.isCaseClass) c.abort(c.enclosingPosition, s"$caseClassSym is not a case class")

    // 各フィールドのシンボル
    val syms: List[TermSymbol] = caseClassSym.typeSignature.decls.toList.collect { case x: TermSymbol if x.isVal && x.isCaseAccessor => x }

    val xmlTreeList: List[Tree] = syms.map { e =>
      val name = e.name.toString.trim
      val elemTree = q"_root_.scala.xml.Elem(null, $name, _root_.scala.xml.Null, _root_.scala.xml.TopScope, false, implicitly[XmlWrites[${e.typeSignature}]].writes(o.${TermName(name)}): _*)"
      q"$$removeEmptyNode($elemTree)"
    }

    val xmlTree: Tree = xmlTreeList.tail.foldLeft(xmlTreeList.head)((x, y) =>
      q"$x ++ $y"
    )

    val finalTree: Tree =
      q"""
        def $$removeEmptyNode(node: _root_.scala.xml.Elem): _root_.scala.xml.NodeSeq = node match {
          case _root_.scala.xml.Elem(_, _, _, _) => _root_.scala.xml.NodeSeq.Empty
          case _ => node
        }

        new XmlWrites[$caseClassSym] {
          def writes(o: $caseClassSym): scala.xml.NodeSeq = $xmlTree
        }
      """

    c.Expr[XmlWrites[A]](finalTree)
  }
}

case classかどうかを判定して、case classならばフィールドと型情報を取得し、それを使ってコードを組み立てていく。また、$$removeEmptyNode関数は<a>NodeSeq.Empty</a>のようなXMLノードが発生した際に、その要素を消去する関数である。

あとは、よく使いそうなインスタンスをXmlWritesのコンパニオンオブジェクトに用意しておく。

object XmlWrites {
  implicit val stringWrites: XmlWrites[String] = new XmlWrites[String] {
    def writes(o: String): NodeSeq = Text(o)
  }

  implicit val intWrites: XmlWrites[Int] = new XmlWrites[Int] {
    def writes(o: Int): NodeSeq = Text(o.toString)
  }

  implicit val floatWrites: XmlWrites[Float] = new XmlWrites[Float] {
    def writes(o: Float): NodeSeq = Text(o.toString)
  }

  implicit val doubleWrites: XmlWrites[Double] = new XmlWrites[Double] {
    def writes(o: Double): NodeSeq = Text(o.toString)
  }

  implicit val booleanWrites: XmlWrites[Boolean] = new XmlWrites[Boolean] {
    def writes(o: Boolean): NodeSeq = Text(o.toString)
  }

  implicit def listWrites[A](implicit W: XmlWrites[A]): XmlWrites[List[A]] = new XmlWrites[List[A]] {
    def writes(o: List[A]): NodeSeq = NodeSeq.fromSeq(o.flatMap(W.writes(_).toSeq))
  }

  implicit def optionWrites[A](implicit W: XmlWrites[A]): XmlWrites[Option[A]] = new XmlWrites[Option[A]] {
    def writes(o: Option[A]): NodeSeq = o match {
      case Some(a) => W.writes(a)
      case None => NodeSeq.Empty
    }
  }
}

これらを使えば、先ほどのcase classTestXmlWrites[Test]は次のように生成できる。

case class Test(a: String, b: Int)

implicit val testJsonWrites: XmlWrites[Test] = Xml.xmlWrites[Test]

Xml.toXml(Test("hoge", 123))

まとめ

この記事ではcase classからXMLへ変換する型クラスを用いて、XMLを生成し、かつインスタンスをマクロで生成するということを行った。はじめてのマクロにはいろいろ苦労したが、実用的なものが少ない行数で実装できてよかった。

コメント