Leaf 游戲服務器框架簡介

jopen 9年前發布 | 32K 次閱讀 游戲開發 LEAF

Leaf 游戲服務器框架簡介

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

Leaf 的關注點:

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

    Leaf 的模塊機制

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

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

      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 輔助庫
      • </ul>

        使用 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.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() {

        }</pre>
        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 }</pre>

        客戶端發送到游戲服務器的消息需要通過 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) }</pre>

        一切就緒,我們現在可以在 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",
        })
        

        }</pre>

        到這里,一個簡單的范例就完成了。為了更加清楚的了解消息的格式,我們從 0 編寫一個最簡單的測試客戶端。

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

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

        其中:

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

          測試客戶端同樣使用 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)
          

          }</pre>

          執行此測試客戶端,游戲服務器輸出:

          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></pre>

          保存上述代碼到某 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 模塊,負責游戲主邏輯
          • </ul>

            一般來說(而非強制規定),從代碼結構上,一個 Leaf 模塊:

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

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

              package game

              import ( "server/game/internal" )

              var ( // 實例化 game 模塊 Module = new(internal.Module) // 暴露 ChanRPC ChanRPC = internal.ChanRPC )</pre>

              首先,模塊會被實例化,這樣才能注冊到 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() {

              }</pre>

              模塊中最關鍵的就是 skeleton(骨架),skeleton 實現了 Module 接口的 Run 方法并提供了:

              • ChanRPC
              • goroutine
              • 定時器
              • </ul>

                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{}) {

                }</pre>

                使用 skeleton 來注冊 ChanRPC。RegisterChanRPC 的第一個參數是 ChanRPC 的名字,第二個參數是 ChanRPC 的實現。這里的 NewAgent 和 CloseAgent 會被 LeafServer 的 gate 模塊在連接建立和連接中斷時調用。ChanRPC 的調用方有 3 種調用模式:

                1. 同步模式,調用并等待 ChanRPC 返回
                2. 異步模式,調用并提供回調函數,回調函數會在 ChanRPC 返回后被調用
                3. Go 模式,調用并立即返回,忽略任何返回值和錯誤
                4. </ol>

                  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 按創建順序執行
                  • </ul>

                    我們來看一個例子(可以在 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")</pre>

                    上面代碼執行結果如下:

                    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 日志,致命錯誤日志
                    5. </ol>

                      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 文件
                      3. </ol>

                        范例:

                        // 確保 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)
                        }
                        

                        }</pre>

                        更加詳細的用法可以參考 leaf/recordfile

                        寫在最后的話

                        本文雖然未能全面描述 Leaf 服務器框架的方方面面,但整體輪廓已經顯現。Leaf 尚小,但已經足夠構建一個完整游戲服務器。在對開發效率的執著追求上,Leaf 還有很長的路需要走。

                        來自:https://github.com/name5566/leaf/blob/master/TUTORIAL_ZH.md

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!