2012年9月28日 星期五

探究如何整合 GLib Main Event Loop 和 Node.js 的 libuv

Standard
在普通情況下,整合 GLib Main Event Loop 和 libuv 不是件平常人會做的事,因為,一般人使用著 GTK+、Clutter、DBus 等等函式庫(Library)時,永遠只會使用 GLib 而不會使用到第二套事件引擎。但是,在 Node.js 中,其事件引擎並不是 GLib,而是使用自己的『libuv』,想同時運行兩套事件引擎是不可能的,所以這將注定我們無法以 Node.js 去引入 GTK+、Clutter、DBus 等函式庫來使用。不過,天下文章一大抄,世界上所有事件引擎的設計差異不大,在理解 GLib Context 的運作後,我們還是可以嘗試將兩者整合在一起,協同運行。

簡單理解一下事件引擎,其說白了就是一個跑到天荒地老的無窮迴圈,不停的去檢查是否有事件被喚醒。所以,由此可知,兩套事件引擎不能被同時跑起來,因為任何一個事件處理的無窮迴圈,都將導致另一個事件處理的迴圈無法正常運作。所以,首要解決的課題,就是讓兩個無窮迴圈可以同時運作。

為了解決這樣的問題,你可能會想到去使用 Thread (執行緒/線程),只要在建立兩個 Thread,然後各自跑不同的事件引擎就可以了。但是這樣做,問題馬上就會出現。由於程式正在運作的過程中,事件數量相當多,事件被安插和喚醒的次數非常頻繁,這將導致兩個 Thread 之間很難維持穩定的資料交換,你得實作 Mutex Lock 等機制來達成 Thread-safe,更還要想辦法解決兩邊各類大小資料交換的需求。種種原因,都會造成兩個事件引擎大量的彼此等待,而效能不彰。此外,也不易同時控制兩邊的事件觸發順序,以及事件被喚醒時間的精確度,如計時器(Timer)。

既然使用 Thread 是不好的做法,是否有其他方式可以解決我們一開始的課題?解決方法是有的,從事件引擎的運作細節,我們可以發現一些端倪。

一般來說,大部份的事件引擎都包括了幾個部份:


從 Initial 出發, Event Loop 的迴圈每運行一次,都經歷了『prepared』、『Polling』和『Dispatching』三種狀態,若用白話來說,他們所代表的意義就是『已準備好事件清單』、『監控事件』、『喚醒、分配並處理事件』。

如果你去參考事件引擎程式碼,會發現在三個狀態之間,分別有『prepare()』、『query()』、『check()』和『dispatch()』等四個動作,而這四個動作的行為簡單來說是『準備事件清單』、『取得需要被監控的事件』、『確認是否事件被觸發』、『喚醒並處理被觸發的事件』。

理解了事件引擎的運作流程後,該如何讓兩個引擎協同運作便有了答案。就如同本文一開頭所說,其實所有的事件引擎都大同小異,所以 GLib 和 libuv 也有差不多的行為,同樣有『prepare()』、『check()』等動作。那麼,我們只需要將 GLib 的事件處理機制,原封不動的一一搬到 libuv 上功能對應的地方即可。

理論上,移植 GLib 的工作並不難,我們只要參考 GLib Source Code,把 g_main_loop_run() 和 g_main_context_iterate() 內的工作拆解出來,放到 libuv 的 prepare() 和 check() 就能達到目的。

首先,在註冊兩個 libuv 的事件(用於 prepare 和 check):
/* Prepare */
uv_prepare_init(uv_default_loop(), &prepare_handle);
uv_prepare_start(&prepare_handle, prepare_cb);

/* Check */
uv_check_init(uv_default_loop(), &check_handle);
uv_check_start(&check_handle, check_cb);

然後在 uv_prepare_t 的 Callback Function(check_cb),取得 GLib main context 的事件清單並監聽:
void prepare_cb(uv_prepare_t *handle, int status)
{
 gint i;
 gint timeout;
 struct gcontext *ctx = &g_context;

 g_main_context_prepare(ctx->gc, &ctx->max_priority);

 /* Getting all sources from GLib main context */
 while(ctx->allocated_nfds < (ctx->nfds = g_main_context_query(ctx->gc,
   ctx->max_priority,
   &timeout,
   ctx->fds,
   ctx->allocated_nfds))) { 

  g_free(ctx->fds);
  g_free(ctx->poll_handles);

  ctx->allocated_nfds = ctx->nfds;

  ctx->fds = g_new(GPollFD, ctx->allocated_nfds);
  ctx->poll_handles = g_new(uv_poll_t, ctx->allocated_nfds);
 }

 /* Polling */
 for (i = 0; i < ctx->nfds; ++i) {
  GPollFD *pfd = ctx->fds + i;
  uv_poll_t *pt = ctx->poll_handles + i;

  struct gcontext_pollfd *data = new gcontext_pollfd;
  data->pfd = pfd;
  pt->data = data;

  pfd->revents = 0;

  uv_poll_init(uv_default_loop(), pt, pfd->fd);
  uv_poll_start(pt, UV_READABLE | UV_WRITABLE, poll_cb);
 }
}
註:Polling 這段在實作 GLib 內部函數 g_main_context_poll() 的工作。

監聽的部份,若有事件被觸發則呼叫 poll_cb(),所以我們也定義:
void poll_cb(uv_poll_t *handle, int status, int events)
{
 struct gcontext_pollfd *_pfd = (struct gcontext_pollfd *)handle->data;

 GPollFD *pfd = _pfd->pfd;

 pfd->revents |= pfd->events &
  ((events & UV_READABLE ? G_IO_IN : 0) |
  (events & UV_WRITABLE ? G_IO_OUT : 0));

 uv_poll_stop(handle);
 uv_close((uv_handle_t *)handle, NULL);
 delete _pfd;
}
註:revents 在監聽前會被我們歸零,在事件觸發時被我們設值,而其用途是告知 GLib main context 該事件被什麼行為(讀或寫)觸發。

最後是 check_cb(),我們要停止所有對事件的 Polling,然後讓 GLib main context 去檢查並處理事件:
void check_cb(uv_check_t *handle, int status)
{
 gint i;
 struct gcontext *ctx = &g_context;

 /* Release all polling events which aren't finished yet. */
 for (i = 0; i < ctx->nfds; ++i) {
  GPollFD *pfd = ctx->fds + i;
  uv_poll_t *pt = ctx->poll_handles + i;

  if (uv_is_active((uv_handle_t *)pt)) {
   uv_poll_stop(pt);
   uv_close((uv_handle_t *)pt, NULL);
   delete (struct gcontext_pollfd *)pt->data;
  }
 }

 g_main_context_check(ctx->gc, ctx->max_priority, ctx->fds, ctx->nfds);
 g_main_context_dispatch(ctx->gc);
}

到目前為止,GLib 的事件引擎已經可以良好的跑在 libuv 上。

此外,由於筆者本身開發了許多 Node.js Module 都有用到 GLib Main Event Loop,如:『dbus』、『jsdx-toolkit』等,常有這樣的需求,所以直接將這個部份寫成 C++ Class,以便日後其他專案使用。如果你有需要,可以參考兩支檔案:


將 Header 引入你的程式碼後,就可以直接宣告並使用它:
Context *context = new Context;
context->Init();


後記

libuv 的歷史和說明,本文就不多提,欲知更多細節,可參閱舊文『Node.js v0.8 大變革?!Native Module 開發者的福音!』。而接下來,你只需要知道,日後『libuv』將是 Node.js 內統一的事件處理機制就可以,這對撰寫 Node.js C/C++ Add-on 的人來說尤為重要,因為無論是在哪一個作業系統,都可以直接使用 libuv API 來操作事件。

2012年9月3日 星期一

MongoDB Replication 簡記

Standard
就在幾天前,MongoDB 邁入了 2.2.0 的穩定版本。我們若回頭來看,MongoDB 一直到了 2.0 前後,比起早期版本,已經有長足的進步,並且支援了相當多的功能,也對規模化和資料庫系統管理下了很多功夫。對於大多數的資料庫應用,已經非常適合。

若你對資料庫相關技術有些了解,就會知道,當資料庫的資料發展一定規模程度,或是要確保系統不當機時,我們就需要用到 Master/Slave 的方式去備份和備援,當主要(Master)伺服器出了問題,次要(Slave)伺服器便即時補上,保持系統運作。但是,既然已經有 Master/Slave 機制,是否可以有更多台備援呢?更或者進一步,將讀寫分開在不同伺服器,以分攤流量和系統負載,並加速讀寫速度。而 Replication 就是這樣的機制,可以用來動態同步多台資料庫伺服器的資料,也可以當主要伺服器因故下線時,讓其他伺服器即時替補主要機器的位置。

在 Debian 上設定 MongoDB 的 Replication 相當容易,首先在想要變成主要(Primary)的機器上,打開設定檔(/etc/mongodb.conf),並為我們的 Replication 群組命名(在 MongoDB 中稱為 ReplSet,一些書籍內翻譯成『複製組』):
replSet = mydb

重新啟動 MongoDB:
$ sudo service mongodb restart

使用指令 mongo,進入 MongoDB 命令模式:
$ mongo

於 MongoDB 命令模式中執行:
# 初使化 replSet
rs.initiate(null)

# 加入自己的 IP 位置
rs.add("192.168.11.1:27017")

若是回應成功,請先稍待數秒鐘,等伺服器偵測和初使化。然後會發現 MongoDB 的命令提示字元從『SECONDARY』變成『PRIMARY』,此時,代表這台機器已經變成 ReplSet(複製組) 中的主要機器。

同樣的,你可以開始為 ReplSet 加入其他次要的資料庫伺服器:
rs.add("192.168.11.2:27017")
rs.add("192.168.11.3:27017")
rs.add("192.168.11.4:27017")
rs.add("192.168.11.5:27017")

這邊要注意一點,如果你的 replSet 中只有兩台 MongoDB (包括 Primary 自己),單單這樣設定,會發生無法判定 Primary 該給誰的問題。因為 MongoDB 的機制,是讓眾伺服器投票決定誰當 Primary,但經測試發現,一個 RelpSet 中只有兩台機器時,常會發生問題,造成整個群組中無 Primary 機器的狀態。所以,這時要加入仲裁者(Arbiter)機器來協助排除。

Arbiter 設定很簡單,你要先準備第三台機器,裝上 MongoDB 和設定 /etc/mongodb.conf 裡的 replSet,接著在 Primary 主機上把這台機器加入到 Arbiter 伺服器清單:
rs.addArb("192.168.11.100:27017")
註:Arbiter 因為不處理資料,所以負載並不高,理論上也可以和原本的資料庫放在同一台機器上,但請注意,如果你要這樣做,需要另外啟動第二個 mongod 服務,並跑在不同的 Port。由於這樣的做法,在 Debian 上沒有標準安裝步驟,就不在本文詳述。此外,如果你只有兩台資料庫主機,記得兩台上面都要架設 Arbiter,這樣,當其中一台當機時,replSet 中還有一個 Arbiter 來決定 Primary 該給誰,不然也會發生整個群組中無 Primary 機器的問題,哪怕只剩一台機器還活著,MongoDB 依然憂柔寡斷的不知道該不該篡位。

最後一步,在其他主機上都將 /etc/mongodb.conf 設定好,並進入 mongo 命令模式初使化,MongoDB 會自動同步 replSet 的設定,然後會把 Primary 上的資料同步並複製過來:
rs.initiate(null)

如果一切沒問題,你可以在 Primary 主機上,進入 mongo 命令模式,然後使用指令看整個 replSet 的狀態:
rs.status()

後記

Replication 設定好後,只要 Secondary 的機器一上線,就會開始同步資料,如果 Primary 主機死掉,也會自動選定一台 Secondary 機器,讓它變成新的 Primary。

過程中,主要可能發生的問題是整個 replSet 選不出 Primary 的狀況,而且在無 Primary 機器時候,你無法去修改並設定 replSet ,造成整個系統不能正常運作。由於 MongoDB 沒有提供強制清除 replSet 設定的功能(至少到目前為止我沒有找到),一個非正式的方法是使用 mongo 去將每一台裡名為『local』的 db 砍掉,然後重啟 Service 並重新設定 replSet 。

2012年9月2日 星期日

夢想偉大,但步伐短小的 DBHouse

Standard
數個月前開始做一個計劃『AppHouse』,實作如 Google App Engine(GAE) 般的 PaaS,其志在打造自己的 Node.js 雲端軟體平台。然後發現,除了讓雲端服務可以在平台上跑起來外,資料庫管理也必需有個便於使用的機制和規劃,仔細想想,一個沒有資料庫配合的雲端服務,可沒有什麼太大的價值,於是,『DBHouse』便應運而生。

你可以在 github 上找到這個專案:
https://github.com/Mandice/node-dbhouse

DBHouse 起初的開發目的,是讓使用 AppHouse 架設以及開發自己雲端服務的人,可以很容易存取資料庫。此外,對我們而言更便於管理資料庫資源,面對許多不同的服務,不需要特別為他們開設資料庫權限,亦或是買許多硬體和主機,建立起許多 VM 並做各種安全性規劃。其實,如果把 DBHouse 的用途,想像成 Google 在做的事,就很容易明白:『在 GAE 上你可以使用統一的 Database APIs,存取 Google 提供的資料庫系統(BigTable)。』,同理,我們也是在做同樣的事。

只不過,學 Google 開發自己的一套資料庫太過於困難,不是一個可以達成的目標,所以我們仍然選用 MongoDB 當做 PaaS 的資料庫底層。僅管資料庫不是自己開發的,我們還是可以提供統一的 API,讓開發者存取。統一的 API 有個好處,若能做到當開發者在使用的時候,不需要知道自己在使用什麼資料庫,日後就可以在這 API 之後串接或替換不同的資料庫系統,有很大的彈性可以擴充。

當然,更遠大的目標是希望在一個 Table(Collection) 內,因應不同的欄位需求,而交由不同資料庫處理,更進一步發揮不同資料庫的特色。但是,這夢想遠大,技術上也有很多盲點待討論,所以能不能實現那是另外一回事,至少,短期內在我們的能力範圍和經濟狀況下,暫時無法達成這一步。

雖然 DBHouse 有這樣的初衷和夢一般的計劃,但不代表 DBHouse 一定得和 AppHouse 配合使用,更準確的說,他們本來就是獨立各自發展的專案,各自可獨立運作。說穿了,DBHouse 本身就只是一個 Database API,你可以在 Node.js 裡使用 DBHouse API 去操作自己的 MongoDB(目前只有支援 MongoDB,其他驅動還待開發),也提供了一套 ORM 供開發者使用。

目前 DBHouse 的特色:
  • 支援簡易的 ORM (包括:多層資料結構)
  • 支援 MongoDB v1.6+
  • 已實作驅動層以便支援其他資料庫
  • 盡可能貼近 SQL 語法習慣(如:select、insert、update、delete、replace)
  • 以 Mongo Query Language 為主要查詢語言
  • 根據 ORM 設定,自動轉換欄位類型(如:讀寫 MongoDB 時,自動轉換 BinObject)
    ex, 讀出 UUID 時轉成字串,以便 JavaScript 操作或網頁間傳遞,存入或查詢時,自動轉成 MongoDB 的 BinObject

目前仍缺少的功能:
  • Cursor
  • 索引(Index)管理
  • 完整的文件

使用 NPM 安裝:
npm install dbhouse

展示如何使用 DBHouse 新增一筆資料:
var DBHouse = require('dbhouse');

var dbHouse = new DBHouse;

/* Define schema */
var Address = new DBHouse.Schema({
        company: { type: 'String' },
        home: { type: 'String' },
        updated_time: { type: 'Date' }
});

var Contact = new DBHouse.Schema({
        _id: { type: 'UUID' },
        name: { type: 'String' },
        email: { type: 'String' },
        tel: { type: 'String' },
        created: { type: 'Date' },
        address: { type: 'Schema', schema: Address }
});

/* Create connection with database server */
dbHouse.connect('mongodb', { host: 'localhost', port: 27017 }, function() {

        /* Create a database operator */
        var db = new DBHouse.Database(dbHouse);
        db.open('dbhouse')
                .collection('contact')
                .model(Contact)
                .insert({
                        name: 'Fred Chien',
                        email: 'cfsghost@gmail.com',
                        tel: '0926123456',
                        created: new Date().getTime(),
                        address: {
                                company: 'Taipei',
                                home: 'Taiwan',
                                updated_time: new Date().getTime()
                        }
                }, function(err, data) {
                        if (err)
                                throw err;

                        console.log(data);
                });
});
註:『collection()』可使用『table()』替換,兩者功能一樣,只是為了同時符合 SQL 和 NoSQL 的習慣。

如果只是想做簡單的資料庫操作,也可以不使用 ORM 機制:
var DBHouse = require('dbhouse');

/* Create connection with database server */
var dbHouse = new DBHouse;
dbHouse.connect('mongodb', { host: 'localhost', port: 27017 }, function() {

        /* Create a database operator */
        var db = new DBHouse.Database(dbHouse);
        db.open('dbhouse')
                .collection('contact')
                .select({
                        email: 0
                })
                .where({
                        '$or': [ { name: 'Fred Chien'}, { name: 'Stacy Li' } ]
                })
                .limit(1)
                .query(function(err, data) {
                        if (err)
                                throw err;

                        console.log(data);
                });
});

如果你使用過 MongoDB ,也熟悉傳統 SQL 資料庫,你一定會發現兩者對於 update 的定義相當不一樣,因為在 MongoDB 中預設方法是直接取代掉整筆資料,而傳統 SQL 中只是修改欄位而已。但是在 DBHouse 中,對 update 的處理是採用 SQL 的動作,也就是修改欄位,若是要整筆取代,則另外使用 replace()。

修改 tel 欄位的資料:
var DBHouse = require('dbhouse');

/* Create connection with database server */
var dbHouse = new DBHouse;
dbHouse.connect('mongodb', { host: 'localhost', port: 27017 }, function() {

        /* Create a database operator */
        var db = new DBHouse.Database(dbHouse);
        db.open('dbhouse')
                .collection('contact')
                .where({
                        name: 'Fred Chien'
                })
                .update({ tel: '0926333572' }, function(err) {
                        if (err)
                                throw err;
                });
});

既然 Mongo Query Language 是特色,當然可以吃特殊命令,以下程式將把 reviewed 欄位值加一:
var DBHouse = require('dbhouse');

/* Create connection with database server */
var dbHouse = new DBHouse;
dbHouse.connect('mongodb', { host: 'localhost', port: 27017 }, function() {

        /* Create a database operator */
        var db = new DBHouse.Database(dbHouse);
        db.open('dbhouse')
                .collection('contact')
                .where({
                        name: 'Fred Chien'
                })
                .update({ $inc: { reviewed: 1 } }, function(err) {
                        if (err)
                                throw err;
                });
});

後記

大致上來說,DBHouse 已經足夠使用,但真要挑惕的話,還是有很多細節功能待補齊。而目前開發模式,是依照我們手邊專案所需,為其添加新功能,如果遲遲沒有加入您想要的功能,還請見諒。

此外,如果有人喜歡 DBHouse,歡迎投入開發並提交 Patch。:-)