Play For Scala 開發指南 - 第10章 MongoDB 開發 頂 原 薦

爲什麼選擇 MongoDB?

在 Reactive 越來越流行的今天,傳統阻塞式的數據庫驅動已經無法滿足Reactive應用的需要了,爲此我們將目光轉向新誕生的數據庫新星 MongoDB 。MongoDB 從誕生以來就爭議不斷,總結一下主要有以下幾點:

  • Schemaless

  • 默認忽略錯誤

  • 默認關閉認證

  • 曾經的數據丟失問題

其實Schemaless不支持事務是技術選型時的決定,不應該受到吐槽,主要看是否滿足業務需求以及團隊的喜好,沒什麼可爭議的。至於默認忽略錯誤也是無稽之談,對於那些非關鍵數據,MongoDB爲你提供了一個Fire and Forget模式,可以顯著提高系統性能,並且幾乎所有的MongoDB驅動都默認關閉了這個模式,如果需要你可以手動打開。默認關閉認證並不是不支持認證,只是爲了方便快速原型,如果你敢在線上裸奔MongoDB,我只能默默地爲你點根蠟燭。數據丟失問題已經成爲歷史,曾經在網上廣爲流傳的兩篇關於MongoDB數據丟失問題(12), 經過分佈式系統安全性測試組織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不僅方便開發,而且容易維護,普通的開發人員利用自帶的mongodumpmongorestore命令便可進行備份、恢復操作。當然更重要的是,利用MongoDB的異步驅動以及ChangeStreams,我們可以開發高性能的實時應用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章