轉自:https://toutiao.io/posts/0l7l7n/previewjavascript
Leaf 是一個由 Go 語言(golang)編寫的開發效率和執行效率並重的開源遊戲服務器框架。Leaf 適用於各種遊戲服務器的開發,包括 H5(HTML5)遊戲服務器。html
Leaf 的關注點:java
一個 Leaf 開發的遊戲服務器由多個模塊組成(例如 LeafServer),模塊有如下特色:git
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 方法。數據庫
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)
如今,在 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 | --------------
其中:
測試客戶端一樣使用 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 老是發送二進制消息而非文本消息。
LeafServer 中包含了 3 個模塊,它們分別是:
通常來講(而非強制規定),從代碼結構上,一個 Leaf 模塊:
每一個模塊下通常有一個 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 方法並提供了:
因爲 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 種調用模式:
gate 模塊這樣調用 game 模塊的 NewAgent ChanRPC(這僅僅是一個示例,實際的代碼細節複雜的多):
game.ChanRPC.Go("NewAgent", a)
這裏調用 NewAgent 並傳遞參數 a,咱們在 rpcNewAgent 的參數 args[0] 中能夠取到 a(args[1] 表示第二個參數,以此類推)。
更加詳細的用法能夠參考 leaf/chanrpc。須要注意的是,不管封裝多麼精巧,跨 goroutine 的調用總不能像直接的函數調用那樣簡單直接,所以除非必要咱們不要構建太多的模塊,模塊間不要太頻繁的交互。模塊在 Leaf 中被設計出來最主要是用於劃分功能而非利用多核,Leaf 認爲在模塊內按需使用 goroutine 纔是多核利用率問題的解決之道。
善用 goroutine 可以充分利用多核資源,Leaf 提供的 Go 機制解決了原生 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。
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 系統支持多種日誌級別:
Debug < Release < Error < Fatal(日誌級別高低)
在 LeafServer 中,bin/conf/server.json 能夠配置日誌級別,低於配置的日誌級別的日誌將不會輸出。Fatal 日誌比較特殊,每次輸出 Fatal 日誌以後遊戲服務器進程就會結束,一般來講,只在遊戲服務器初始化失敗時使用 Fatal 日誌。
更加詳細的用法能夠參考 leaf/log。
Leaf 的 recordfile 是基於 CSV 格式(範例見這裏)。recordfile 用於管理遊戲配置數據。在 LeafServer 中使用 recordfile 很是簡單:
範例:
// 確保 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。