ども。プロダクトグループ所属エンジニアの、あおいの(@AoiroAoino)です。
先月25歳になりましたが、見た目年齢は30代らしいです。よろしくお願いしますmm
弊社技術ブログ一発目の記事担当になってしまったので頑張ります!
さてさて、記念すべき一発目はMongoDBとScalaの組み合わせについて書こうかなと思います。
テーマは「モデルを定義/使用する人がラクできるようにする」です。
前置き
とりあえず何も無いのもアレなので、「イベントホスティングサービスを開発する」という妄想で話を進めていきます。
モデル部分のデータ構造はざっとこんな感じで。
case class Event (
_id: ObjectId,
title: String,
start: String,
end: String,
place: String,
owners: List[User],
attendees: List[User]
)
case class User (
_id: ObjectId,
name: String,
phone: String
)
さぁ、定義したモデル使ってもりもり開発していきましょう!
Casbahを使う
Scalaで使用できるMongoDBクライアントはいくつかあるようですが、今回はMongoDB公式の「Casbah」を使用します。
内部的にはJavaで実装されたmongo-java-driverをラップし、Scalaっぽく書けるようにしたものです。
検索結果がOption
に包まれて返ってきたり、検索クエリ構築のための内部DSLが用意されていたりするので、
Java版を直接使用するよりもScalaっぽく書けるようになっています。
色々方法はあると思いますが、今回はモデルとDBObject
の相互変換をラクにするため、
toModel
とtoDBObject
メソッドを用意していきます。
trait ModelMapper[A] {
def toDBObject(model: A): DBObject
def toModel(obj: DBObject): Option[A]
}
object User extends ModelMapper[User] {
def toDBObject(user: User): DBObject = {
DBObject(
"_id" -> user._id,
"name" -> user.name,
"phone" -> user.phone
)
}
def toModel(obj: DBObject): Option[User] = {
for {
oid <- obj.getAs[ObjectId]("_id")
name <- obj.getAs[String]("name")
phone <- obj.getAs[String]("phone")
} yield User(oid, name, phone)
}
}
ここで、DBObject
からModel
への変換は失敗する可能性があるので、結果がOption
で包まれているのがポイントです。
後々重要になるので、頭の片隅に置いておいてくださいね。
さて、これで準備は完了です!
以下のようにして使用することができます。
scala> val user = User(new ObjectId, "Aoino", "xxx-xxx-xxx")
scala> val obj = User.toDBObject(user)
scala> val model = User.toModel(obj)
scala> model.map(_.name)
scala> User.toModel(DBObject.empty)
良さそうですね!
これでも十分に開発を進めることができるかと思います。
さて、ここからがいよいよ本番です!!
マクロを使ってモデル定義時にラクをする
さて、このままだと新たなモデルが増えるたびにtoDBObject/toModelの定義をしなければいけません。
私は貧弱Vimmerなので、手元のVimにはIDEほどの入力補完もなければ長年蓄積されたスニペット資産もないのでとても面倒です。
なので、面倒なお仕事はコンパイラに押し付けてしまおう!ってことでマクロを使ってみることにします。1
まず最初にマクロを呼び出す側のコードを書いてしまいます。
ModelMapper
をcase class
から得られる情報で自動的に構築するので、とりあえずAutoModelMapper
って名前にしました。
object AutoModelMapper {
def apply[A]: ModelMapper[A] = macro AutoModelMapperImpl.autoModelMapper_impl[A]
}
次に、呼び出されるマクロを実装します。
private object AutoModelMapperImpl {
def autoModelMapper_impl[A: c.WeakTypeTag](c: Context): c.Expr[ModelMapper[A]] = {
import c.universe._
val tpe = weakTypeOf[A]
val constructor = tpe.decls.collectFirst {
case m: MethodSymbol if m.isPrimaryConstructor => m
}.getOrElse(c.abort(c.enclosingPosition, s"Cannot find constructor in $tpe"))
val fields = constructor.paramLists.head match {
case Nil => c.abort(c.enclosingPosition, s"Constructor argument is empty in $tpe")
case x => x
}
val toDBObjectParams: List[Tree] = fields.map { field =>
val name = field.name.toTermName
val decoded = name.decodedName.toString
q"$decoded -> model.$name"
}
val toModelParams: List[Tree] = fields.map { field =>
val name = field.name.toTermName
val decoded: String = name.decodedName.toString
val returnType = tpe.decl(name).typeSignature
q"""obj.getAs[$returnType]($decoded)"""
}
val n = if (toModelParams.length <= 1) "" else toModelParams.length.toString
val applyN = TermName("apply" + n)
val companion = tpe.typeSymbol.companion
c.Expr[ModelMapper[A]](q"""
import scalaz._, Scalaz._
import com.mongodb.casbah.Imports._
import com.github.aoiroaoino.mongodb.ModelMapper
new ModelMapper[$tpe] {
def toDBObject(model: $tpe): DBObject =
DBObject(..$toDBObjectParams)
def toModel(obj: DBObject): Option[$tpe] =
Apply[Option].$applyN(..$toModelParams)($companion.apply _)
}
""")
}
}
コードはちょっと長いですが、やってる事は単純です。ざっくりと見ていきましょう。
まずは型パラメータAの情報を取得し、そこからapply
メソッド、およびその引数名一覧を取得します。
val tpe = weakTypeOf[A]
val constructor = tpe.decls.collectFirst {
case m: MethodSymbol if m.isPrimaryConstructor => m
}.getOrElse(c.abort(c.enclosingPosition, s"Cannot find constructor in $tpe"))
val fields = constructor.paramLists.head match {
case Nil => c.abort(c.enclosingPosition, s"Constructor argument is empty in $tpe")
case x => x
}
fieldsよりDBObject.apply
に渡すためのタプルを作ります。
val toDBObjectParams: List[Tree] = fields.map { field =>
val name = field.name.toTermName
val decoded = name.decodedName.toString
q"$decoded -> model.$name"
}
fieldsよりscalaz.Apply
に渡すための式(obj.getAs[Type]("field_name")
、getAs
はCasbah提供のメソッド)を作ります。2
val toModelParams: List[Tree] = fields.map { field =>
val name = field.name.toTermName
val decoded = name.decodedName.toString
val returnType = tpe.decl(name).typeSignature
q"""obj.getAs[$returnType]($decoded)"""
}
で、最後にModelMapper[User]
のインスタンスを返します。
c.Expr[ModelMapper[A]](q"""
import scalaz._, Scalaz._
import com.mongodb.casbah.Imports._
import com.github.aoiroaoino.mongodb.ModelMapper
new ModelMapper[$tpe] {
def toDBObject(model: $tpe): DBObject =
DBObject(..$toDBObjectParams)
def toModel(obj: DBObject): Option[$tpe] =
Apply[Option].$applyN(..$toModelParams)($companion.apply _)
}
""")
これで一通りマクロの実装は完了です!
先ほどのobject User
の中身を以下のように書き換えます。
object User {
val m = AutoModelMapper[User]
def toDBObject(user: User) = m.toDBObject(user)
def toModel(obj: DBObject) = m.toModel(obj)
}
置き換えた上で、再度試してみると手作業で実装したtoDBObject
/toModel
と同様に使えるかと思います。
これで非常に少ないコードでモデルを定義する際にラクできるようになりました!3
Monocleでモデル使用時にラクをする
さて、突然のMonocleです。
そもそもMonocleとはなんだ?って方が多いかと思うので、簡単に説明すると,「Haskellのlensパッケージを元にJulien Truffaut氏が開発したScalaでOptics
(Lens
, Prism
など)な概念が使えるようになるライブラリ」です。
んじゃそもそもLens
, Prism
とは何かという話になりますが、ここでは簡単な使い方のみご紹介します。4
Lens
は誤解を恐れずに言うと、composable
な抽象化されたgetter
/setter
です。
まずはEvent
に対してLens
とPrism
を定義してしょう。Monocleでは以下のようにして定義します。
trait EventLens {
private def eventGenLens = GenLens[Event]
def _eventOid = eventGenLens(_._id)
def _title = eventGenLens(_.title)
def _startDate = eventGenLens(_.start)
def _endDate = eventGenLens(_.end)
def _place = eventGenLens(_.place)
def _owners = eventGenLens(_.owners)
def _attendees = eventGenLens(_.attendees)
}
今回は省力化のためにMonocleのGenLens
マクロを使用しましたが、マクロを使用せずに定義すると以下のようになります。
こっちの方がcomposable
なLens
って入れ物にgetter
/setter
を入れてる感(?)が伝わってきますね。
def _title = Lens[Event, String](_.title)(t => e => e.copy(title = t))
Prism
は一言で表現するのは難しいのですが、とりあえずcomposable
で、変換(getOption: S => Option[A]
) とその逆変換(reverseGet: A => S
)の関数を持つ概念ってイメージです。
このS => A
とA => Option[S]
がまさにtoModel
とtoDBObject
の関係にあたるのが見てとれるかと思います。
trait EventPrism {
def _event = Prism[DBObject, Event] {
case obj: DBObject => Event.toModel(obj)
case _ => None
}(Event.toDBObject _)
}
さーて、ここまでだいぶ長い道のりでしたが、Lens
とPrism
を定義することで魔法のような操作が可能になります。
val owner1 = User(new ObjectId, "Owner1", "xxx")
val owner2 = User(new ObjectId, "Owner2", "yyy")
val event = Event(new ObjectId, "title", "start", "end", "place", List(owner1, owner2), List())
val findOneResult: Option[Event] = Some(event)
上記サンプルデータを使ってデータを操作してみましょう。5
scala> findOneResult applyPrism some[Event, Event] composeLens _place getOption
res31: Option[String] = Some(place)
scala> findOneResult applyPrism some[Event, Event] composeLens _title set("new title")
res32: Option[com.github.aoiroaoino.model.Event] = Some(Event(559f39e3d4c646b1d30589bd,new title,start,end,place,List(User(559f39e3d4c646b1d30589bb,Owner1,xxx), User(559f39e3d4c646b1d30589bc,Owner2,yyy)),List()))
scala> findOneResult applyPrism some[Event, Event] composeLens _owners composeTraversal each composeLens _name getAll
res33: List[String] = List(Owner1, Owner2)
すごーく冗長に感じてしまった方も多いかと思いますが、ご安心ください。
MonocleではapplyXXX
, composeXXX
に対してエイリアスも用意されているので、以下のように書くことも可能です。
findOneResult &<-? some[Event, Event] ^|-> _place getOption
findOneResult &<-? some[Event, Event] ^|-> _title set("new title")
findOneResult &<-? some[Event, Event] ^|-> _owners ^|->> each ^|-> _name getAll
いかがでしょうか?Lens
/Prism
の力によって、より柔軟にtoModel
/toDBObject
を操作できるようになりましたね。
アドホックにLens
やPrism
を合成できるので、findした結果 -> Prism
-> Lens
へのシームレスな連携が可能となりました!
さて、これがどういった時に美味しいのかというのをもう一例みてみましょう。
例えば、開発時に「Event
の全参加者の名前を大文字に変換したデータが欲しい」ってメソッドが欲しくなったとします。
このメソッドをLens
を使わずに書くと
def Lens使わずに全参加者の名前を大文字に(e: Event) = {
event.copy(attendees = event.attendees.map { attendee =>
val n = attendee.name.toUpperCase
attendee.copy(name = n)
})
}
割と素直で簡潔に書いたつもりですが、copy
とmap
が絡まってちょっと直感的でない印象ですね。
ですが、同様のメソッドをLens
を使って書くと
def Lens使って全参加者の名前を大文字に(e: Event) = {
event &|-> _attendees ^|->> each ^|-> _name modify(_.toUpperCase)
}
と書けます。すごいですね、実質一行です!
一応動作確認もしてみましょう。
scala> :paste
val owner = User(new ObjectId, "Owner", "xxx")
val attendee1 = User(new ObjectId, "attendee1", "yyy")
val attendee2 = User(new ObjectId, "attendee2", "zzz")
val event = Event(new ObjectId, "title", "start", "end", "place", List(owner), List(attendee1, attendee2))
scala> Lens使わずに全参加者の名前を大文字に(event) == Lens使って全参加者の名前を大文字に(event)
res16: Boolean = true
ちゃんと同じ結果になってますね!!
といった感じでcopy
からのmap
してcopy
なんて面倒なコードも書かなくて済むようになりました。
これでモデルを弄る側もハッピーです!!
まとめ
いかがでしたでしょうか?
マクロとMonocleを使ってモデルの定義と使用がラクになりましたね!
実際はもっと複雑でこんな簡単にはいかないもんで、悩まなきゃいけないことは山ほどあるかと思います。
toDBObject
/toModel
に引数で渡すのダサいからメソッドとして呼ぶような形式にしたくない?とか、
case class
のfield名をcamelCase
からsnake_case
に変換するようにしたくない?とか、
それはそれで色々考え出すととても面白いのですが、記事の長さと時間の都合上この辺にしたいと思います。
今回の記事を書きつつ、柔軟な構文と豊富な機能によって色々な選択肢があるところがまたScalaの楽しいところだなーと改めて思いました。
最終的なコードはこちらに公開しておきましたので参考になれば幸いです。
以上、あおいの(@AoiroAoino)がお送り致しました。次回の記事も乞うご期待!!