golang Leaf 游戲服務器框架簡介


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

Leaf 的關注點:

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

Leaf 的模塊機制

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

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

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://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.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 文件可以看到如下代碼:

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 日志。

我們還可以通過配置 LeafServer conf/conf.go 的 LogFlag 來在日志中輸出文件名和行號:

LogFlag = log.Lshortfile

可用的 LogFlag 見:https://golang.org/pkg/log/#pkg-constants

更加詳細的用法可以參考 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

 
閱讀更多


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM