loki設計構想

最近寫代碼一直沒啥心情,好友建議既然寫不出代碼,那麼乾脆就一個月不要寫代碼好了。於是我就開始考慮寫文檔算了。想到在知乎這麼久,問題回答的少,贊也點的少,如果想寫點東西的話,那麼就在這裡寫好了。

作為我的第一篇文章,我想寫寫loki,就寫寫我為什麼要做這個項目,並且這個項目現在的情況吧。

10年入行,我主要做的是手機遊戲的客戶端。一直在設想完整的製作一個手機遊戲的全棧開發方案,我並不喜歡諸如Cocos2D或者Unity這種框架式的方案。我想要的,一直是一個通用,強大,而又可以有積累的工具箱。loki本身作為這個工具箱的伺服器部分,自然也要體現這樣的特點。

loki是一個通用的伺服器「框架」。框架之所以打引號,是因為loki本身其實完全算不上是框架,而只是一個工具罷了。loki的設計思路參考於skynet,但又有很多不一樣的地方——確切的說,loki的架構和skynet是完全不一樣的,雖然都是為了同一個目的而開發,但是設計選擇是完全不一樣的。

對於不了解skynet的人來說,這裡可以簡單介紹一下。loki是一個多線程的消息傳遞框架。即「服務容器」。loki本身不提供任何服務,它只是服務的容器,在loki內部,多個服務可以多線程地相互傳遞消息完成工作。loki就是負責這個消息傳遞的一個「程序庫」。

不一樣的地方在於,不像skynet是一個完整的應用程序,loki本身只是一個庫而已,而且是一個很特殊的「單頭文件庫」,因此,loki本身可以非常容易的嵌入到其他的項目里——你只需要將loki.h這個頭文件直接扔到項目裡面去就可以了。loki本身是跨平台的,目前支持Windows,Linux和Mac這些主流平台,相比skynet更專註於Linux平台。

作為一個庫的設計,當然會和作為一個獨立的應用程序設計的skynet有很多的不同。loki.h本身並沒有實現任何服務,但它是一個完整地,可以獨自使用的完整框架。作為庫的設計,loki對於大多數的設計選擇和skynet並不一樣,首先,我們還是先了解一下loki庫的使用。

作為一個消息傳遞框架,首先我們需要創建loki上下文。我所認為的工具箱,應該為使用者提供最大的方便,因此我幾乎所有的開源項目,都是純Clean C編寫,並不使用任何全局的狀態,因此幾乎可以最大程度的嵌入到各種實際項目里。但就是因為這樣,所以你要使用這些庫提供的服務,那麼你就需要自行維護這些庫的全局狀態。對loki來說,這個全局狀態就是lk_State——即loki的全局上下文對象。

然而,loki是一個特別設計的庫——通過lk_newstate()/lk_close()得到的lk_State對象,本身有很多特殊的用途,這點我們之後會詳細說明的。我們先來看一下單頭文件庫應該如何使用。

對於純C的單頭文件庫,使用是比較簡單的,你需要在使用的時候包含這個頭文件。如果你希望在不同的.c文件中,擁有這個庫的不同的拷貝,那麼你需要在包含頭文件之前,定義LK_STATIC_API這個宏。如果不是這樣,也就是說,你希望整個項目只有一份loki的拷貝,那麼你需要在任意一份且僅此一份.c文件中,定義LOKI_IMPLEMENTATION宏,這樣這個文件中就會包含loki的所有實現了。

如果你包含了LOKI_IMPLEMENTATION宏,那麼在Windows上,另外一個副作用就是你的程序(exe或者dll)會導出loki定義的所有的API(以LK_API宏標記),如果你並不想導出這些符號,那麼在定義了LOKI_IMPLEMENTATION之後,再手動定義LK_API即可。總而言之,單頭文件庫的使用是非常簡單而且自由的,你可以用任何你想要的方式來使用這個庫。

對於一些example而言,這樣的庫是非常好使用的——只需要定義前面提到的宏,就可以直接放心大膽地包含loki.h即可。只要能知道這個頭文件,那麼你並不需要任何特殊的編譯選項,直接正常編譯即可。這也是我的一個理念,那就是庫不應該有任何特殊的編譯方式,應該「開箱即用」,即用正常的方式就可以編譯使用,庫本身應該處理各種編譯器的細節——如果需要的話。更進一步的,我更希望庫實現的功能可以不用處理各種編譯器和系統的差別,因為那樣才意味著你抽象出來的邏輯是放之四海而皆準的。

說了這麼多閑話,我們繼續來看loki庫的使用,前面說了,要使用這個庫,你需要有一個全局的上下文對象,即lk_State,擁有了這個對象之後,你就可以使用這個對象提供的服務了。loki核心提供的服務並不多,其實就一個:通過lk_launch()這個API創建「服務」。

在loki中,一個服務是這麼一個東西:它有一個名字,它通過lk_Service指針來標識(相對應的,skynet的服務是用一個int服務號來區分的,為什麼選擇不一樣的方案呢?後面會說明這個問題的)。服務工作的方式,是暴露一系列的Slot供使用者向服務發送消息來完成的,這一點和skynet是一致的。但,對skynet來說,一個服務就是一個介面,而所有的消息都是發送給這個介面,服務是消息目標的最小單位,如果一個服務需要提供很多的功能怎麼辦呢?那麼服務的入口函數就會是一個大型的switch-case語句,利用消息的type標記來決定消息到底想使用什麼功能。而在loki中,一個服務可以提供很多的Slot,Slot才是消息模板的最小單位,你可以給一個服務的不同的Slot來發送消息,進而標記你在使用不同的功能。而Slot通過lk_Slot類型的指針來標識。

通過持有lk_Service或者lk_Slot的指針,你可以通過lk_emit()介面直接給服務或者Slot發送消息,lk_emit()內部會處理所有多線程的細節,因此你並不需要關心你在哪個線程,發送消息這個操作本身是線程安全並且是原子的。

上面提到了,服務有一個名字,這個名字大小是有限制的,通過LK_MAX_NAMESIZE限制,目前是32個位元組,如果不夠用,以後可以考慮修改。創建服務需要調用lk_launch()這個API,提供服務名,一個lk_Handler的函數指針作為服務的初始化函數,和一個void*指針作為服務的創建數據,lk_launch是同步和原子的,如果服務成功創建,這個函數會返回給你服務指針,如果創建失敗,則返回給你一個空指針NULL。

服務的名字是唯一的,因此如果你lk_launch()了一個同名的服務,那麼lk_launch()並不會創建新的服務,而是直接給你一個舊的服務指針,因此在使用的時候需要注意這一點,你可以通過給服務不同的名字來避免這一點,比如同樣是Lua腳本,你可以給一個服務命名為lua:main.lua而另外一個叫lua:event.h來區分,總而言之,lk_launch()並不考慮這些,如果有同名的服務,那麼其實你不是不知道這個服務並不是你創建的,因為使用者必須考慮服務的唯一性。

如果服務創建好了,你就獲得了一個lk_Service指針,奇特的地方是,這個指針同時也是個const char指針,將其強制轉換成(const char*),你就能獲得這個服務的名字,而這個指針同時也是一個默認的Slot的指針,直接將其強制轉換成lk_Slot*,你就可以使用針對Slot的各種服務了。在官方的服務實現里一直有一個模式:即這個特殊的lk_Slot會被當做功能回包的接收Slot,如果你是服務Foo,你向另外一個服務Bar的某個Slot發送了一個請求,Bar就會將這個請求的執行結果,發送給你的服務Foo對應的這個「默認的」Slot,而不管你是在Foo服務的哪個Slot給Bar發送請求的。

既然Slot是這麼設計的,那麼你也應該想到了,沒錯,lk_State這個全局上下文指針也是一個服務,你可以將lk_State指針轉換成一個lk_Service指針,這個指針就是這個lk_State的「root」服務。通常這個服務是loki的全局創建者發送消息的回包收集的地址,loki伺服器會使用到這個特性,然而對loki庫而言,這個服務是內部不使用的,用戶可以任意使用這個默認提供的服務。

所以,奇妙的地方在於,如果你創建了lk_State了,那麼其實你已經有一個服務了,而且你還有一個Slot了,所以你其實已經可以直接給自己發送一個消息來測試下了,所以,最簡單的loki的hello world,其實是這樣的:

#define LOKI_IMPLEMENTATION#include "loki.h"int main(void) { lk_State *S = lk_newstate(NULL, NULL, NULL); lk_emitstring((lk_Slot*)S, 0, 0, "Hello World
"); lk_start(S, 0); lk_close(S); return 0;}

而這就是整個loki框架所提供的功能了,下面是一些設計上的細節了。

我們來看上面的代碼,創建lk_State的時候調用lk_newstate,我們傳遞了三個NULL。第一個參數是這個lk_State(它同時也是個服務)的名字,默認名字是「root」,如果使用默認名字,那麼名字傳遞NULL就可以了(當然,用lk_launch()創建其他服務的時候可不能這樣)。第二個和第三個參數是一個自定義內存分配函數,loki所有的內存分配都會通過這個函數進行,如果你並不想使用默認的malloc/free,或者你想在內存分配出錯的時候執行一些操作,那麼你可以自行實現lk_Allocf類型的函數。

然後,我們給S所代表的那個Slot發送一條字元串的消息「Hello World
」,lk_emitstring()是lk_emit()函數的「糖函數」,對emit進行了包裝用起來更加方便。而這時,loki的線程池還沒有開啟,消息還停留在消息隊列里。

下一個lk_start()函數則開啟線程池,第二個參數是線程數量,填0代表按照CPU數量創建工作線程,這時消息才會開始傳遞和處理。

最後我們關閉掉全局上下文,所有服務就結束了。注意,其實真正的Hello World程序不應該在start後直接調用close,而是需要先調用lk_waitclose()函數等待所有服務自行關閉。然而,因為我們並沒有設置主服務的消息處理函數(見下段),我們也無法在主服務內部關閉服務,如果調用了lk_waitclose(),那麼這個程序就永遠都不回結束了,因此這裡我們直接調用lk_close()。

上面提到了每個Slot都會有一個消息處理函數,而每個loki的服務或者Slot處理消息的函數指針,都是lk_Handler類型的。對於root服務來說,這個指針默認是空指針,這樣,發給這個Slot的所有消息會被自動丟棄。當然,通過lk_setslothandler()這個介面,你可以隨時修改這個函數。這個函數接受三個參數:lk_State全局上下文指針,lk_Slot消息發送的目標Slot指針(也就是自己的指針),和消息lk_Signal指針。對於服務的lk_Handler函數,又有些不同的地方,首先,服務創建的時候,這個函數會被調用一次,但這時Slot指針式空的,意思是告訴你服務需要初始化了。你可以通過調用lk_self()獲得服務指針,或者通過lk_current()獲得當前的Slot的指針(當然,這兩個函數在任何時刻都可以調用)。另外,如果你有Slot指針,你也可以通過lk_service()函數獲得這個Slot所屬的服務。

在服務被關閉的時候,服務的lk_Handler也會被調用,這時,Signal指針又會是空指針,告訴你這是服務收到的最後一個消息,服務需要清理掉自己分配的所有資源,然後服務就會被銷毀了。銷毀服務使用和銷毀全局上下文一樣的函數,lk_close()。事實上,這個函數就是用來銷毀自身的,如果在某個服務的Slot處理函數中調用,那麼就是關閉服務,否則,就是關閉整個loki消息傳遞系統了,當然這時所有服務也都會被關閉。

除此以外,正常的Slot的handler函數指針都不會被這麼特殊調用。

對一個服務而言,有一個很有特色的設計:每個服務,都可以為自己設置一個「重構函數」,通過lk_setrefactor()函數進行。什麼是重構函數呢?比如說,有一個服務的功能是設置一個計時器。通常的做法(比如skynet),是在這個時間到了以後,給設置計時器的服務發送一個消息,告訴你「時間到啦」,然後在這個服務的消息處理函數里去處理計時器的消息。然而,loki不是這樣,loki自帶的timer服務里,你可以發現當時間到的時候,timer會自動在請求服務的上下文調用一個函數,這是怎麼做到的呢?就是通過重構函數做到的。當一個服務處理完一個請求,並發送回包的時候,如果自己設置了重構函數指針,那麼這個函數會在對方收到這個回包的時候,在對方的上下文執行。注意,因為是在對方的上下文執行,那麼如果要取得自己服務的狀態,那就需要加鎖了。然而,這個函數通常並不需要加鎖,因為它通常是根據回包,直接在對方的上下文中執行一些操作,這樣,我們就可以實現「智能請求」,在請求完成的時候,可以由服務執行方指定一個特殊的操作,而並不需要請求服務的客戶理解服務返回的消息協議。

對skynet來說,你必須能分別一個一個消息的到來到底是請求包還是回包——在loki里,這不是問題,因為通常回包會直接發送給特定的Slot(即Service對應的那個Slot)。對skynet來說,你必須得知道對方發來的回包是採用了什麼協議,否則你無法解析這個回包,然而對loki來說,你也是不必知道這個回包究竟是什麼的,因為發送這個回包的服務可以「貼心地」設置一個重構指針,將這個回包「翻譯成」客戶理解的協議,至於到底服務內部使用了什麼協議,就不重要了。

通過這些改進,loki在使用方面會比skynet更加舒服。

loki.h這個單頭文件本身就是很完整的一套消息傳遞框架了,而loki項目還不僅於此。loki項目的目標是製作一個類似skynet的靈活的服務容器,並提供一系列的默認服務。不像skynet,loki核心並不提供任何服務,然而loki項目會提供一些標準服務,這些服務的實現都命名為service_*.c。如果是你自己的項目,你可以選擇這些服務中的某幾個來自行搭建一套基礎的服務框架,這些就不屬於loki的核心了,而只是外部的一些約定罷了。

在我寫這篇介紹文字的目前的狀態是,loki.h這個核心庫已經能夠完全使用了,自帶的標準服務,timer,log,socket,monitor,loader這幾個主要而核心的服務也都已經可以使用了,這些服務都是可以跨平台使用的。然而這還很不夠。loki還需要支持harbor服務(以提供跨進程的服務容器的解決方案)和lua服務(以能夠執行Lua腳本)。目前loki的開發就我一個,在業餘時間慢慢完成的,我會經常重構loki,希望它能夠足夠完美。除此以外,loki還沒有一套完整的伺服器設計方法論,缺少類似gate、proxy的機制,這些我都會在之後進行慢慢的完善。

目前來看,loki說代替skynet還很早,但loki克服了skynet的很多先天弱點,並擁有更加靈活高效嚴謹的架構設計,我相信使用loki一定是一件很讓人開心的事情,對我來說,沒什麼比使用我的代碼感到開心更讓我開心的啦~

對loki的使用和設計就介紹到這裡啦,如果有任何問題,可以在這裡回帖提問,或者去loki的項目:github.com/starwing/lok 提問,謝謝觀賞 :)

以上。


推薦閱讀:

讓計算更智慧 | 聯想發布全新ThinkSystem、ThinkAgile品牌把握AI風口
深挖NUMA

TAG:遊戲伺服器 | 伺服器架構 | 手機遊戲開發 |