在开始阅读本文之前,请确保你熟悉Play-Json的相关开发,或是已经阅读过Play Scala 2.5.x - Play JSON开发指南。
1 为什么要Play with MongoDB?
在Reactive越来越流行的今天,传统阻塞式的数据库驱动已经无法满足Reactive应用的需要,为此我们将目光转向新诞生的数据库新星MongoDB。MongoDB从诞生以来就争议不断,总结一下主要有一下几点:
- Schemaless
- 不支持事务
- 默认忽略错误
- 默认关闭认证
- 会导致数据丢失
其实Schemaless
和不支持事务
是技术选型时的决定,不应该受到吐槽,主要看是否满足业务需求以及团队的喜好,没什么可争议的。至于默认忽略错误
也是无稽之谈,对于那些非关键数据,MongoDB为你提供了一个Fire and Forget
模式,可以显著提高系统性能,并且几乎所有的MongoDB驱动都默认关闭了这个模式,如果需要你可以手动打开。默认关闭认证
并不是不支持认证
,只是为了方便快速原型,如果你敢在线上裸奔MongoDB,我只能默默地为你点根蜡烛...。数据丢失
问题已经成为历史,曾经在网上广为流传的两篇关于MongoDB数据丢失问题(1, 2), 经过分布式系统安全性测试组织JEPSEN最新的测试分析表明,MongoDB 3.4.0已经解决了这些问题。
聊完争议,我们来看看MongoDB有哪些优点:
- 简单易用
- BSON格式数据统一前后台
- 异步数据库驱动
- 没有事务,所以高并发时仍能保持很好的读写性能
- Schemaless,方便快速原型
- 支持集群,MapReduce
- 支持GridFS,易用的分布式文件系统
- 通过oplog可以实现实时应用
其中异步数据库驱动
最为吸引人,也是本文关注的重点。其它的一些优点并非是MongoDB独有的,例如oplog,其它数据库也有相似的技术,例如mysql的binlog。
2 如何Play with MongoDB?
Reactive-Mongo
是一个基于Scala编写的异步非阻塞MongoDB驱动,该项目同时提供了Play框架的集成插件Play-ReactiveMongo。本文将基于Play-ReactiveMongo插件介绍MongoDB的开发技巧。
2.1 配置Play-ReactiveMongo插件
打开Play项目,修改build.sbt
添加Play-ReactiveMongo依赖:
libraryDependencies ++= Seq(
"org.reactivemongo" %% "play2-reactivemongo" % "0.11.14"
)
修改application.conf
,添加如下内容:
# 启用ReactiveMongoModule
play.modules.enabled += "play.modules.reactivemongo.ReactiveMongoModule"
# 配置数据库连接
mongodb.uri = "mongodb://someuser:somepasswd@localhost:27017/your_db_name"
OK,此时在命令行执行sbt compile
,sbt会自动下载Play-ReactiveMongo依赖,并完成编译过程。
2.2 开发示例
2.2.1 定义Model和Controller
在定义Model时最好显式声明_id
属性,因为该属性为MongoDB的默认主键,如果没有,在插入时会自动生成。下面代码定义了一个Person
类,以及用于完成Person
和JsObject
之间相互转换的隐式OFormat[Person]
对象personFormat
。
package models
case class Person(_id: String, name: String, age: Int)
object JsonFormats {
import play.api.libs.json.Json
// Generates Writes and Reads for Person, thanks to Json Macros
implicit val personFormat = Json.format[Person]
}
只要导入models.JsonFormats.personFormat
这个隐式对象,我们便可以在Person
和JsObject
实现双向转换:
import models.JsonFormats.personFormat
//JsObject -> Person
val jsObj = Json.obj("name" -> "joymufeng", "age" -> 31)
val p = jsObj.as[Person]
//Person -> JsObject
val newJsObj = Json.toJson(p)
Application
Controller混入了MongoController
,所以在Application
内可以直接使用MongoController
定义的方法和属性,例如database
。
import play.api.mvc.{ Action, Controller }
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.libs.json._
//导入ReactiveMongo插件
import play.modules.reactivemongo.{ MongoController, ReactiveMongoApi, ReactiveMongoComponents }
//导入BSON-JSON conversions/collection
import reactivemongo.play.json._
import reactivemongo.play.json.collection._
//导入隐式的format对象,用于JsObject <-> Person之间相互转换
import models.JsonFormats._
class Application @Inject() (val reactiveMongoApi: ReactiveMongoApi) extends Controller
with MongoController with ReactiveMongoComponents {
def personColFuture = database.map(_.collection[JSONCollection]("persons"))
...
}
请注意,personColFuture
是def
而不是val
,这样做的原因是为了适应Play框架的热加载功能。
2.2.2 插入操作
不同的修改操作会返回不同类型的WriteResult
,通过该类型的WriteResult
可以判断当前操作是否成功。JSONCollection.insert()
方法返回类型为Future[WriteResult]
类型,判断当前操作成功的条件是wr.ok && wr.n == 1
。
def testInsert(name: String, age: Int) = Action.async {
personColFuture.flatMap(_.insert(Person(name, name, age))).map{ wr: WriteResult =>
if (wr.ok && wr.n == 1) {
Ok("success")
} else {
Ok("fail")
}
}.recover{ case t: Throwable =>
Ok("error")
}
}
所有的操作都是异步的,即返回结果类型为Future[T],你需要熟悉这种开发模式。
WriteResult.ok
为true仅仅表明成功的读取了WriteResult响应,并不表示当前的操作一定执行成功了。
2.2.3 更新操作
JSONCollection.update()
方法返回Future[UpdateWriteResult]
,UpdateWriteResult.n
表示匹配条件的记录数量,UpdateWriteResult.nModified
表示真实被修改的记录数量(不包含更新值和原值相同的记录,因为这些记录其实并没有被修改),UpdateWriteResult.upserted
返回被upserted的记录_id列表。
def testUpdate(_id: String, newName: String) = Action.async {
personColFuture.flatMap(_.update(Json.obj("_id" -> _id), Json.obj("$set" -> Json.obj("name" -> newName)))).map{ uwr =>
if (uwr.ok && uwr.n == 1) {
Ok("success")
} else {
Ok("fail")
}
}.recover{ case t: Throwable =>
Ok("error")
}
}
MongoDB的update
操作支持更新文档或替换文档,如果更新文档的部分属性使用$set
操作符,例如上面的示例代码仅更新了name
属性。如果没有$set
操作符,则意味着是用当前的文档替换原文档,例如:
def update(_id: String, newName: String) = Action.async {
personColFuture.flatMap(_.update(Json.obj("_id" -> _id), Json.obj("name" -> newName))).map{ uwr =>
if (uwr.ok && uwr.n == 1) {
Ok("success")
} else {
Ok("fail")
}
}.recover{ case t: Throwable =>
Ok("error")
}
}
上面的代码将会把符合条件的文档更新为只剩一个name
属性的文档片段。
在使用
update
方法时,千万别忘记$set
操作符,否则会造成数据丢失。
2.2.4 查询操作
JSONCollection.find()
方法返回结果为GenericQueryBuilder
类型,该类型用于构建查询语句,调用其cursor
方法会触发查询请求并返回一个Cursor[T]
类型,通过迭代该Cursor[T]
我们可以收集查询结果。GenericQueryBuilder.one[T]
方法等价于GenericQueryBuilder.cursor[T]().headOption
。
def testRead(_id: String) = Action.async {
personColFuture.flatMap(_.find(Json.obj("_id" -> _id)).one[Person]).map{
case Some(p) => Ok("Find Person " + p.name)
case None => Ok("Person Not Found.")
}.recover{ case t: Throwable =>
Ok("error")
}
}
2.2.5 删除操作
JSONCollection.remove()
方法返回结果为Future[WriteResult]
类型,WriteResult.n
表示删除的记录数量。
def testDelete(_id: String) = Action.async {
personColFuture.flatMap(_.remove(Json.obj("_id" -> _id))).map{ wr =>
if (uwr.ok && uwr.n == 1) {
Ok("success")
} else {
Ok("fail")
}
}.recover{ case t: Throwable =>
Ok("error")
}
}
2.2.6 分页操作
这里使用GenericQueryBuilder.options()
方法设置分页信息,然后使用Cursor[T].collect[List]()
方法收集前15条查询结果。利用JSONCollection.count()
方法可以查询满足条件的记录总数。
def testPaging(page: Int) = Action.async {
for{
personCol <- personColFuture
list <- personCol.find(Json.obj())
.options(QueryOpts(skipN = page * 15, batchSizeN = 15))
.cursor[Person]()
.collect[List](15)
total <- personCol.count(Some(Json.obj()))
} yield {
Ok(s"Total: ${total}\r\n${list.map(_.name).mkString("\r\n")}")
}
}
2.2.7 批量插入
批量插入可以直接使用JSONCollection.bulkInsert
, 插入前需将List[Person]
转换成Documents
,返回类型为MultiBulkWriteResult
。MultiBulkWriteResult.n
表示成功插入的条数。
def testBulkInsert = Action.async {
val list = List(Person("0", "p0", 30), Person("1", "p1", 30))
personColFuture.flatMap{ personCol =>
//将List[Person]转换成待插入的Documents
val docs = list.map(implicitly[personCol.ImplicitlyDocumentProducer](_))
personCol.bulkInsert(false)(docs: _*).map{ mbwr: MultiBulkWriteResult =>
if(mbwr.ok && mbwr.n > 0){
Ok(s"成功插入${mbwr.n}条记录")
} else {
Ok(mbwr.toString)
}
}
}
}
2.2.8 FindAndModify
借助MongoDB提供的FindAndModify
方法,可以实现一个简单的消息队列或是任务领取功能,
def testFindAndModify = Action.async {
personColFuture.flatMap{ personCol =>
val selector = Json.obj()
val modifier = personCol.updateModifier(Json.obj("$set" -> Json.obj("age" -> 30)))
personCol.findAndModify(selector, modifier)
.map(_.result[Person]).map{
case Some(personBeforeUpdate) =>
Ok(s"Fetch Person ${personBeforeUpdate.name}")
case None =>
Ok("No Person Found.")
}
}
}
3 客户端工具选择
3.1 Studio 3T
Studio 3T是由3T Software Labs公司开发的MongoDB管理工具,非商业用途可以免费使用,如果是公司还是建议购买商业Licence。该工具基于Java开发,支持跨平台并且功能非常全面,例如在查询结果列表上可以直接进行编辑,Collections的复制粘贴和导入导出,用户角色和权限管理,是客户端管理的首选工具。
3.2 Robomongo
Robomongo前身是由Dmitry Schetnikovichk开发并维护的个人项目,目前已经被Studio 3T收购,并对外承诺永久免费使用。该工具基于Qt开发,支持跨平台,目前已经正式发布1.0版本。
4 小结
MongoDB自2009发布以来,产品和社区都已经非常成熟,已经有商业公司在云上提供MongoDB服务。除此之外,MongoDB不仅方便开发,而且容易维护,普通的开发人员利用自带的mongodump
和mongorestore
命令便可进行备份、恢复操作。当然最重要的是利用MongoDB的异步驱动和oplog可以开发高性能的实时应用,同时统一了前后端的数据结构,开发体验非常不错!最后再补充一句,如果对事务性要求较高,还是建议选择RDBMS。转载请注明作者joymufeng。