Leaf - 一個由 Go 語言編寫的開發效率和執行效率並重的開源遊戲服務器框架

轉自:https://toutiao.io/posts/0l7l7n/previewjavascript

Leaf 遊戲服務器框架簡介

Leaf 是一個由 Go 語言(golang)編寫的開發效率和執行效率並重的開源遊戲服務器框架。Leaf 適用於各種遊戲服務器的開發,包括 H5(HTML5)遊戲服務器。html

Leaf 的關注點:java

  • 良好的使用體驗。Leaf 老是儘量的提供簡潔和易用的接口,儘量的提高開發的效率
  • 穩定性。Leaf 老是儘量的恢復運行過程當中的錯誤,避免崩潰
  • 多核支持。Leaf 經過模塊機制和 leaf/go 儘量的利用多核資源,同時又儘可能避免各類反作用
  • 模塊機制。

Leaf 的模塊機制

一個 Leaf 開發的遊戲服務器由多個模塊組成(例如 LeafServer),模塊有如下特色:git

  • 每一個模塊運行在一個單獨的 goroutine 中
  • 模塊間經過一套輕量的 RPC 機制通信(leaf/chanrpc

Leaf 不建議在遊戲服務器中設計過多的模塊。github

遊戲服務器在啓動時進行模塊的註冊,例如:golang

leaf.Run(
    game.Module, gate.Module, login.Module, )

這裏按順序註冊了 game、gate、login 三個模塊。每一個模塊都須要實現接口:mongodb

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 做爲起點。json

獲取 LeafServer:數組

git clone https://github.com/name5566/leafserver

設置 leafserver 目錄到 GOPATH 環境變量後獲取 Leaf:

go get github.com/name5566/leaf

編譯 LeafServer:

go install server

若是一切順利,運行 server 你能夠得到如下輸出:

2015/08/26 22:11:27 [release] Leaf 1.1.1 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 文件能夠看到以下代碼:

package msg import ( "github.com/name5566/leaf/network" ) var Processor network.Processor func init() { }

Processor 爲消息的處理器(可由用戶自定義),這裏咱們使用 Leaf 默認提供的 JSON 消息處理器並嘗試添加一個名字爲 Hello 的消息:

package msg 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,敲入以下代碼:

package gate import ( "server/game" "server/msg" ) func init() { // 這裏指定消息 Hello 路由到 game 模塊 // 模塊間使用 ChanRPC 通信,消息路由也不例外 msg.Processor.SetRouter(&msg.Hello{}, game.ChanRPC) }

一切就緒,咱們如今能夠在 game 模塊中處理 Hello 消息了。打開 LeafServer game/internal/handler.go,敲入以下代碼:

package internal 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) // 輸出收到的消息的內容 log.Debug("hello %v", m.Name) // 給發送者回應一個 Hello 消息 a.WriteMsg(&msg.Hello{ Name: "client", }) }

到這裏,一個簡單的範例就完成了。爲了更加清楚的瞭解消息的格式,咱們從 0 編寫一個最簡單的測試客戶端。

Leaf 中,當選擇使用 TCP 協議時,在網絡中傳輸的消息都會使用如下格式:

--------------
| len | data |
--------------

其中:

  1. len 表示了 data 部分的長度(字節數)。len 自己也有長度,默認爲 2 字節(可配置),len 自己的長度決定了單個消息的最大大小
  2. data 部分使用 JSON 或者 protobuf 編碼(也可自定義其餘編碼方式)

測試客戶端一樣使用 Go 語言編寫:

package main import ( "encoding/binary" "net" ) func main() { conn, err := net.Dial("tcp", "127.0.0.1:3563") if err != nil { panic(err) } // Hello 消息(JSON 格式) // 對應遊戲服務器 Hello 消息結構體 data := []byte(`{  "Hello": {  "Name": "leaf"  }  }`) // len + data m := make([]byte, 2+len(data)) // 默認使用大端序 binary.BigEndian.PutUint16(m, uint16(len(data))) copy(m[2:], data) // 發送消息 conn.Write(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 模塊:

  1. 放置於一個目錄中(例如 game 模塊放置於 game 目錄中)
  2. 模塊的具體實現放置於 internal 包中(例如 game 模塊的具體實現放置於 game/internal 包中)

每一個模塊下通常有一個 external.go 的文件,顧名思義表示模塊對外暴露的接口,這裏以 game 模塊的 external.go 文件爲例:

package game 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):

package internal 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 方法並提供了:

  • ChanRPC
  • goroutine
  • 定時器

Leaf ChanRPC

因爲 Leaf 中,每一個模塊跑在獨立的 goroutine 上,爲了模塊間方便的相互調用就有了基於 channel 的 RPC 機制。一個 ChanRPC 須要在遊戲服務器初始化的時候進行註冊(註冊過程不是 goroutine 安全的),例如 LeafServer 中 game 模塊註冊了 NewAgent 和 CloseAgent 兩個 ChanRPC:

package internal 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 種調用模式:

  1. 同步模式,調用並等待 ChanRPC 返回
  2. 異步模式,調用並提供回調函數,回調函數會在 ChanRPC 返回後被調用
  3. 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 方法中測試):

log.Debug("1") // 定義變量 res 接收結果 var res string skeleton.Go(func() { // 這裏使用 Sleep 來模擬一個很慢的操做 time.Sleep(1 * time.Second) // 假定獲得結果 res = "3" }, 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 timer

Go 語言標準庫提供了定時器的支持:

func AfterFunc(d Duration, f func()) *Timer

AfterFunc 會等待 d 時長後調用 f 函數,這裏的 f 函數將在另一個 goroutine 中執行。Leaf 提供了一個相同的 AfterFunc 函數,相比之下,f 函數在 AfterFunc 的調用 goroutine 中執行,這樣就避免了同步機制的使用:

skeleton.AfterFunc(5 * time.Second, func() { // ... })

另外,Leaf timer 還支持 cron 表達式,用於實現諸如「天天 9 點執行」、「每週末 6 點執行」的邏輯。

更加詳細的用法能夠參考 leaf/timer

Leaf log

Leaf 的 log 系統支持多種日誌級別:

  1. Debug 日誌,非關鍵日誌
  2. Release 日誌,關鍵日誌
  3. Error 日誌,錯誤日誌
  4. Fatal 日誌,致命錯誤日誌

Debug < Release < Error < Fatal(日誌級別高低)

在 LeafServer 中,bin/conf/server.json 能夠配置日誌級別,低於配置的日誌級別的日誌將不會輸出。Fatal 日誌比較特殊,每次輸出 Fatal 日誌以後遊戲服務器進程就會結束,一般來講,只在遊戲服務器初始化失敗時使用 Fatal 日誌。

更加詳細的用法能夠參考 leaf/log

Leaf recordfile

Leaf 的 recordfile 是基於 CSV 格式(範例見這裏)。recordfile 用於管理遊戲配置數據。在 LeafServer 中使用 recordfile 很是簡單:

  1. 將 CSV 文件放置於 bin/gamedata 目錄中
  2. 在 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) if r != nil { row := r.(*Test) // 輸出此行的全部列的數據 log.Debug("%v %v %v", row.Id, row.Arr, row.Str) } }

更加詳細的用法能夠參考 leaf/recordfile

相關文章
相關標籤/搜索