Leaf 是一個由 Go 語言(golang)編寫的開發效率和執行效率並重的開源遊戲伺服器框架。Leaf 適用於各類遊戲伺服器的開發,包括 H5(HTML5)遊戲伺服器。
Leaf 的關注點:
良好的使用體驗。Leaf 總是儘可能的提供簡潔和易用的介面,儘可能的提升開發的效率 穩定性。Leaf 總是儘可能的恢復執行過程中的錯誤,避免崩潰 多核支援。Leaf 通過模組機制和 leaf/go 儘可能的利用多核資源,同時又儘量避免各種副作用 模組機制。 Leaf 的模組機制一個 Leaf 開發的遊戲伺服器由多個模組組成(例如 LeafServer ),模組有以下特點:
Leaf 不建議在遊戲伺服器中設計過多的模組。
遊戲伺服器在啟動時進行模組的註冊,例如:
leaf.Run (
game.Module ,
gate.Module ,
login.Module ,
) 這裡按順序註冊了 game、gate、login 三個模組。每個模組都需要實現介面:
type Module interface {
OnInit ()
OnDestroy ()
Run (closeSig chan bool )
}Leaf 首先會在同一個 goroutine 中按模組註冊順序執行模組的 OnInit 方法,等到所有模組 OnInit 方法執行完成後則為每一個模組啟動一個 goroutine 並執行模組的 Run 方法。最後,遊戲伺服器關閉時(Ctrl + C 關閉遊戲伺服器)將按模組註冊相反順序在同一個 goroutine 中執行模組的 OnDestroy 方法。
Leaf 原始碼概覽leaf/chanrpc 提供了一套基於 channel 的 RPC 機制,用於遊戲伺服器模組間通訊 leaf/db 資料庫相關,目前支援 MongoDB leaf/gate 閘道器模組,負責遊戲客戶端的接入 leaf/go 用於建立能夠被 Leaf 管理的 goroutine leaf/log 日誌相關 leaf/network 網路相關,使用 TCP 和 WebSocket 協議,可自定義訊息格式,預設 Leaf 提供了基於 protobuf 和 JSON 的訊息格式 leaf/recordfile 用於管理遊戲資料 leaf/timer 定時器相關 leaf/util 輔助庫 使用 Leaf 開發遊戲伺服器LeafServer 是一個基於 Leaf 開發的遊戲伺服器,我們以 LeafServer 作為起點。
獲取 LeafServer:
git clone https:
設定 leafserver 目錄到 GOPATH 環境變數後獲取 Leaf:
go get github.com/name5566/leaf
編譯 LeafServer:
go install server
如果一切順利,執行 server 你可以獲得以下輸出:
2015/08/26 22:11:27 [release ] Leaf 1.1 .2 starting up
敲擊 Ctrl + C 關閉遊戲伺服器,伺服器正常關閉輸出:
2015 /08 /26 22 :12 :30 [release] Leaf closing down (signal: interrupt )
Hello Leaf現在,在 LeafServer 的基礎上,我們來看看遊戲伺服器如何接收和處理網路訊息。
首先定義一個 JSON 格式的訊息(protobuf 類似)。開啟 LeafServer msg/msg.go 檔案可以看到如下程式碼:
import (
“ github.com/name5566/leaf/network ”
)
var Processor network.Processor
func init () {
}
Processor 為訊息的處理器(可由使用者自定義),這裡我們使用 Leaf 預設提供的 JSON 訊息處理器並嘗試新增一個名字為 Hello 的訊息:
import (
“ github.com/name5566/leaf/network/json ”
)
// 使用預設的 JSON 訊息處理器(預設還提供了 protobuf 訊息處理器)
var Processor = json.NewProcessor ()
func init () {
// 這裡我們註冊了一個 JSON 訊息 Hello
Processor.Register (&Hello{})
}
// 一個結構體定義了一個 JSON 訊息的格式
// 訊息名為 Hello
type Hello struct {
Name string
}
客戶端傳送到遊戲伺服器的訊息需要通過 gate 模組路由,簡而言之,gate 模組決定了某個訊息具體交給內部的哪個模組來處理。這裡,我們將 Hello 訊息路由到 game 模組中。開啟 LeafServer gate/router.go,敲入如下程式碼:
import (
“ server/game”
“ server/msg”
)
func init () {
// 這裡指定訊息 Hello 路由到 game 模組
// 模組間使用 ChanRPC 通訊,訊息路由也不例外
msg.Processor .SetRouter (&msg.Hello {}, game.ChanRPC )
}
一切就緒,我們現在可以在 game 模組中處理 Hello 訊息了。開啟 LeafServer game/internal/handler.go,敲入如下程式碼:
import (
“ github.com/name5566/leaf/log ”
“ github.com/name5566/leaf/gate ”
“ reflect”
“ server/msg”
)
func init () {
// 向當前模組(game 模組)註冊 Hello 訊息的訊息處理函式 handleHello
handler (&msg.Hello {}, handleHello)
}
func handler (m interface {}, h interface {}) {
skeleton.RegisterChanRPC (reflect.TypeOf (m), h)
}
func handleHello (args []interface {}) {
// 收到的 Hello 訊息
m := args[0 ].(*msg.Hello )
// 訊息的傳送者
a := args[1 ].(gate.Agent )
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 輸出收到的訊息的內容</span>
log.<span class="pl-c1" style="color:rgb(0,92,197);">Debug</span>(<span class="pl-s" style="color:rgb(3,47,98);"><span class="pl-pds">"</span>hello <span class="pl-c1" style="color:rgb(0,92,197);">%v</span><span class="pl-pds">"</span></span>, m.<span class="pl-smi">Name</span>)
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 給傳送者回應一個 Hello 訊息</span>
a.<span class="pl-c1" style="color:rgb(0,92,197);">WriteMsg</span>(&msg.<span class="pl-smi">Hello</span>{
Name: <span class="pl-s" style="color:rgb(3,47,98);"><span class="pl-pds">"</span>client<span class="pl-pds">"</span></span>,
})
}
到這裡,一個簡單的範例就完成了。為了更加清楚的瞭解訊息的格式,我們從 0 編寫一個最簡單的測試客戶端。
Leaf 中,當選擇使用 TCP 協議時,在網路中傳輸的訊息都會使用以下格式:
其中:
len 表示了 data 部分的長度(位元組數)。len 本身也有長度,預設為 2 位元組(可配置),len 本身的長度決定了單個訊息的最大大小 data 部分使用 JSON 或者 protobuf 編碼(也可自定義其他編碼方式) 測試客戶端同樣使用 Go 語言編寫:
import (
“ encoding/binary”
“ net”
)
func main () {
conn , err := net.Dial (“ tcp” , “ 127.0.0.1:3563” )
if err != nil {
panic (err)
}
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> Hello 訊息(JSON 格式)</span>
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 對應遊戲伺服器 Hello 訊息結構體</span>
<span class="pl-smi">data</span> <span class="pl-k" style="color:rgb(215,58,73);">:=</span> []<span class="pl-k" style="color:rgb(215,58,73);">byte</span>(<span class="pl-s" style="color:rgb(3,47,98);"><span class="pl-pds">`</span>{</span>
“Hello”: {
“Name”: “leaf”
}
}` )
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> len + data</span>
<span class="pl-smi">m</span> <span class="pl-k" style="color:rgb(215,58,73);">:=</span> <span class="pl-c1" style="color:rgb(0,92,197);">make</span>([]<span class="pl-k" style="color:rgb(215,58,73);">byte</span>, <span class="pl-c1" style="color:rgb(0,92,197);">2</span>+<span class="pl-c1" style="color:rgb(0,92,197);">len</span>(data))
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 預設使用大端序</span>
binary.<span class="pl-smi">BigEndian</span>.<span class="pl-c1" style="color:rgb(0,92,197);">PutUint16</span>(m, <span class="pl-c1" style="color:rgb(0,92,197);">uint16</span>(<span class="pl-c1" style="color:rgb(0,92,197);">len</span>(data)))
<span class="pl-c1" style="color:rgb(0,92,197);">copy</span>(m[<span class="pl-c1" style="color:rgb(0,92,197);">2</span>:], data)
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 傳送訊息</span>
conn.<span class="pl-c1" style="color:rgb(0,92,197);">Write</span>(m)
}
執行此測試客戶端,遊戲伺服器輸出:
2015 /09 /25 07 :41 :03 [debug ] hello leaf
2015 /09 /25 07 :41 :03 [debug ] read message : read tcp 127.0 .0.1 :3563 ->127.0 .0.1 :54599 : wsarecv: An existing connection was forcibly closed by the remote host.
測試客戶端傳送完訊息以後就退出了,此時和遊戲伺服器的連線斷開,相應的,遊戲伺服器輸出連線斷開的提示日誌(第二條日誌,日誌的具體內容和 Go 語言版本有關)。
除了使用 TCP 協議外,還可以選擇使用 WebSocket 協議(例如開發 H5 遊戲)。Leaf 可以單獨使用 TCP 協議或 WebSocket 協議,也可以同時使用兩者,換而言之,伺服器可以同時接受 TCP 連線和 WebSocket 連線,對開發者而言訊息來自 TCP 還是 WebSocket 是完全透明的。現在,我們來編寫一個對應上例的使用 WebSocket 協議的客戶端:
<script type =“ text/javascript” >
var ws = new WebSocket (‘ ws://127.0.0.1:3653’ )
ws .onopen = function () {
// 傳送 Hello 訊息
ws .send (JSON .stringify ({Hello: {
Name: ‘ leaf’
}}))
}
< /script > 儲存上述程式碼到某 HTML 檔案中並使用(任意支援 WebSocket 協議的)瀏覽器開啟。在開啟此 HTML 檔案前,首先需要配置一下 LeafServer 的 bin/conf/server.json 檔案,增加 WebSocket 監聽地址(WSAddr):
{
“ LogLevel” : “ debug” ,
“ LogPath” : “ ” ,
“ TCPAddr” : “ 127.0.0.1:3563” ,
“ WSAddr” : “ 127.0.0.1:3653” ,
“ MaxConnNum” : 20000
} 重啟遊戲伺服器後,方可接受 WebSocket 訊息:
2015/09/25 07:50:03 [debug ] hello leaf
在 Leaf 中使用 WebSocket 需要注意的一點是:Leaf 總是傳送二進位制訊息而非文字訊息。
Leaf 模組詳解LeafServer 中包含了 3 個模組,它們分別是:
gate 模組,負責遊戲客戶端的接入 login 模組,負責登入流程 game 模組,負責遊戲主邏輯 一般來說(而非強制規定),從程式碼結構上,一個 Leaf 模組:
放置於一個目錄中(例如 game 模組放置於 game 目錄中) 模組的具體實現放置於 internal 包中(例如 game 模組的具體實現放置於 game/internal 包中) 每個模組下一般有一個 external.go 的檔案,顧名思義表示模組對外暴露的介面,這裡以 game 模組的 external.go 檔案為例:
import (
“ server/game/internal”
)
var (
// 例項化 game 模組
Module = new (internal.Module )
// 暴露 ChanRPC
ChanRPC = internal.ChanRPC
)
首先,模組會被例項化,這樣才能註冊到 Leaf 框架中(詳見 LeafServer main.go),另外,模組暴露的 ChanRPC 被用於模組間通訊。
進入 game 模組的內部(LeafServer game/internal/module.go):
import (
“ github.com/name5566/leaf/module ”
“ server/base”
)
var (
skeleton = base.NewSkeleton ()
ChanRPC = skeleton.ChanRPCServer
)
type Module struct {
*module.Skeleton
}
func (m *Module ) OnInit () {
m.Skeleton = skeleton
}
func (m *Module ) OnDestroy () {
}
模組中最關鍵的就是 skeleton(骨架),skeleton 實現了 Module 介面的 Run 方法並提供了:
Leaf ChanRPC由於 Leaf 中,每個模組跑在獨立的 goroutine 上,為了模組間方便的相互呼叫就有了基於 channel 的 RPC 機制。一個 ChanRPC 需要在遊戲伺服器初始化的時候進行註冊(註冊過程不是 goroutine 安全的),例如 LeafServer 中 game 模組註冊了 NewAgent 和 CloseAgent 兩個 ChanRPC:
import (
“ github.com/name5566/leaf/gate ”
)
func init () {
skeleton.RegisterChanRPC (“ NewAgent” , rpcNewAgent)
skeleton.RegisterChanRPC (“ CloseAgent” , rpcCloseAgent)
}
func rpcNewAgent (args []interface {}) {
}
func rpcCloseAgent (args []interface {}) {
}
使用 skeleton 來註冊 ChanRPC。RegisterChanRPC 的第一個引數是 ChanRPC 的名字,第二個引數是 ChanRPC 的實現。這裡的 NewAgent 和 CloseAgent 會被 LeafServer 的 gate 模組在連線建立和連線中斷時呼叫。ChanRPC 的呼叫方有 3 種呼叫模式:
同步模式,呼叫並等待 ChanRPC 返回 非同步模式,呼叫並提供回撥函式,回撥函式會在 ChanRPC 返回後被呼叫 Go 模式,呼叫並立即返回,忽略任何返回值和錯誤 gate 模組這樣呼叫 game 模組的 NewAgent ChanRPC(這僅僅是一個示例,實際的程式碼細節複雜的多):
game.ChanRPC .Go (“ NewAgent” , a) 這裡呼叫 NewAgent 並傳遞引數 a,我們在 rpcNewAgent 的引數 args[0] 中可以取到 a(args[1] 表示第二個引數,以此類推)。
更加詳細的用法可以參考 leaf/chanrpc 。需要注意的是,無論封裝多麼精巧,跨 goroutine 的呼叫總不能像直接的函式呼叫那樣簡單直接,因此除非必要我們不要構建太多的模組,模組間不要太頻繁的互動。模組在 Leaf 中被設計出來最主要是用於劃分功能而非利用多核,Leaf 認為在模組內按需使用 goroutine 才是多核利用率問題的解決之道。
Leaf Go善用 goroutine 能夠充分利用多核資源,Leaf 提供的 Go 機制解決了原生 goroutine 存在的一些問題:
能夠恢復 goroutine 執行過程中的錯誤 遊戲伺服器會等待所有 goroutine 執行結束後才關閉 非常方便的獲取 goroutine 執行的結果資料 在一些特殊場合保證 goroutine 按建立順序執行 我們來看一個例子(可以在 LeafServer 的模組的 OnInit 方法中測試):
// 定義變數 res 接收結果
var res string
skeleton.Go (func () {
// 這裡使用 Sleep 來模擬一個很慢的操作
time.Sleep (1 * time.Second )
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 假定得到結果</span>
res = <span class="pl-s" style="color:rgb(3,47,98);"><span class="pl-pds">"</span>3<span class="pl-pds">"</span></span>
}, func () {
log.Debug (res)
})
log.Debug (“ 2” )
上面程式碼執行結果如下:
2015 /08 /27 20 :37 :17 [debug ] 1
2015 /08 /27 20 :37 :17 [debug ] 2
2015 /08 /27 20 :37 :18 [debug ] 3 這裡的 Go 方法接收 2 個函式作為引數,第一個函式會被放置在一個新建立的 goroutine 中執行,在其執行完成之後,第二個函式會在當前 goroutine 中被執行。由此,我們可以看到變數 res 同一時刻總是隻被一個 goroutine 訪問,這就避免了同步機制的使用。Go 的設計使得 CPU 得到充分利用,避免操作阻塞當前 goroutine,同時又無需為共享資源同步而憂心。
更加詳細的用法可以參考 leaf/go 。
Leaf timerGo 語言標準庫提供了定時器的支援:
func AfterFunc (d Duration , f func ()) *TimerAfterFunc 會等待 d 時長後呼叫 f 函式,這裡的 f 函式將在另外一個 goroutine 中執行。Leaf 提供了一個相同的 AfterFunc 函式,相比之下,f 函式在 AfterFunc 的呼叫 goroutine 中執行,這樣就避免了同步機制的使用:
skeleton.AfterFunc (5 * time.Second , func () {
// …
}) 另外,Leaf timer 還支援 cron 表示式 ,用於實現諸如“每天 9 點執行”、“每週末 6 點執行”的邏輯。
更加詳細的用法可以參考 leaf/timer 。
Leaf logLeaf 的 log 系統支援多種日誌級別:
Debug 日誌,非關鍵日誌 Release 日誌,關鍵日誌 Error 日誌,錯誤日誌 Fatal 日誌,致命錯誤日誌 Debug < Release < Error < Fatal(日誌級別高低)
在 LeafServer 中,bin/conf/server.json 可以配置日誌級別,低於配置的日誌級別的日誌將不會輸出。Fatal 日誌比較特殊,每次輸出 Fatal 日誌之後遊戲伺服器程式就會結束,通常來說,只在遊戲伺服器初始化失敗時使用 Fatal 日誌。
我們還可以通過配置 LeafServer conf/conf.go 的 LogFlag 來在日誌中輸出檔名和行號:
LogFlag = log .Lshortfile
可用的 LogFlag 見:https://golang.org/pkg/log/#pkg-constants
更加詳細的用法可以參考 leaf/log 。
Leaf recordfileLeaf 的 recordfile 是基於 CSV 格式(範例見這裡 )。recordfile 用於管理遊戲配置資料。在 LeafServer 中使用 recordfile 非常簡單:
將 CSV 檔案放置於 bin/gamedata 目錄中 在 gamedata 模組中呼叫函式 readRf 讀取 CSV 檔案 範例:
// 確保 bin/gamedata 目錄中存在 Test.txt 檔案
// 檔名必須和此結構體名稱相同(大小寫敏感)
// 結構體的一個例項對映 recordfile 中的一行
type Test struct {
// 將第一列按 int 型別解析
// “index” 表明在此列上建立唯一索引
Id int “ index”
// 將第二列解析為長度為 4 的整型陣列
Arr [4 ]int
// 將第三列解析為字串
Str string
}
// 讀取 recordfile Test.txt 到記憶體中
// RfTest 即為 Test.txt 的記憶體映象
var RfTest = readRf (Test{})
func init () {
// 按索引查詢
// 獲取 Test.txt 中 Id 為 1 的那一行
r := RfTest.Index (1 )
<span class="pl-k" style="color:rgb(215,58,73);">if</span> r != <span class="pl-c1" style="color:rgb(0,92,197);">nil</span> {
<span class="pl-smi">row</span> <span class="pl-k" style="color:rgb(215,58,73);">:=</span> r.(*Test)
<span class="pl-c" style="color:rgb(106,115,125);"><span class="pl-c">//</span> 輸出此行的所有列的資料</span>
log.<span class="pl-c1" style="color:rgb(0,92,197);">Debug</span>(<span class="pl-s" style="color:rgb(3,47,98);"><span class="pl-pds">"</span><span class="pl-c1" style="color:rgb(0,92,197);">%v</span> <span class="pl-c1" style="color:rgb(0,92,197);">%v</span> <span class="pl-c1" style="color:rgb(0,92,197);">%v</span><span class="pl-pds">"</span></span>, row.<span class="pl-smi">Id</span>, row.<span class="pl-smi">Arr</span>, row.<span class="pl-smi">Str</span>)
}
}
更加詳細的用法可以參考 leaf/recordfile 。
<script>
(function(){
function setArticleH(btnReadmore,posi){
var winH = $(window).height();
var articleBox = $("div.article_content");
var artH = articleBox.height();
if(artH > winH*posi){
articleBox.css({
'height':winH*posi+'px',
'overflow':'hidden'
})
btnReadmore.click(function(){
articleBox.removeAttr("style");
$(this).parent().remove();
})
}else{
btnReadmore.parent().remove();
}
}
var btnReadmore = $("#btn-readmore");
if(btnReadmore.length>0){
if(currentUserName){
setArticleH(btnReadmore,3);
}else{
setArticleH(btnReadmore,1.2);
}
}
})()
</script>
</article>