爲什麼選擇 MongoDB?
在 Reactive 越來越流行的今天,傳統阻塞式的數據庫驅動已經無法滿足Reactive應用的需要了,爲此我們將目光轉向新誕生的數據庫新星 MongoDB 。MongoDB 從誕生以來就爭議不斷,總結一下主要有以下幾點:
-
Schemaless
-
默認忽略錯誤
-
默認關閉認證
-
曾經的數據丟失問題
其實Schemaless
和不支持事務
是技術選型時的決定,不應該受到吐槽,主要看是否滿足業務需求以及團隊的喜好,沒什麼可爭議的。至於默認忽略錯誤
也是無稽之談,對於那些非關鍵數據,MongoDB爲你提供了一個Fire and Forget
模式,可以顯著提高系統性能,並且幾乎所有的MongoDB驅動都默認關閉了這個模式,如果需要你可以手動打開。默認關閉認證
並不是不支持認證
,只是爲了方便快速原型,如果你敢在線上裸奔MongoDB,我只能默默地爲你點根蠟燭。數據丟失
問題已經成爲歷史,曾經在網上廣爲流傳的兩篇關於MongoDB數據丟失問題(1, 2), 經過分佈式系統安全性測試組織JEPSEN最新的測試分析表明,MongoDB 3.4.0已經解決了這些問題。
聊完爭議,我們來看看MongoDB有哪些優點:
-
簡單易用
-
異步數據庫驅動
-
全棧Json,統一前後臺
-
半結構化數據結構,避免多表查詢,避免多文檔事務
-
基於單文檔的高性能原子操作
-
支持跨數據庫的多文檔事務
-
Schemaless,方便快速原型
-
支持集羣,MapReduce
-
支持GridFS,易用的分佈式文件系統
-
支持基於ChangeStream的實時應用
其中異步數據庫驅動
最爲吸引人,該技術是實現 Reactive 應用的基石。
如何進行 MongoDB 開發 ?
目前有如下三個基於 Scala 開發的 MongoDB 驅動可供選擇:
Mongo Scala Driver 是 MongoDB 官方維護的 Scala 驅動,該驅動底層基於官方的 Java 驅動,在此基礎上提供了一層很薄的 Scala 包裝。Mongo Scala Driver 提供了一套基於 Java 的 Bson Api,無法與 Play Json 集成。另外 Mongo Scala Driver 並沒有實現 Reactive Streams 規範,而是實現了一套與 Reactive Streams 類似的 Reactive Api,即 Observable, Subscription 和 Observer。另外 Mongo Scala Driver 的數據庫操作默認返回 Observable 類型,如果你忘記了調用 toFuture 方法,或是沒有消費返回數據,則數據庫操作實際上並不會被執行,在開發中很容易引入一些Bug。
ReactiveMongo 是 Play Framework 團隊成員私下維護的項目,似乎並沒有得到官方的支持。該項目基於 Akka 和 Netty 重新實現了 MongoDB 通信協議,並且基於 Scala 實現了一套原生的 Bson Api。該項目提供了一個 Play 模塊,實現了 Bson 和 Json 的自動轉換。ReactiveMongo 主要有三個問題,一是版本更新不夠及時,無法跟上 MongoDB 的更新節奏;二是可能存在安全隱患,容易造成生產事故,詳情參考:issue#721。三是語法過於繁瑣,向開發者暴露了太多細節,例如批量插入操作:
val docs = seq.map(c => implicitly[statChatCol.ImplicitlyDocumentProducer](c.toStatChat)) collection.bulkInsert(false)(docs: _*)
讓開發者編寫類似implicitly[statChatCol.ImplicitlyDocumentProducer]
這樣的代碼似乎不太合適。
由於 Reactive Mongo 的種種問題,最終誕生了 Play Mongo。Play Mongo 是由 PlayScala 社區爲 Play Framework 開發的 MongoDB 模塊, 該項目基於 MongoDB 官方的 Scala 驅動,並且提供了更多的實用功能,例如,
-
更簡潔多樣的數據庫交方式
-
自動識別模型類(Model),自動編解碼
-
自動完成 JsValue 和 BsonValue 互轉
-
更方便的 GridFS 交互
-
Change Stream 轉 Akka Stream.
-
支持關聯查詢(Relationship Query)
Play Mongo 基於官方驅動開發,可以爲開發者提供最佳的穩定性,並能及時跟進 MongoDB 的版本升級。另外 Play Mongo 不會過多關注底層驅動的實現細節,而是將關注點放在與 Play Framework 的集成上,可以爲開發者提供更舒適的開發體驗。本文將採用 Play Mongo 講述 MongoDB 的開發細節。
Play Mongo 開發入門
Play Mongo 只是爲我們提供了數據訪問層,我們還需要基於訪問層構建模型層。關於模型層的設計,我們可以選擇貧血模型、充血模型以及應對複雜業務的領域模型。關於模型層的設計,我們將會在“第四部分 Play 框架開發實戰”中繼續討論。爲了方便闡述,我們這裏選擇最簡單的貧血模型,即模型層只包含數據,不包含任何的業務邏輯實現。
添加依賴
打開 Play 項目,編輯 build.sbt
,添加如下依賴,
libraryDependencies += "cn.playscala" % "play-mongo_2.12" % "0.3.0" addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)
打開 conf/application.conf
, 添加數據庫連接,
mongodb.uri = "mongodb://user:password@host:port/dbName?authMode=scram-sha1"
定義模型層
我們建議在定義 Model 類時要顯式聲明 _id 屬性,該屬性爲 MongoDB 的默認主鍵,如果沒有,在插入時會自動生成。下面代碼定義了一個 Person 類:
package models @Entity("common-person") case class Person(_id: String, name: String, age: Int)
@Entity 註解參數用於指定關聯的 mongodb collection 名稱, 如果未指定,則默認爲 Model 類名稱。 作爲約定,Model 類使用 _id 字段作爲唯一標識, 該字段同時也是 mongodb collection 的默認主鍵。
模型層編解碼
在應用啓動時指定模型層(models)的包路徑,編輯app/Module
類,
class Module extends AbstractModule { override def configure() = { Mongo.setModelsPackage("models") } }
Mongo.setModelsPackage方法將會查找指定包路徑下的所有Case Class,自動生成驅動所需的編解碼器。需要注意的是,這些編解碼器是驅動私有的,外界無法共享。我們仍然需要定義全局共享的隱式 Format 對象:
import play.api.libs.json.Format package object models { implicit val personFormat = Json.format[Person] }
如果有很多的 Case Class,則需要逐個定義,編寫起來還是挺麻煩的。我們可以使用 @JsonFormat 宏註解,通過一行代碼爲所有 Case Class 生成相應的隱式 Format 對象:
import cn.playscala.mongo.codecs.macrocodecs.JsonFormat package object models { @JsonFormat("models") implicit val formats = ??? }
由於這些隱式的 Format 對象是在模型層的包對象(package object)中創建的,所以使用時無需顯式導入,編譯器會自動加載。
依賴注入
至此,我們便可以將 Mongo 實例注入到任意需要的地方:
@Singleton class Application @Inject()(cc: ControllerComponents, mongo: Mongo) extends AbstractController(cc) {}
模型類和Collection
模型類使用 @Entity 註解標註, 一個模型類實例表示 mongodb collection 中的一個文檔, 一個 mongodb collection 在概念上類似於關係數據庫的一張表。
@Entity("common-user") case class User(_id: String, name: String, password: String, addTime: Instant)
@Entity 註解參數用於指定關聯的 mongodb collection 名稱, 如果未指定,則默認爲 Model 類名稱。 作爲約定,模型類使用 _id 字段作爲唯一標識, 該字段同時也是 mongodb collection 的默認主鍵。
我們可以通過兩種方式訪問 mongodb collection, 第一種方式是使用模型類,
mongo.find[User]().list().map{ users => ... }
這裏的參數類型 User 不僅用於指定關聯的 mongodb collection, 而且用於指明返回的結果類型。 這意味着查詢操作將會在 common-user collection 上執行, 並且返回的結果類型是 User。 需要注意的是,在該方式下無法改變返回的結果類型。
第二種方式是使用 mongo.collection 方法,
mongo.collection("common-user").find[User]().list().map{ users => }
在這裏, find 方法上的參數類型 User 僅僅用於指定返回的結果類型, 我們可以通過更改該參數類型設置不同的返回結果類型,
mongo.collection("common-user").find[JsObject]().list().map{ jsObjList => } mongo.collection("common-user").find[User](Json.obj("userType" -> "common")).list().map{ commonUsers => }
當然,我們也可以使用 model 類指定關聯的 mongodb collection,
mongo.collection[User].find[User]().list().map{ user => }
第1個參數類型 User 用於指定關聯的 mongodb collection, 第2個參數類型 User 用於指定返回的結果類型。 我們仍然可以通過改變第2個參數類型從而改變返回的結果類型。
常見操作
以下示例代碼默認執行了 import play.api.libs.json.Json._
導入, 所以 Json.obj()
可以被簡寫爲 obj()
。
創建操作
// 插入 Model mongo.insert[User](User("0", "joymufeng", "123456", Instant.now)) // 插入 Json val jsObj = obj("_id" -> "0", "name" -> "joymufeng", "password" -> "123456", "addTime" -> Instant.now) mongo.collection[User].insert(jsObj) mongo.collection("common-user").insert(jsObj)
更新操作
mongo.updateById[User]("0", obj("$set" -> obj("password" -> "123321"))) mongo.updateOne[User](obj("_id" -> "0"), obj("$set" -> obj("password" -> "123321"))) mongo.collection[User].updateById("0", obj("$set" -> obj("password" -> "123321"))) mongo.collection[User].updateOne(obj("_id" -> "0"), obj("$set" -> obj("password" -> "123321"))) mongo.collection("common-user").updateById("0", obj("$set" -> obj("password" -> "123321"))) mongo.collection("common-user").updateOne(obj("_id" -> "0"), obj("$set" -> obj("password" -> "123321")))
查詢操作
mongo.findById[User]("0") // Future[Option[User]] mongo.find[User](obj("_id" -> "0")).first // Future[Option[User]] mongo.collection[User].findById[User]("0") // Future[Option[User]] mongo.collection[User].find[User](obj("_id" -> "0")).first // Future[Option[User]] mongo.collection[User].findById[JsObject]("0") // Future[Option[JsObject]] mongo.collection[User].find[JsObject](obj("_id" -> "0")).first // Future[Option[JsObject]] mongo.collection("common-user").findById[User]("0") // Future[Option[User]] mongo.collection("common-user").find[User](obj("_id" -> "0")).first // Future[Option[User]] mongo.collection("common-user").findById[JsObject]("0") // Future[Option[JsObject]] mongo.collection("common-user").find[JsObject](obj("_id" -> "0")).first // Future[Option[JsObject]]
刪除操作
mongo.deleteById[User]("0") mongo.deleteOne[User](obj("_id" -> "0")) mongo.collection[User].deleteById("0") mongo.collection[User].deleteOne(obj("_id" -> "0")) mongo.collection("common-user").deleteById("0") mongo.collection("common-user").deleteOne(obj("_id" -> "0"))
上傳和下載文件
// Upload and get the fileId mongo.gridFSBucket.uploadFromFile("image.jpg", "image/jpg", new File("./image.jpg")).map{ fileId => Ok(fileId) } // Download file by fileId mongo.gridFSBucket.findById("5b1183fed3ba643a3826325f").map{ case Some(file) => Ok.chunked(file.stream.toSource) .as(file.getContentType) case None => NotFound }
Change Stream
我們可以通過 toSource
方法將 Change Stream 轉換成 Akka Source,之後便會有趣很多。例如下面的代碼擁有如下幾個功能:
-
將從 Change Stream 接收到的元素進行緩衝,以方便批處理,當滿足其中一個條件時便結束緩衝向後傳遞:
-
緩衝滿10個元素
-
緩衝時間超過了1000毫秒
-
-
對緩衝後的元素進行流控,每秒只允許通過1個元素
mongo .collection[User] .watch() .fullDocument .toSource .groupedWithin(10, 1000.millis) .throttle(elements = 1, per = 1.second, maximumBurst = 1, ThrottleMode.shaping) .runForeach{ seq => // ... }
關聯查詢操作
@Entity("common-article") case class Article(_id: String, title: String, content: String, authorId: String) @Entity("common-author") case class Author(_id: String, name: String) mongo.find[Article].fetch[Author]("authorId").list().map{ _.map{ t => val (article, author) = t } }
對於滿足查詢條件的每一個 article , 將會根據匹配條件article.authorId == author._id
拉取關聯的 author。
小結
MongoDB自2009發佈以來,產品和社區都已經非常成熟,已經有商業公司在雲上提供MongoDB服務。除此之外,MongoDB不僅方便開發,而且容易維護,普通的開發人員利用自帶的mongodump
和mongorestore
命令便可進行備份、恢復操作。當然更重要的是,利用MongoDB的異步驅動以及ChangeStreams,我們可以開發高性能的實時應用。