こんにちは。張沢です。
業務で特定の型のインスタンスを文字列に変換する処理を書いた際に、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-json
のWrites
みたいに書けないかなぁ、と思いました。例えば以下のように。
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-jsonの OWrites
では以下のように定義されています。
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-jsonの OWrites
では以下のように定義されています。
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)) } }
完成形のコード
早速、 Writer
に FunctionalCanBuild
と ContravariantFunctor
を定義してみましょう。
ついでに 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でそれがどのように使われているのかソースコードを読んでみると面白いかと思います。