MongoDB JVM 開發庫使用簡介

ImportNew發表於2015-05-19

當儲存基於文件的 JSON 資料的時候,MongoDB 是我最喜歡的資料庫。基於 JVM 的語言在與 MongoDB 互動上有很多種選擇。我覺得拿四個最流行的解決方案並且都實現一個用例,對我來說不失為一個好的練習。用例:建立一個可以獲取一個城市和距其最近的城市的列表的 REST 服務。

我要比較的四個選擇是:標準的MongoDB Java Driver、Jongo、Monphia和Spring Data Mongo。為了簡潔,我是用 groovy 完成程式碼,並且使用 Spring Boot 以減少樣板程式碼。

基礎配置

Spring Boot 應用的程式碼非常簡潔,如下:

import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.EnableAutoConfiguration
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
@EnableAutoConfiguration
@ComponentScan
@Configuration
class MongoComparison
{
    static void main(String[] args) {
        SpringApplication.run(MongoComparison, args);
    }
}

同時,我也提供了此次對比所使用的Gradle構建檔案:

buildscript {
    repositories {
        jcenter()
        maven {
            url 'http://repo.spring.io/milestone'
        }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:1.1.9.RELEASE")
    }
}
apply plugin: 'groovy'
apply plugin: 'spring-boot'
repositories {
    jcenter()
    maven { url 'http://repo.spring.io/milestone' }
    maven { url 'http://www.allanbank.com/repo/' }
}
dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-data-mongodb")
    compile("org.jongo:jongo:1.1")
    compile("org.mongodb.morphia:morphia:0.108")
    compile("de.grundid.opendatalab:geojson-jackson:1.2")
    compile("org.codehaus.groovy:groovy-all:2.3.6")
 }
task wrapper(type: Wrapper) {
    gradleVersion = '2.1'
}

因為我使用了 Spring Boot 和 Spring Data MongoDB 框架,一些配置可以忽略。例如,Spring Boot 框架在為 Spring Boot 的應用程式上下文提供了 MongoClient bean 和 MongoTemplate bean。你無需在你的配置檔案中額外配置(我是用的是 YAML 風格的配置)。

spring:
    groovy:
        template:
            check-template-location: false
    data:
        mongodb:
            host: "localhost"
            database: "citydbdata"

基本框架完成之後,我們可以開始對比。

MongoDB Java驅動

因為所有的連線 MongoDB 的程式,都用到了 MongoDB 原生的 Java 驅動,所以我覺得從 MongoDB Java Driver (下稱 Java Driver)開始最合適。Java Driver 是 JVM 上使用 MongoDB 的最底層的途徑。也就是說,寫出的程式會略顯冗長,並且API不如其他的更加使用者友好。然而,你使用Java Driver能夠實現所有的功能。Java Driver 在 Spring Data MongoDB 裡已經自動引入,如果你需要單獨使用的話,需要引入相應的依賴。

這是使用Java Driver實現的程式碼:

import com.mongodb.*
import org.bson.types.ObjectId
import org.geojson.Point
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.*
import org.springframework.web.bind.annotation.*
import javax.annotation.PostConstruct
import static org.springframework.web.bind.annotation.RequestMethod.GET
@RestController
@RequestMapping("/mongoclient")
class CityControllerMongoClient {
    final DB db
    def dbObjectToCityTransformer = { DBObject it ->
        def objectMap = it.toMap()
        return new City(_id: objectMap._id, name: objectMap.name, location: new Point(objectMap.location.coordinates[0], objectMap.location.coordinates[1]))
    }
    @Autowired
    CityControllerMongoClient(MongoClient mongoClient) {
        db = mongoClient.getDB("citydbmongoclient")
    }
    @RequestMapping(value="/", method = GET)
    List<City> index() {
        return db.getCollection("city").find().collect(dbObjectToCityTransformer)
    }
    @RequestMapping(value="/near/{cityName}", method = GET)
    ResponseEntity nearCity(@PathVariable String cityName) {
        def city = dbObjectToCityTransformer(db.getCollection("city").findOne(new BasicDBObject("name", cityName)))
        if(city) {
            def point = new BasicDBObject([type: "Point", coordinates: [city.location.coordinates.longitude, city.location.coordinates.latitude]])
            def geoNearCommand =  new BasicDBObject([geoNear: "city", spherical: true, near: point])
            def closestCities = db.command(geoNearCommand).toMap()
            def closest = closestCities.results[1]
            return new ResponseEntity([name:closest.obj.name, distance:closest.dis/1000], HttpStatus.OK)
        }
        else {
            return new ResponseEntity(HttpStatus.NOT_FOUND)
        }
    }
    @PostConstruct
    void populateCities() {
        db.getCollection("city").drop()
        [new City(name: "London",
                location: new Point(-0.125487, 51.508515)),
         new City(name: "Paris",
                 location: new Point(2.352222, 48.856614)),
         new City(name: "New York",
                 location: new Point(-74.005973, 40.714353)),
         new City(name: "San Francisco",
                 location: new Point(-122.419416, 37.774929))].each {
            DBObject location = new BasicDBObject([type: "Point", coordinates: [it.location.coordinates.longitude, it.location.coordinates.latitude]])
            DBObject city = new BasicDBObject([name: it.name, location: location])
            db.getCollection("city").insert(city)
        }
        db.getCollection("city").createIndex(new BasicDBObject("location", "2dsphere"))
    }
    static class City {
        ObjectId _id
        String name
        Point location
    }
}

Java Driver 整體以 DBObject 為中心,你需要一直提供領域物件和DBObject之間的對映。Java Driver沒有提供任何形式的物件對映。幸運的是, DBObject 的結構很像 map,並且 Groovy Map 的簡潔的風格讓其操作起來方便許多。本例中,要找到距某城市最近的城市以及其最短距離時,需要用到 geoNear 命令,你可能需要從 mongoDB 的手冊找到其詳細的語法。語法縮略如下:

{
   geoNear: collectionName,
   near: { type: "Point" , coordinates: [ longitude, latitude ] } ,
   spherical: true
}

geoNear 命令會返回集合中距離最近的物件,並且提供一個欄位來標識他們之間的距離;距離的單位是米。 geoNear 命令中的near欄位的格式有兩種,一種是上面程式碼示例,另一種是更傳統的2個 double 值組成的陣列。因為前一種符合 GeoJSON 的標準,我更推薦這種方式。在我所有的例子中,我儘量都是用 GeoJSON 記法來儲存地理位置資訊資料。從程式碼裡能看出來,我使用了一個提供了所有 GeoJSON 型別支援的 Java 類庫。

撇開所有 DBObject 到領域物件的約定,例子中的程式碼都非常易讀。當然你需要知道 MongoDB 查詢的細節;然而當你瞭解了之後,Java Driver 就是一個非常強大的工具。

Jongo

Jongo 框架支援基於字串的互動和查詢(查詢時不需要建立 DBObject ),因此允許你使用接近於 Mongo Shell 的方式與 MongoDB 例項進行互動。Jongo 使用 Jackson 框架來完成物件對映,所以無需將查詢結果和想插入的資料轉換為 DBObject 例項。我在使用的 GeoJSON 庫內建了Jackson 的支援,對此,我們無需為此多編寫程式碼。

Jongo的用例的程式碼如下:

import com.fasterxml.jackson.databind.ObjectMapper
import com.mongodb.MongoClient
import org.bson.types.ObjectId
import org.geojson.Point
import org.jongo.Jongo
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.*
import org.springframework.web.bind.annotation.*
import javax.annotation.PostConstruct
import static org.springframework.web.bind.annotation.RequestMethod.GET
@RestController
@RequestMapping("/jongo")
class CityControllerJongo {
    final Jongo jongo
    @Autowired
    CityControllerJongo(MongoClient mongoClient) {
        jongo = new Jongo(mongoClient.getDB("citydbjongo"))
    }
    @RequestMapping(value="/", method = GET)
    List<City> index() {
        return jongo.getCollection("city").find().as(City).asList()
    }
    @RequestMapping(value="/near/{cityName}", method = GET)
    ResponseEntity nearCity(@PathVariable String cityName) {
        def city = jongo.getCollection("city").findOne("{name:'$cityName'}").as(City)
        if(city) {
            def command = """{
                geoNear: "city",
                near: ${new ObjectMapper().writeValueAsString(city.location)},
                spherical: true
            }"""
            def closestCities = jongo.runCommand(command).as(GeoNearResult) as GeoNearResult<City>
            def closest = closestCities.results[1]
            return new ResponseEntity([name:closest.obj.name, distance:closest.dis/1000], HttpStatus.OK)
        }
        else {
            return new ResponseEntity(HttpStatus.NOT_FOUND)
        }
    }
    @PostConstruct
    void populateCities() {
        jongo.getCollection("city").drop()
        [ new City( name:"London",
                location: new Point(-0.125487, 51.508515)),
          new City( name:"Paris",
                  location: new Point(2.352222, 48.856614)),
          new City( name:"New York",
                  location: new Point(-74.005973, 40.714353)),
          new City( name:"San Francisco",
                  location: new Point(-122.419416, 37.774929)) ].each {
            jongo.getCollection("city").save(it)
        }
        jongo.getCollection("city").ensureIndex("{location:'2dsphere'}")
    }
    static class GeoNearResult<O> {
        List<GeoNearItem<O>> results
    }
    static class GeoNearItem<O> {
        Double dis
        O obj
    }
    static class City {
        ObjectId _id
        String name
        Point location
    }
}

從例子中可以看出,Jongo 更面向字串,尤其是使用 GeoNear 命令查詢的時候。同時,多虧 Jackson 框架,我們查詢和插入時,不用編寫任何的轉換的程式碼。

如果你是先接觸到MongoDB,熟悉shell命令並且不想做手工對映的話,Jongo是非常便捷的。但是,你需要去了解Mongo Shell API的確切語法;同時,你在構造查詢、編寫命令式沒有自動的程式碼補全,如果你覺得這樣是可以接受的話,Jongo是一個不錯的選擇。

Morphia

MongoDB 的開發者(因為Trisha Gee,我不能說漢子們)為MongoDB量身定做了一個對映框架。 Morphia是一個註解驅動的框架,也就是說為了使用 Morphia ,你得使用註解來註釋你的 POJO (儘管如此,你可以不寫註解以使用預設的註解)。 Morphia 支援 MongoDB 的大部分函式,遺憾的是沒有對 GeoJSON 提供支援從而也不支援 geoNear。MongoDB 的開發者專注的開發 MongoDB Java Driver 3.0,有些忽略 Morphia。 可能會在未來的版本中提供對 GeoJSON 的支援。

因為我用到了 geoNear 函式,除了把Java Driver的測試用例中的程式碼中的拿來複用也沒有更好的選項了。如下是用 Morphia 實現的用例:

import com.mongodb.*
import org.bson.types.ObjectId
import org.geojson.Point
import org.mongodb.morphia.*
import org.mongodb.morphia.annotations.*
import org.mongodb.morphia.converters.TypeConverter
import org.mongodb.morphia.mapping.MappedField
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.*
import org.springframework.web.bind.annotation.*
import javax.annotation.PostConstruct
import static org.springframework.web.bind.annotation.RequestMethod.GET
@RestController
@RequestMapping("/mongomorphia")
class CityControllerMorphia {
    final Datastore datastore
    @Autowired
    CityControllerMorphia(MongoClient mongoClient) {
        def morphia = new Morphia()
        morphia.mapper.converters.addConverter(GeoJsonPointTypeConverter)
        datastore = morphia.createDatastore(mongoClient, "citymorphia")
    }
    @RequestMapping(value="/", method = GET)
    List<City> index() {
        return datastore.find(City).asList()
    }
    @RequestMapping(value="/near/{cityName}", method = GET)
    ResponseEntity nearCity(@PathVariable String cityName) {
        def city = datastore.find(City, "name", cityName).get()
        if(city) {
            def point = new BasicDBObject([type: "Point", coordinates: [city.location.coordinates.longitude, city.location.coordinates.latitude]])
            def geoNearCommand =  new BasicDBObject([geoNear: "City", spherical: true, near: point])
            def closestCities = datastore.DB.command(geoNearCommand).toMap()
            def closest = (closestCities.results as List<Map>).get(1)
            return new ResponseEntity([name:closest.obj.name, distance:closest.dis/1000], HttpStatus.OK)
        }
        else {
            return new ResponseEntity(HttpStatus.NOT_FOUND)
        }
    }
    @PostConstruct
    void populateCities() {
        datastore.delete(datastore.createQuery(City))
        [new City(name: "London",
                location: new Point(-0.125487, 51.508515)),
         new City(name: "Paris",
                 location: new Point(2.352222, 48.856614)),
         new City(name: "New York",
                 location: new Point(-74.005973, 40.714353)),
         new City(name: "San Francisco",
                 location: new Point(-122.419416, 37.774929))].each {
            datastore.save(it)
        }
        datastore.getCollection(City).createIndex(new BasicDBObject("location", "2dsphere"))
    }
    @Entity
    static class City {
        @Id
        ObjectId id
        String name
        Point location
    }
    static class GeoJsonPointTypeConverter extends TypeConverter {
        GeoJsonPointTypeConverter() {
            super(Point)
        }
        @Override
        Object decode(Class<?> targetClass, Object fromDBObject, MappedField optionalExtraInfo) {
            double[] coordinates = (fromDBObject as DBObject).get("coordinates")
            return new Point(coordinates[0], coordinates[1])
        }
        @Override
        Object encode(Object value, MappedField optionalExtraInfo) {
            def point = value as Point
            return new BasicDBObject([type:"Point", coordinates:[point.coordinates.longitude, point.coordinates.latitude]])
        }
    }
}

因為 Morphia 框架沒有對 GeoJSON 提供支援,所以,你要麼使用傳統的用包含兩個座標的 double 型別的陣列,要麼寫你自己的轉換器。我選擇了後者,畢竟也不是那麼難寫。不要忘了把你的轉換器加入到 Morphia 中。從程式碼中可以看出,我已經使用 Morphia 的註解註釋了 City 類,對於那些熟悉 JPA 的開發者來說,這種方式直截了當。同時,因為 Morphia 不提供 2dsphere index 查詢支援,你要自己建立 2dsphere  索引。

針對MongoDB的Spring Data

最後但同樣重要的是 Spring Data,我在研究如何用它完成這個用例。如果你熟知 Spring Data 框架的話,你需要寫與資料儲存互動的庫介面,使用方法名來指定你需要使用的查詢。在此例中,我們只需要兩個查詢:根據名字查詢城市,找到距某城市最近的城市。Spring Data 框架支援 geospatial 查詢(地理空間查詢)。

Spring Data 框架有用來表示地理空間座標的類,但是和 GeoJSON 不相容(再提一遍:Spring 有其自有的方式)。所幸 Spring Data 能夠自動生成索引並且 mongo 也能夠處理 Spring Data 使用的座標表示方式。

這是針對Morphia的實現:譯註:我認為是原文錯誤

import org.bson.types.ObjectId
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.geo.*
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.index.*
import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import javax.annotation.PostConstruct
import static org.springframework.web.bind.annotation.RequestMethod.GET
@RestController
@RequestMapping("/mongodata")
class CityControllerMongoData {
    final CityRepository cityRepository
    @Autowired
    CityControllerMongoData(CityRepository cityRepository) {
        this.cityRepository = cityRepository
    }
    @RequestMapping(value="/", method = GET)
    List<City> index() {
        return cityRepository.findAll()
    }
    @RequestMapping(value="/near/{cityName}", method = GET)
    ResponseEntity nearCity(@PathVariable String cityName) {
        def city = cityRepository.findByName(cityName)
        if(city) {
            GeoResults<City> closestCities = cityRepository.findByLocationNear(city.location, new Distance(10000, Metrics.KILOMETERS))
            def closest = closestCities.content.get(1)
            return new ResponseEntity([name:closest.content.name, distance:closest.distance.in(Metrics.KILOMETERS).value], HttpStatus.OK)
        }
        else {
            return new ResponseEntity(HttpStatus.NOT_FOUND)
        }
    }
    @PostConstruct
    void populateCities() {
        cityRepository.deleteAll()
        [ new City( name:"London",
                location: new Point(-0.125487, 51.508515)),
          new City( name:"Paris",
                  location: new Point(2.352222, 48.856614)),
          new City( name:"New York",
                  location: new Point(-74.005973, 40.714353)),
          new City( name:"San Francisco",
                  location: new Point(-122.419416, 37.774929)) ].each {
            cityRepository.save(it)
        }
    }
    @Document(collection = "city")
    static class City {
        ObjectId id
        String name
        @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
        Point location
    }
}
interface CityRepository extends MongoRepository<CityControllerMongoData.City, ObjectId> {
    CityControllerMongoData.City findByName(String name);
    GeoResults<CityControllerMongoData.City> findByLocationNear(Point point, Distance distance);
}

就可讀性來說,Spring Data 無疑是最好的。你不需要知道 MongoDB 裡的查詢語句是如何構建的,你只需要使用庫中的命名慣例就好。當你使用2dsphere 索引的時候,要記住一點,就是使用 near query 方法時要加入 distance 引數,不然 Spring Data 在查詢 MongoDB 時會忽略 sphere 選項(在此情況下會失敗?報錯)。如果你不需要距離,你可以把命令的返回值城市物件的列表。你不用實現相應的介面,Spring Data 框架會替你實現。

使用Spring Data MongoDB 框架的時候,你也可以使用 MongoTemplate 類。MongoTemplate 類提供了一種類似於 Jongo 和 Java Driver 的機制。使用 MongoTemplate 可以很容易的實現geoNear 查詢。

你也可能已經注意到,Spring Data MongoDB 是唯一一個沒有在Java程式碼中提供資料庫名字的框架。這是因為Spring Data使用MongoTemplate,而 MongoTemplate 需要你在配置檔案中配置。也就是說,你可以將資料庫的名字注入到對應的變數上,並且使用此變數來代表資料庫的名字。

對於Spring Data Mongo唯一不滿意的地方就是他們選取了一種不標準的方式來表示地理資訊資料。如果你有一個mongo的集合,集合裡全是使用GeoJSON格式化過的資料,因為倉庫不能處理,基本上你就“完蛋”了(至少生成的near查詢不行)。我嘗試了在我對映的City物件裡使用GeoJSON的類,但是不能使轉換正常工作(Spring Data框架沒有使用Jackson框架做序列化)。並且,庫介面裡的geoNear方法生成的query串使用的舊的座標對,而不是GeoJSON幾何結構。如果Spring Data能夠提供對GeoJSON格式的位置和查詢的支援,就像在一個很好的蛋糕頂端新增了一顆櫻桃。

結論

對於這個用例,我對於框架選擇是:Spring Data,接著是Jongo和Java Driver。 Jongo排第二是因為其對映的功能,而不是其他方面的功能;Java Driver也基本相同。Morphia排最後是因為缺少對geoNear查詢的支援,並且缺少對地理物件的內建支援(double型別的陣列除外)。當Morphia的下一個版本釋出的時候,它的關注點可能會改變。使用Java Driver寫出的程式可能較冗長,但是和Groovy搭配在一起使用,冗長的缺點也可以克服。

這是一個相當簡單的例子,但是對我來說這是一個寶貴的學習經驗。基於上文中我的瞭解,我可能傾向於使用Spring Data MongoDB框架,並且當在庫介面裡函式過於複雜時,我會引入Java Driver。或許在其他的場景下,我的選擇會不一樣,時間會給我答案。我覺得,胸中有了這兩個的組合,沒有什麼我做不了的了。

相關文章