Septeni Engineer's Blog

セプテーニ・オリジナルのエンジニアが綴る技術ブログ

ちょっとした文字列変換処理を書いた話(あるいはplay-functionalの機能を利用してみた話)

こんにちは。張沢です。

業務で特定の型のインスタンスを文字列に変換する処理を書いた際に、play-functionalの機能を利用したときの話を書いてみます。

例えば、あるオブジェクト(case classなど)があった場合、以下のような仕様の文字列が生成されるような実装について考えます。*1

  • key=value の形式で出力する
  • 各keyは、. で区切られる
  • 配列やリストの場合は、keyの後にzero-based indexを [] で囲んで付ける

具体例は以下の通りです。

case class User(id: Long, name: String, telephoneNumbers: Seq[String])

例1:

val user = User(1L, "user_1", Seq("000-0000-0000", "111-1111-1111"))
user.id=1
user.name=user_1
user.telephoneNumbers[0]=000-0000-0000
user.telephoneNumbers[1]=111-1111-1111

例2:

val users = Seq(
    User(1L, "user_1", Seq("111-1111-1111")),
    User(2L, "user_2", Seq("222-2222-2222"))
)
users[0].id=1
users[0].name=user_1
users[0].telephoneNumbers[0]=111-1111-1111
users[1].id=2
users[1].name=user_2
users[1].telephoneNumbers[0]=222-2222-2222

実装してみる

とりあえず、特定の型に対する文字列変換処理だけ書ければよいので、今回は型クラスを使って実装してみます。まずは、振る舞いを持つtraitを定義しましょう。

ある型 A が複数のfieldを持つ場合は複数の文字列のリストが返るので、戻り値は Seq[String] としておきます。

trait Writer[-A] {
  def write(key: String, value: A): Seq[String]
}

次に、基本的な型に対する Writer と、Writer を取得する便利メソッドの Writer.of[T] を定義しておきます。

object Writer extends DefaultWriters {

  def write[T](key: String, value: T)(implicit writer: Writer[T]): Seq[String] =
    writer.write(key, value)

  def of[T](implicit writer: Writer[T]): Writer[T] = writer
}

trait DefaultWriters extends LowPriorityWriter {
  implicit val stringWriter: Writer[String] =
    (key, value) => Seq(s"$key=$value")

  implicit val intWriter: Writer[Int] =
    (key, value) => stringWriter.write(key, value.toString)

  implicit val longWriter: Writer[Long] =
    (key, value) => stringWriter.write(key, value.toString)

  implicit val bigDecimalWriter: Writer[BigDecimal] =
    (key, value) => stringWriter.write(key, value.toString)

  implicit def arrayWriter[T: ClassTag: Writer](implicit writer: Writer[T]): Writer[Array[T]] =
    (key, values) => {
      values.zipWithIndex.flatMap {
        case (v, i) =>
          writer.write(s"$key[$i]", v)
      }
    }
}

sealed trait LowPriorityWriter {
  implicit def traversableWriter[T: Writer](implicit writer: Writer[T]): Writer[Traversable[T]] =
    (key, values) => {
      values.toSeq.zipWithIndex.flatMap {
        case (v, i) =>
          writer.write(s"$key[$i]", v)
      }
    }
}

Writer[Traversable[T]]LowPriorityWriter に定義して継承しているのは、ambiguous implicit values エラーを避けるためによく使われる方法です。(継承により、implicit解決の優先順位に差が付きます)

参考: "Scala 2.8 implicit prioritisation, as discussed in: http://www.scala-lang.org/sid/7"
https://gist.github.com/retronym/228673

さて、基本的な実装は大体できたので、さっそく使ってみましょう。

$ sbt console

scala> Writer.write("key", "string_value")
res0: Seq[String] = List(key=string_value)

scala> Writer.write("foo", Seq(1,2,3))
res1: Seq[String] = List(foo[0]=1, foo[1]=2, foo[2]=3)

scala> Writer.write("foo", Seq(Seq(1,2,3), Seq(4,5,6)))
res2: Seq[String] = List(foo[0][0]=1, foo[0][1]=2, foo[0][2]=3, foo[1][0]=4, foo[1][1]=5, foo[1][2]=6)

ここまでは良さそうな感じです。

では、複数のフィールドを持つ型に対する Writer を定義してみましょう。まず、フィールド名(key)を受け取り、元のkey名(prefix)に . で連結した新しいkey名で値を書き出すように定義します。

object Writer extends DefaultWriters {

  // 中略

  def at[T](key: String)(implicit writer: Writer[T]): Writer[T] =
    (prefix, value) => writer.write(newKey(key, prefix), value)

  private def newKey(key: String, prefix: String): String =
    if (prefix.nonEmpty) s"$prefix.$key"
    else key
}

さて、では早速このメソッドを使って User に対するWriterを定義してみましょう。

case class User(id: Long, name: String, telephoneNumbers: Seq[String])

object User {

  implicit val userWriter: Writer[User] = Writer { (prefix, value) =>
    Writer.at[Long]("id").write(prefix, value.id) ++
    Writer.at[String]("name").write(prefix, value.name) ++
    Writer.at[Seq[String]]("telephoneNumbers").write(prefix, value.telephoneNumbers)
  }
}

うーん…これは…。

毎回write()にkeyとしてprefix渡さないといけないとか、値としてvalue.idとか全部指定しないとダメとか、色々とイケてないですね。

play-jsonWritesみたいに書けないかなぁ、と思いました。例えば以下のように。

object User {
    import play.api.libs.functional.syntax._

    implicit val userWriter: Writer[User] = (
      Writer.at[Long]("id") and
      Writer.at[String]("name") and
      Writer.at[Seq[String]]("telephoneNumbers")
    )(unlift(User.unapply))
}

play-jsonの内部実装を見てみる

play-jsonのWritesで使われてるandメソッドの定義元に移動すると、play-jsonが依存しているplay-functionalの以下のコードにたどり着きます。

play-functional/src/main/scala/play/api/libs/functional/Products.scala#L23-L30

class FunctionalBuilderOps[M[_], A](ma: M[A])(implicit fcb: FunctionalCanBuild[M]) {
  def ~[B](mb: M[B]): FunctionalBuilder[M]#CanBuild2[A, B] = {
    val b = new FunctionalBuilder(fcb)
    new b.CanBuild2[A, B](ma, mb)
  }

  def and[B](mb: M[B]): FunctionalBuilder[M]#CanBuild2[A, B] = this.~(mb)
}

型パラメータを1つ取る M に対する FunctionalCanBuild が定義されていれば、 and を呼び出すことができそうです。play-jsonOWrites では以下のように定義されています。

play-json/shared/src/main/scala/play/api/libs/json/Writes.scala#L94-L105

object OWrites extends PathWrites with ConstraintWrites {
  import play.api.libs.functional._

  // 中略

  /**
   * An `OWrites` merging the results of two separate `OWrites`.
   */
  private object MergedOWrites {
    def apply[A, B](wa: OWrites[A], wb: OWrites[B]): OWrites[A ~ B] =
      new OWritesFromFields[A ~ B] {
        def writeFields(fieldsMap: mutable.Map[String, JsValue], obj: A ~ B): Unit = {
          val a ~ b = obj
          mergeIn(fieldsMap, wa, a)
          mergeIn(fieldsMap, wb, b)
        }
      }

  // 中略

  implicit val functionalCanBuildOWrites: FunctionalCanBuild[OWrites] = new FunctionalCanBuild[OWrites] {
    def apply[A, B](wa: OWrites[A], wb: OWrites[B]): OWrites[A ~ B] = MergedOWrites[A, B](wa, wb)
  }

2つの OWrites を使って1つの OWrites[A ~ B] が返せれば良いようです。また、 val a ~ b = obj のような書き方でAとBそれぞれの値を取得できそうです。これは、play-functionalで ~ が以下のように定義されていて、パターンマッチングで値が取り出せるためです。

play-functional/src/main/scala/play/api/libs/functional/Products.scala#L9

case class ~[A, B](_1: A, _2: B)

幸い今回実装した Writer は、戻り値が Seq[String] なので、2つの Writer の結果をまとめることは難しくありません。以下のように定義すればよさそうです。 Writer はSAM type*2なので、いわゆるラムダ式で書けます。

  // Writer[A ~ B] を Writer[A] と Writer[B] を使って実装
  implicit val functionalCanBuildWriter: FunctionalCanBuild[Writer] = new FunctionalCanBuild[Writer] {
    def apply[A, B](pa: Writer[A], pb: Writer[B]): Writer[A ~ B] = (key, value) => {
      val a ~ b = value
      pa.write(key, a) ++ pb.write(key, b)
    }
  }

やったか!?と思いつつ前記のコードを書いてみると、どうやら ContravariantFunctor[Writer] が定義されていないと怒られるようです。

  implicit val userWriter: Writer[User] = (
    Writer.at[Long]("id") and
    Writer.at[String]("name") and
    Writer.at[Seq[String]]("telephoneNumbers")
  )(unlift(User.unapply))

そもそも and で繋がれた処理の戻り値はなんでしょうか?

どうやら FunctionalBuilder[M]#CanBuildN (Nは2〜22)が返るようです。今回は Writer を3つ繋げているので、 FunctionalBuilder[Writer]#CanBuild3[Long, String, Seq[String]] となります。この型のapplyの引数が (Long, String, Seq[String]) => User になっているので、unlift(User.unapply) を渡していたわけですね。

さて、この apply() のimplicit引数に ContravariantFunctor[Writer] が要求されているため、コンパイルエラーになっていたようです。

play-jsonOWrites では以下のように定義されています。

play-json/shared/src/main/scala/play/api/libs/json/Writes.scala#L140-L143

trait Writes[A] { self =>

  // 中略

  /**
   * Returns a new instance that first converts a `B` value to a `A` one,
   * before converting this `A` value into a [[JsValue]].
   */
  def contramap[B](f: B => A): Writes[B] = Writes[B](b => self.writes(f(b)))

  // 中略

trait OWrites[A] extends Writes[A] {
  def writes(o: A): JsObject

  // 中略

  override def contramap[B](f: B => A): OWrites[B] =
    OWrites[B](b => this.writes(f(b)))

  // 中略

  implicit val contravariantfunctorOWrites: ContravariantFunctor[OWrites] = new ContravariantFunctor[OWrites] {
    def contramap[A, B](wa: OWrites[A], f: B => A): OWrites[B] =
      wa.contramap[B](f)
  }

contravariant(反変)とのことですが、とりあえず M[B] を返すような contramap() が定義できればよいみたいです。 Writer[A]f: B => A があれば、 Writer[B] を作ることは難しくありません。以下のように書けばよいでしょう。

  implicit val contravariantFunctorWriter: ContravariantFunctor[Writer] = new ContravariantFunctor[Writer] {
    def contramap[A, B](wa: Writer[A], f: B => A): Writer[B] = (key, value) => {
      wa.write(key, f(value))
    }
  }

完成形のコード

早速、 WriterFunctionalCanBuildContravariantFunctor を定義してみましょう。

ついでに key を指定せずにインスタンスを直接書き出すメソッドも追加しておきましょう。 Writer のcompanion objectの完成形は以下のようになりました。

object Writer extends DefaultWriters {
  import play.api.libs.functional._

  def write[T](key: String, value: T)(implicit writer: Writer[T]): Seq[String] =
    writer.write(key, value)

  def write[T](value: T)(implicit writer: Writer[T]): Seq[String] =
    writer.write("", value)

  def of[T](implicit writer: Writer[T]): Writer[T] = writer

  def at[T](key: String)(implicit writer: Writer[T]): Writer[T] =
    (prefix, value) => writer.write(newKey(key, prefix), value)

  private def newKey(key: String, prefix: String): String =
    if (prefix.nonEmpty) s"$prefix.$key"
    else key

  implicit val functionalCanBuildWriter: FunctionalCanBuild[Writer] = new FunctionalCanBuild[Writer] {
    def apply[A, B](pa: Writer[A], pb: Writer[B]): Writer[A ~ B] = (key, value) => {
      val a ~ b = value
      pa.write(key, a) ++ pb.write(key, b)
    }
  }

  implicit val contravariantFunctorWriter: ContravariantFunctor[Writer] = new ContravariantFunctor[Writer] {
    def contramap[A, B](wa: Writer[A], f: B => A): Writer[B] = (key, value) => {
      wa.write(key, f(value))
    }
  }
}
case class User(id: Long, name: String, telephoneNumbers: Seq[String])

object User {
  import play.api.libs.functional.syntax._

  implicit val userWriter: Writer[User] = (
    Writer.at[Long]("id") and
    Writer.at[String]("name") and
    Writer.at[Seq[String]]("telephoneNumbers")
  )(unlift(User.unapply))
}
$ sbt console

scala> Writer.write("user", User(12345L, "user_1", Seq("000-0000-0000", "111-1111-1111")))
res0: Seq[String] = List(user.id=12345, user.name=user_1, user.telephoneNumbers[0]=000-0000-0000, user.telephoneNumbers[1]=111-1111-1111)

scala> Writer.write(User(12345L, "user_1", Seq("000-0000-0000", "111-1111-1111")))
res1: Seq[String] = List(id=12345, name=user_1, telephoneNumbers[0]=000-0000-0000, telephoneNumbers[1]=111-1111-1111)

できました!最初のコードよりはすっきり書けるようになったと思います。

また、型クラスを使用しているので、以下のように Writer が定義されている型のインスタンスだけを許容する関数の定義もできます。( Writer が定義されていない型のインスタンスが渡された場合はコンパイルエラーになります)

def post[T: Writer](endpoint: String, value: T): Future[Unit] = {

  // この関数の中で Writer.write(value) が実行できます

}

終わりに

いかがでしたか?play-jsonに依存しているプロジェクトであればplay-functionalの機能も使えますし、play-jsonでそれがどのように使われているのかソースコードを読んでみると面白いかと思います。

参考リンク

blog.leifbattermann.de

*1:業務で実装した文字列の仕様は、この記事での仕様例とは異なります

*2:Single Abstract Method type