ども。プロダクトグループ所属エンジニアの、あおいの(@AoiroAoino)です。
先月25歳になりましたが、見た目年齢は30代らしいです。よろしくお願いしますmm
弊社技術ブログ一発目の記事担当になってしまったので頑張ります!
さてさて、記念すべき一発目はMongoDBとScalaの組み合わせについて書こうかなと思います。
テーマは「モデルを定義/使用する人がラクできるようにする」です。
前置き
とりあえず何も無いのもアレなので、「イベントホスティングサービスを開発する」という妄想で話を進めていきます。
モデル部分のデータ構造はざっとこんな感じで。
// Event.scala case class Event ( _id: ObjectId, title: String, start: String, end: String, place: String, owners: List[User], attendees: List[User] )
// User.scala 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
メソッドを用意していきます。
// ModelMapper.scala trait ModelMapper[A] { // Model -> DBObject def toDBObject(model: A): DBObject // DBObject -> Option[Model] def toModel(obj: DBObject): Option[A] }
// User.scala 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) // => com.mongodb.DBObject = { "_id" : { "$oid" : "559e6298ce242b655aa45de1"} , "name" : "Aoino" , "phone" : "xxx-xxx-xxx"} // 変換成功例 scala> val model = User.toModel(obj) // => Option[com.github.aoiroaoino.model.User] = Some(User(559e6298ce242b655aa45de1,Aoino,xxx-xxx-xxx)) // 変換成功したmodelからnameの取得 scala> model.map(_.name) // => Option[String] = Some(Aoino) // DBObjectが想定してる構造で無かった場合 scala> User.toModel(DBObject.empty) // => Option[com.github.aoiroaoino.model.User] = None
良さそうですね!
これでも十分に開発を進めることができるかと思います。
さて、ここからがいよいよ本番です!!
マクロを使ってモデル定義時にラクをする
さて、このままだと新たなモデルが増えるたびにtoDBObject/toModelの定義をしなければいけません。
私は貧弱Vimmerなので、手元のVimにはIDEほどの入力補完もなければ長年蓄積されたスニペット資産もないのでとても面倒です。
なので、面倒なお仕事はコンパイラに押し付けてしまおう!ってことでマクロを使ってみることにします。1
まず最初にマクロを呼び出す側のコードを書いてしまいます。
ModelMapper
をcase class
から得られる情報で自動的に構築するので、とりあえずAutoModelMapper
って名前にしました。
// AutoModelMapper.scala object AutoModelMapper { def apply[A]: ModelMapper[A] = macro AutoModelMapperImpl.autoModelMapper_impl[A] }
次に、呼び出されるマクロを実装します。
// AutoModelMapper.scala 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 // Watch out for apply12 problem 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
に渡すためのタプルを作ります。
// toDBObject作るための準備 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
// toModel作るための準備 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
の中身を以下のように書き換えます。
// User.scala object User { // toDBObject/toModelが実装されたModelMapper[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では以下のようにして定義します。
// EventOptics.scala trait EventLens { // GenLensマクロを使用 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
を入れてる感(?)が伝わってきますね。
// 手動でLensを定義 // getterとsetterを引数に渡して、titleに対するLensを定義する 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
の関係にあたるのが見てとれるかと思います。
// EventOptics.scala 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()) // MongoDBでfindOneした結果を想定 val findOneResult: Option[Event] = Some(event)
上記サンプルデータを使ってデータを操作してみましょう。5
// 使用例 // Eventの開催場所を取得 scala> findOneResult applyPrism some[Event, Event] composeLens _place getOption res31: Option[String] = Some(place) // Eventのタイトルを変更 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())) // Event主催者の名前一覧を取得 scala> findOneResult applyPrism some[Event, Event] composeLens _owners composeTraversal each composeLens _name getAll res33: List[String] = List(Owner1, Owner2)
すごーく冗長に感じてしまった方も多いかと思いますが、ご安心ください。
MonocleではapplyXXX
, composeXXX
に対してエイリアスも用意されているので、以下のように書くことも可能です。
// エイリアスメソッドを使う // Eventの開催場所を取得 findOneResult &<-? some[Event, Event] ^|-> _place getOption // Eventのタイトルを変更 findOneResult &<-? some[Event, Event] ^|-> _title set("new title") // Eventオーナーの名前一覧を取得 findOneResult &<-? some[Event, Event] ^|-> _owners ^|->> each ^|-> _name getAll
いかがでしょうか?Lens
/Prism
の力によって、より柔軟にtoModel
/toDBObject
を操作できるようになりましたね。
アドホックにLens
やPrism
を合成できるので、findした結果 -> Prism
-> Lens
へのシームレスな連携が可能となりました!
さて、これがどういった時に美味しいのかというのをもう一例みてみましょう。
例えば、開発時に「Event
の全参加者の名前を大文字に変換したデータが欲しい」ってメソッドが欲しくなったとします。
このメソッドをLens
を使わずに書くと
// Lens使わない版 def Lens使わずに全参加者の名前を大文字に(e: Event) = { event.copy(attendees = event.attendees.map { attendee => val n = attendee.name.toUpperCase attendee.copy(name = n) }) }
割と素直で簡潔に書いたつもりですが、copy
とmap
が絡まってちょっと直感的でない印象ですね。
ですが、同様のメソッドをLens
を使って書くと
// Lensを使う版 def Lens使って全参加者の名前を大文字に(e: Event) = { event &|-> _attendees ^|->> each ^|-> _name modify(_.toUpperCase) }
と書けます。すごいですね、実質一行です!
一応動作確認もしてみましょう。
// 動作確認 scala> :paste // Entering paste mode (ctrl-D to finish) 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)) // Exiting paste mode, now interpreting. scala> Lens使わずに全参加者の名前を大文字に(event) == Lens使って全参加者の名前を大文字に(event) res16: Boolean = true
ちゃんと同じ結果になってますね!!
といった感じでcopy
からのmap
してcopy
なんて面倒なコードも書かなくて済むようになりました。
これでモデルを弄る側もハッピーです!!
まとめ
いかがでしたでしょうか?
マクロとMonocleを使ってモデルの定義と使用がラクになりましたね!
実際はもっと複雑でこんな簡単にはいかないもんで、悩まなきゃいけないことは山ほどあるかと思います。
toDBObject
/toModel
に引数で渡すのダサいからメソッドとして呼ぶような形式にしたくない?とか、
case class
のfield名をcamelCase
からsnake_case
に変換するようにしたくない?とか、
それはそれで色々考え出すととても面白いのですが、記事の長さと時間の都合上この辺にしたいと思います。
今回の記事を書きつつ、柔軟な構文と豊富な機能によって色々な選択肢があるところがまたScalaの楽しいところだなーと改めて思いました。 最終的なコードはこちらに公開しておきましたので参考になれば幸いです。
以上、あおいの(@AoiroAoino)がお送り致しました。次回の記事も乞うご期待!!