ScalaでMongoDBモデル操作をラクにする

ども。プロダクトグループ所属エンジニアの、あおいの(@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の相互変換をラクにするため、
toModeltoDBObjectメソッドを用意していきます。

// 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

まず最初にマクロを呼び出す側のコードを書いてしまいます。
ModelMappercase 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氏が開発したScalaOptics(Lens, Prismなど)な概念が使えるようになるライブラリ」です。
んじゃそもそもLens, Prismとは何かという話になりますが、ここでは簡単な使い方のみご紹介します。4

Lensは誤解を恐れずに言うと、composableな抽象化されたgetter/setterです。
まずはEventに対してLensPrismを定義してしょう。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マクロを使用しましたが、マクロを使用せずに定義すると以下のようになります。
こっちの方がcomposableLensって入れ物に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 => AA => Option[S]がまさにtoModeltoDBObjectの関係にあたるのが見てとれるかと思います。

// EventOptics.scala
trait EventPrism {

  def _event = Prism[DBObject, Event] {
    case obj: DBObject => Event.toModel(obj)
    case _             => None
  }(Event.toDBObject _)
}

さーて、ここまでだいぶ長い道のりでしたが、LensPrismを定義することで魔法のような操作が可能になります。

// サンプルデータ
// サンプルデータを適当に用意
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を操作できるようになりましたね。
アドホックLensPrismを合成できるので、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)
  })
}

割と素直で簡潔に書いたつもりですが、copymapが絡まってちょっと直感的でない印象ですね。
ですが、同様のメソッドを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)がお送り致しました。次回の記事も乞うご期待!!


  1. あまりいい理由では無いですね!

  2. 本当は温かみのある手作業時と同様にfor式で組み立てたかったのですが、上手く実装できなかったのでScalazのApplyを使用しました…

  3. 残念ながら、今回作成したマクロはあまり汎用的ではありません。case classのfield名とDBObjectのfield名が一致している必要がある等の制限があり、課題は山積みです:-(

  4. 気になる方は手前味噌で申し訳ないですが、こちらの記事をご覧ください。

  5. REPLで試す際にはScala型推論の都合上、findOneResultsomeで明示的に型を指定する必要がありますので注意…