許多人在使用SQL資料庫時,會使用ORM(Object-Relational Mapping,物件關係對映)的方式來操作資料庫,因為這樣可以省去重複撰寫SQL語句的麻煩。而屬於NoSQL的MongoDB,因為不需撰寫SQL語句就可以將文件(document)寫入至資料庫,或是可以直接利用某個文件當作查詢條件,將指定文件從資料庫中再讀取出來。使用JavaScript程式語言來操作MongoDB是非常便利的,因為JavaScript的任意物件都可以被當作是一個文件,所以就比較沒那麼需要用類似ORM的方式來存取MongoDB。那些需要確保MongoDB中同一個集合(collection)內的文件格式是相同的JavaScript程式開發人員,才會去使用如Mongoose這樣的套件,以類似ORM的方式來存取MongoDB,只不過由於MongoDB並不是「關聯式」(Relational)資料庫,所以它的物件對映被稱為「ODM」(Object-Document Mapping,物件文件對映)。



比較JavaScript和Rust操作的MongoDB的程式寫法

這邊先不談ORM還是ODM,我們用最原始的方式來增、查、改、刪某筆文件,來比較用JavaScript程式語言和Rust程式語言實作的難易度。

首先來看看JavaScript的程式碼:

const databaseName = 'hello_mongo';
const collectionName = 'my_collection';

const mongodb = require('mongodb');

async function main() {
    let client;

    try {
        client = await mongodb.MongoClient.connect('mongodb://localhost:27017/');
    } catch (e) {
        throw e;
    }

    try {
        let db = client.db(databaseName);

        let collection = db.collection(collectionName);

        await collection.createIndex({
            name: 1
        }, {
            unique: true
        });

        await collection.insertOne({
            name: 'MagicLen',
            url: 'http://magiclen.org'
        });

        await collection.updateOne({
            name: 'MagicLen'
        }, {
            $set: {
                url: 'https://magiclen.org'
            }
        });

        let document = await collection.findOne({
            name: 'MagicLen'
        });

        if (document !== null) {
            console.log(document);

            await collection.deleteOne({
                name: 'MagicLen'
            });
        }
    } catch (e) {
        throw e;
    } finally {
        await client.close();
    }
}

main().catch(e => {
    console.error(e);
});

接著來看看Rust的程式碼:

#[macro_use]
extern crate mongodb;

use mongodb::ThreadedClient;
use mongodb::db::ThreadedDatabase;
use mongodb::coll::options::IndexOptions;

const DATABASE_NAME: &str = "hello_mongo";
const COLLECTION_NAME: &str = "my_collection";

fn main() {
    let client = mongodb::Client::with_uri("mongodb://localhost:27017/").unwrap();

    let db = client.db(DATABASE_NAME);

    let collection = db.collection(COLLECTION_NAME);

    let mut index_options = IndexOptions::new();

    index_options.unique = Some(true);

    collection.create_index(doc! {
        "name": 1
    }, Some(index_options)).unwrap();

    collection.insert_one(doc! {
        "name": "MagicLen",
        "url": "http://magiclen.org"
    }, None).unwrap();

    collection.update_one(doc! {
        "name": "MagicLen"
    }, doc! {
        "$set" : {
            "url": "http://magiclen.org"
        }
    }, None).unwrap();

    let document = collection.find_one(Some(doc! {
        "name": "MagicLen"
    }), None).unwrap();

    if let Some(document) = document {
        println!("{:?}", document);

        collection.delete_one(doc! {
            "name": "MagicLen"
        }, None).unwrap();
    }
}

以上兩個用不同語言寫出來的程式碼,看起來大同小異。但如果我們要讀取和修改存在於記憶體中的文件的欄位時,Rust程式語言就會變得十分麻煩!

先看一下JavaScript要怎麼做:

const databaseName = 'hello_mongo';
const collectionName = 'my_collection';

const mongodb = require('mongodb');

async function main() {
    let client;

    try {
        client = await mongodb.MongoClient.connect('mongodb://localhost:27017/' + databaseName);
    } catch (e) {
        throw e;
    }

    try {
        let db = client.db();

        let collection = db.collection(collectionName);

        await collection.createIndex({
            name: 1
        }, {
            unique: true
        });

        await collection.insertOne({
            name: 'MagicLen',
            url: 'http://magiclen.org'
        });

        await collection.updateOne({
            name: 'MagicLen'
        }, {
            $set: {
                url: 'https://magiclen.org'
            }
        });

        let document = await collection.findOne({
            name: 'MagicLen'
        });

        if (document !== null) {
            console.log(document);

            document.url += '/rust-mongodb-odm';

            console.log(document);

            await collection.deleteOne({
                name: 'MagicLen'
            });
        }
    } catch (e) {
        throw e;
    } finally {
        await client.close();
    }
}

main().catch(e => {
    console.error(e);
});

再來看看Rust要怎麼做:

#[macro_use]
extern crate mongodb;

use mongodb::ThreadedClient;
use mongodb::db::ThreadedDatabase;
use mongodb::coll::options::IndexOptions;
use mongodb::Bson;

const DATABASE_NAME: &str = "hello_mongo";
const COLLECTION_NAME: &str = "my_collection";

fn main() {
    let client = mongodb::Client::with_uri("mongodb://localhost:27017/").unwrap();

    let db = client.db(DATABASE_NAME);

    let collection = db.collection(COLLECTION_NAME);

    let mut index_options = IndexOptions::new();

    index_options.unique = Some(true);

    collection.create_index(doc! {
        "name": 1
    }, Some(index_options)).unwrap();

    collection.insert_one(doc! {
        "name": "MagicLen",
        "url": "http://magiclen.org"
    }, None).unwrap();

    collection.update_one(doc! {
        "name": "MagicLen"
    }, doc! {
        "$set" : {
            "url": "http://magiclen.org"
        }
    }, None).unwrap();

    let document = collection.find_one(Some(doc! {
        "name": "MagicLen"
    }), None).unwrap();

    if let Some(mut document) = document {
        println!("{:?}", document);

        let url = document.get_mut("url").unwrap();

        if let Bson::String(url) = url {
            url.push_str("/rust-mongodb-odm");
        }

        println!("{:?}", document);

        collection.delete_one(doc! {
            "name": "MagicLen"
        }, None).unwrap();
    }
}

在JavaScript程式碼中,只要在文件的變數名稱後加上點.,再接上欄位名稱就可以存取到那個欄位了。但是在Rust程式碼中,儘管我們已經可以確定這個文件內已經有哪些欄位,也還是要使用該文件變數儲存的mongodb::Document結構實體提供的get或是get_mut方法來讀取欄位,而且這兩個方法回傳的是mongodb::Bson這個結構體的不可變或是可變參考,而不是直接回傳字串的參考。所以我們還必須要再做一次型別匹配,才可以真正存取到我們想要的值。雖然mongodb::Document結構實體還有提供get_str等方法,可以直接回傳我們想要的型別的參考,但它內部也還是去呼叫get方法後再去做型別匹配。

也就是說,當我們要讀取mongodb::Document結構實體的欄位時,最好在第一次讀取後就把已經確定好型別的欄位值參考存到某個變數下,不然如果每次都在那邊get的話,效能是不會多好的。但是這樣程式撰寫起來就變得不是很方便,進而影響到開發速度。

所以用Rust程式語言操作MongoDB,若專案不算小的話,建議還是弄個ODM架構吧!

Wither

Wither是Rust程式語言上的MongoDB ODM框架,雖然使用者不多,但用起來是真的挺方便的。Wither要跟Serde搭配才能使用,如果您還不會用Serde,可以參考這篇文章:

https://magiclen.org/rust-serde/

Wither和Serde一樣都有提供程式序巨集,所以在引用時會有兩個套件:

wither = "*"
wither_derive = "*"

用Wither改寫剛才的例子

在開始介紹Wither的功能前,先來看看用Wither改寫剛才的例子後的程式碼:

#[macro_use]
extern crate mongodb;

extern crate serde;

#[macro_use]
extern crate serde_derive;

extern crate wither;

#[macro_use]
extern crate wither_derive;

use std::borrow::Cow;

use mongodb::ThreadedClient;
use mongodb::db::ThreadedDatabase;
use mongodb::coll::options::IndexOptions;
use mongodb::oid::ObjectId;

use wither::Model;

const DATABASE_NAME: &str = "hello_mongo";

#[derive(Debug, Serialize, Deserialize, Model)]
#[model(collection_name = "my_collection")]
struct Website<'a> {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "dsc", unique = "true"))]
    name: Cow<'a, str>,
    url: Cow<'a, str>,
}

fn main() {
    let client = mongodb::Client::with_uri("mongodb://localhost:27017/").unwrap();

    let db = client.db(DATABASE_NAME);

    let collection = db.collection(Website::COLLECTION_NAME);

    let mut index_options = IndexOptions::new();

    index_options.unique = Some(true);

    collection.create_indexes(Website::indexes()).unwrap();

    let mut website = Website {
        id: None,
        name: Cow::Borrowed("MagicLen"),
        url: Cow::Borrowed("http://magiclen.org"),
    };

    website.save(db.clone(), None).unwrap();

    website.update(db.clone(), None, doc! {
        "$set" : {
            "url": "http://magiclen.org"
        }
    }, None).unwrap();

    let website = Website::find_one(db.clone(), Some(doc! {
        "name": "MagicLen"
    }), None).unwrap();

    if let Some(mut website) = website {
        println!("{:?}", website);

        website.url += "/rust-mongodb-odm";

        println!("{:?}", website);

        website.delete(db.clone()).unwrap();
    }
}

用了ODM之後,雖然我們需要比較多行的程式碼來模型化(modeling)我們的資料,但在MongoDB的集合被建立與設定(把該建的索引建一建)完成之後,只要使用Website結構體就可以直接讀寫MongoDB的集合了!

注意以上的Website結構體用了泛型,但目前的Wither作者尚未實作這個功能,所以要使用筆者的Wither修改版本才行。

Wither的使用方法

接著來說明一下Wither比較常用的功能。

基本的模型化

在Wither框架中,一個能對映MongoDB中集合內的文件的結構體,其必須要有實作serde::ser::Serialize特性和serde::de::Deserialize特性,並且還要有個能對應文件中_id欄位的id欄位。接著要把該結構體加上#[derive(Model)]屬性,id欄位也要加上#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]屬性。

如下:

#[derive(Debug, Serialize, Deserialize, Model)]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
}
集合的名稱

Wither框架預設會將結構體的名稱複數化,再轉成MongoDB的名稱格式(snake_case),作為集合的名稱。例如MyDocument結構體,其集合名稱即為my_documents

如果要手動設定結構體的集合名稱,可以替結構體加上#[model(collection_name = "collection_name")]屬性,其中的collection_name就是集合名稱。

例如:

#[derive(Debug, Serialize, Deserialize, Model)]
#[model(collection_name = "members")]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
}

而要取得結構體所對應的名稱,可以使用該結構體提供的COLLECTION_NAME常數。

索引(index)

MongoDB的集合利用索引來進行加速,索引也可以被定義在模型中。替有索引的欄位加上#[model(index(index = "type")]屬性其中的type允許的值如下:

  • asc:對應著正整數(1)。即遞增索引。
  • dsc:對應著負整數(-1)。即遞減索引。
  • 2d:索引非GEO的二維座標。
  • 2dsphere:索引GEO的二維座標。
  • geoHaystack:這也是用來索引GEO的座標,但是以桶子的結構來索引。
  • text:索引文件中指定的文本欄位。
  • hashed:用雜湊值來索引。

如果要與其它欄位結合成復合欄位的索引,可以將屬性寫成#[model(index(index = "type", with(field = "another_field", index = "type_2"))],若要同時索引超過兩個以上的欄位,就一直with下去吧。

例如:

#[derive(Debug, Serialize, Deserialize, Model)]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "dsc", with(field = "height", index = "asc"), with(field = "height", index = "asc")))]
    age: u8,
    height: u8,
    weight: u8
}

如果text索引是復合欄位,且有設定權重的話,屬性要寫成#[model(index(index = "text", with(field = "another_field", index = "text"), weight(field = "this_field", weight = "weight_value"), weight(field = "another_field", weight = "weight_value_2"))]

例如:

#[derive(Debug, Serialize, Deserialize, Model)]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "text", with(field = "nick_name", index = "text"), weight(field = "name", weight = "10"), weight(field = "nick_name", weight = "5")))]
    name: String,
    nick_name: String,
}

如果被索引的欄位並不是最上層的欄位,而是在某個欄位之下的某層文件的欄位的話,屬性要寫成#[model(index(index = "type", embedded = "subdoc1.subdoc2.subdocN.target_field"))]

例如:

#[derive(Debug, Serialize, Deserialize, Model)]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "asc", embedded = "unit.id"))]
    metadata: mongodb::Document,
}

而其它索引相關的選項,其實可以參考這個文件來看有哪些。如果要設定其中的unique的話,就把屬性寫成#[model(index(index = "type", unique = "true"))],依此類推。

例如:

#[derive(Debug, Serialize, Deserialize, Model)]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "asc", sparse = "true", unique = "true", name = "phone_number"))]
    phone: String,
}

替我們的模型加上索引資訊,實際上並不會影響MongoDB的索引設定。但是Wither可以幫我們快速產生出索引的設定,餵給MongoDB的集合實體來建立索引。如前面舉的例子中,程式第46行,使用了集合實體提供的create_indexes方法,傳入的參數為我們模型的indexes關聯函數所回傳的值(Vec<mongodb::coll::options::IndexModel>)。

增、改、查、刪

模型的結構實體提供的save方法,可以用來將目前的結構實體序列化成MongoDB的文件,並儲存到集合中。

例如:

const DATABASE_NAME: &str = "hello_mongo";

#[derive(Debug, Serialize, Deserialize, Model)]
#[model(collection_name = "members")]
struct MyDocument {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<ObjectId>,
    #[model(index(index = "dsc"))]
    #[model(index(index = "dsc", with(field = "height", index = "asc"), with(field = "height", index = "asc")))]
    age: u8,
    height: u8,
    weight: u8
}

let client = mongodb::Client::with_uri("mongodb://localhost:27017/").unwrap();

let db = client.db(DATABASE_NAME);

let mut member = MyDocument {
    id: None,
    age: 18,
    height: 175,
    weight: 60,
};

member.save(db.clone(), None).unwrap();

注意在使用save方法時,結構實體必須要是可變的,因為id欄位在資料庫寫入後會被修改成MongoDB自己產生出來的Object ID。並且在使用模型的關聯函數或是方法來操作MongoDB時,要傳入資料庫的實體到第一個參數。由於資料庫的實體我們會一直重複使用,所以需要建立一個新的實體來傳進去,這邊就直接用clone方法就好了,不用擔心複製資料庫實體會有太多的開支(overhead),因為MongoDB的資料庫實體實際上已經被Arc智慧型指標包裹起來了,複製資料庫實體時其實只是複製智慧型指標而已。

save方法的第二個參數可以用來傳入一個mongodb::Document,作為查詢現有文件的條件。如果目前結構實體的id欄位是None,且save方法的第二個參數有設定查詢條件的話,這個結構實體會被寫入到查詢到第一個文件的位置。

模型的結構實體提供的update方法,它會拿走實體的擁有權,然後利用結構實體的id欄位來更新資料庫集合中對應的文件狀態,並且會在資料庫修改之後,把更新前或是更新後的文件再回傳出來(預設是傳回更新前的結果)。注意,這個方法是回傳mongodb::Document出來!如果要便利一點,可以直接使用剛才提的save方法來做更新,但效能似乎會差一點(因為要把整個結構實體給序列化,覆蓋掉資料庫中原先存在的整個文件)。

例如:

member.update(db.clone(), None, doc! {
    "$set" : {
        "weight": 70
    }
}, None).unwrap();

save方法的第二個參數功用和save方法的第二個參數差不多,都是在目前結構實體的id欄位是None時,去用搜尋條件來將查詢到的第一個文件,作為要被更新的文件。

模型的結構體,提供了findfind_one等相關的關聯和數可以使用,查詢出來的結果會自動反序列化為模型的結構實體。

例如:

let members: Vec<MyDocument> = MyDocument::find(db.clone(), Some(doc! {
    "age": {
        "$gte": 18
    }
}), None).unwrap();

如果只是想得知符合查詢條件的文件數量的話,可以使用count關聯函數。

例如:

let members_count = MyDocument::count(db.clone(), Some(doc! {
    "age": {
        "$gte": 18
    }
}), None).unwrap();

要刪除資料的話,可以使用模型的結構體提供的delete_many關聯函數,傳入過濾條件來刪除多筆資料。或者直接用模型的結構實體提供的delete方法,將該實體對應的文件從資料庫中刪除。

例如:

member.delete(db.clone()).unwrap();

MyDocument::delete_many(db.clone(), doc! {
    "age" : {
        "$gte": 18
    }
}).unwrap();

一些比較需要被改善的地方

以下記錄Wither的一些缺點,方便追蹤,看日後是否會被改善:

  • 有泛型的結構體無法被當作模型。
  • id欄位的型別一定要是Option<mongodb::oid::ObjectId>,而且一定要被命名為id
  • 模型缺少正常的update相關的關聯函數。現有的update方法有點詭異,它實質上是去使用集合的find_one_and_update方法。
  • 資料庫遷移(migration)機制不夠完善。