仔细看了下Agent 是一个接口

也就是理解错了

找agent的Run 在gate.go下面

照例分析一波

首先还是个无限循环

data, err := a.conn.ReadMsg()

看代码的意思是把数据读出来放到data里面去 那就去看下

在看

这里就不细看了总之就是把conn里的数据包解包然后放到一个[]byte里

返回继续看

然后在看 这个在protobuf.go下面

这是解包

这里看的脑壳疼 在结合这个页面里的关系看

大概意思是先判断包的长度不够len长度的肯定直接pass

id这里

先判断大小端读取方式 在加个安全判断 数据不对的直接pass

然后处理msg

这里要把结构拉出来看

这里还是先放一放把 不看这么细了,后面在看一章加解包的

总之这个

agent的Run 就是解包然后放到

agent.gate.Processor 里面

gate模块的分析就此结束

总结下 gate模块工作就是开启 一个ws 和TCPserver 开启端口进行监听

将监听到的数据解包好放到Agent里面 传给game模块的通道里

现在在回头看下leaf官方的文档立刻清晰明了了

在看完这个 就可以开始做自己的服务器了 可能要得分析下game模块

基础知识参考Golang 游戏架构简介
本系列参考github leaf的官方wiki及issues

本文参考
Leaf 代码阅读
Leaf 游戏服务器框架简介

一、概述

1.Leaf 服务器的设计

Leaf专注于游戏服务器,因此与一些Web服务器开发的设计和考虑有所不同。

在一些游戏服务器中,采用的是分布式架构,即服务器整体被划分为不同的模块,各个模块承担不同的功能,而模块之间通过TCP进行交互。这样的架构能够保证服务器能够在多台机器上部署,单点故障不至于让服务整体崩溃。但是这种服务器有其自身的开发难度,而且有时候做好模块划分并不容易。

Leaf是一体式的框架,连最外围的接入服务器也被整合在一起。虽然Leaf中间也划分了不同模块,但是他们是通过InnerRpc进行通讯的。介于现在手游兴起,单机性能提升,不少游戏服务器所需要的性能并不十分苛刻,所以Leaf在这方面的简洁与易于开发有很大的优势。

一个 Leaf 开发的游戏服务器由多个模块组成(例如 LeafServer),模块有以下特点:

  • 每个模块运行在一个单独的 goroutine 中
  • 模块间通过一套轻量的 RPC 机制通讯(leaf/chanrpc)

Leaf 不建议在游戏服务器中设计过多的模块。无论封装多么精巧,跨 goroutine 的调用总不能像直接的函数调用那样简单直接,因此除非必要我们不要构建太多的模块,模块间不要太频繁的交互。模块在 Leaf 中被设计出来最主要是用于划分功能而非利用多核,Leaf 认为在模块内按需使用 goroutine 才是多核利用率问题的解决之道

游戏服务器在启动时进行模块的注册,例如:

这里按顺序注册了 game、gate、login 三个模块。每个模块都需要实现接口:

Leaf 首先会在同一个 goroutine 中按模块注册顺序执行模块的 OnInit 方法,等到所有模块 OnInit 方法执行完成后则为每一个模块启动一个 goroutine 并执行模块的 Run 方法。最后,游戏服务器关闭时(Ctrl + C 关闭游戏服务器)将按模块注册相反顺序在同一个 goroutine 中执行模块的 OnDestroy 方法。

与一些Web服务器不同,Leaf运行的数据绝大部分都在内存里面,虽然提供了Mongo模块,但是做实时交互的数据一般是保存在内存中的。Mongo只是为了持久化一些用户数据。这与有些无状态,靠数据库做数据交互的Web服务器有很大不同。

二、官方示例

参考
Leaf 游戏服务器框架简介,介绍了官方的leafServer

1.消息处理

首先定义一个 JSON 格式的消息(protobuf 类似)。Processor 为消息的处理器(可由用户自定义),这里我们使用 Leaf 默认提供的 JSON 消息处理器并尝试添加一个名字为 Hello 的消息:

客户端发送到游戏服务器的消息需要通过 gate 模块路由,简而言之,gate 模块决定了某个消息具体交给内部的哪个模块来处理。这里,我们将 Hello 消息路由到 game 模块中。打开 LeafServer gate/router.go,敲入如下代码:

一切就绪,我们现在可以在 game 模块中处理 Hello 消息了。打开 LeafServer game/internal/handler.go,敲入如下代码:

到这里,一个简单的范例就完成了。为了更加清楚的了解消息的格式,我们从 0 编写一个最简单的测试客户端。

Leaf 中,当选择使用 TCP 协议时,在网络中传输的消息都会使用以下格式:

其中:

  • len 表示了 data 部分的长度(字节数)。len 本身也有长度,默认为 2 字节(可配置),len 本身的长度决定了单个消息的最大大小
  • data 部分使用 JSON 或者 protobuf 编码(也可自定义其他编码方式)

测试客户端同样使用 Go 语言编写:

2015/09/25 07:41:03 [debug ] hello leaf2015/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 协议的客户端:

保存上述代码到某 HTML 文件中并使用(任意支持 WebSocket 协议的)浏览器打开。在打开此 HTML 文件前,首先需要配置一下 LeafServer 的 bin/conf/server.json 文件,增加 WebSocket 监听地址(WSAddr):

重启游戏服务器后,方可接受 WebSocket 消息:

在 Leaf 中使用 WebSocket 需要注意的一点是:Leaf 总是发送二进制消息而非文本消息。

2.模块结构

一般来说(而非强制规定),从代码结构上,一个 Leaf 模块:

  • 放置于一个目录中(例如 game 模块放置于 game 目录中)
  • 模块的具体实现放置于 internal 包中(例如 game 模块的具体实现放置于 game/internal 包中)


image.png

每个模块下一般有一个 external.go 的文件,顾名思义表示模块对外暴露的接口,这里以 game 模块的 external.go 文件为例:

首先,模块会被实例化,这样才能注册到 Leaf 框架中(详见 LeafServer main.go),另外,模块暴露的 ChanRPC 被用于模块间通讯。

进入 game 模块的内部(LeafServer game/internal/module.go):

模块中最关键的就是 skeleton(骨架),skeleton 实现了 Module 接口的 Run 方法并提供了:

  • ChanRPC
  • goroutine
  • 定时器

3.Leaf ChanRPC

由于 Leaf 中,每个模块跑在独立的 goroutine 上,为了模块间方便的相互调用就有了基于 channel 的 RPC 机制。一个 ChanRPC 需要在游戏服务器初始化的时候进行注册(注册过程不是 goroutine 安全的),例如 LeafServer 中 game 模块注册了 NewAgent 和 CloseAgent 两个 ChanRPC:

使用 skeleton 来注册 ChanRPC。RegisterChanRPC 的第一个参数是 ChanRPC 的名字,第二个参数是 ChanRPC 的实现。这里的 NewAgent 和 CloseAgent 会被 LeafServer 的 gate 模块在连接建立和连接中断时调用。ChanRPC 的调用方有 3 种调用模式:

  • 同步模式,调用并等待 ChanRPC 返回
  • 异步模式,调用并提供回调函数,回调函数会在 ChanRPC 返回后被调用
  • Go 模式,调用并立即返回,忽略任何返回值和错误

gate 模块这样调用 game 模块的 NewAgent ChanRPC(这仅仅是一个示例,实际的代码细节复杂的多):

这里调用 NewAgent 并传递参数 a,我们在 rpcNewAgent 的参数 args[0] 中可以取到 a(args[1] 表示第二个参数,以此类推)。

更加详细的用法可以参考 leaf/chanrpc。需要注意的是,无论封装多么精巧,跨 goroutine 的调用总不能像直接的函数调用那样简单直接,因此除非必要我们不要构建太多的模块,模块间不要太频繁的交互。模块在 Leaf 中被设计出来最主要是用于划分功能而非利用多核,Leaf 认为在模块内按需使用 goroutine 才是多核利用率问题的解决之道。

三、Leaf 服务器的主要模块

1.ChanRpc

Rpc是远程过程调用的简称,原来是通过Tcp手段使得一个本地的函数调用,将调用信息传递给其他服务器执行,并通过Tcp返回结果的一种技术手段,在分布式中,这种调用十分常见。但是这里的ChanRpc是在不同协程中进行函数调用,其实现的手段是Chan,所以成为ChanRpc。你可以将不同的功能模块实现为ChanRpc并提供给其他模块调用。

2.Cluster

Cluster 主要是管理集群,但是Leaf本身专注的还是单机服务,所以这个模块的功能现在还没有实现。

3.Conf

Conf 是Leaf的配置管理模块。里面主要是Leaf启动的一些必要信息。

4.Console

Console 模块为Leaf管理提供了一个终端接口,你可以使用Telnet连接上去动态的修改参数,或者指向命令。其内部实现了Help, CpuProf, Prof命令,并提供扩展,可以方便的添加其他命令。另外,扩展命令是通过ChanRpc实现的。

5.DB

DB模块提供里Mongo支持,也可以在这里聚合其他DB模块。

6.Gate

Gate 模块为Leaf提供接入功能。这个模块的功能很重要,是服务器的入口。它能同时监听TcpSocket和WebSocket。主要流程是在接入连接的时候创建一个Agent,并将这个Agent通知给AgentRpc。其核心其实是一个TcpServer和WebScoketServer,他的协议函数能够将socket字节流分包,封装为Msg传递给Agent。其工作流可以查看Server模块。

客户端发送到游戏服务器的消息需要通过 gate 模块路由,简而言之,gate 模块决定了某个消息具体交给内部的哪个模块来处理。

7.Go

用于创建能够被 Leaf 管理的 goroutine。Go模块是对golang中go提供一些额外功能。Go提供回调功能,LinearContext提供顺序调用功能。善用 goroutine 能够充分利用多核资源,Leaf 提供的 Go 机制解决了原生 goroutine 存在的一些问题:

  • 能够恢复 goroutine 运行过程中的错误
  • 游戏服务器会等待所有 goroutine 执行结束后才关闭
  • 非常方便的获取 goroutine 执行的结果数据
  • 在一些特殊场合保证 goroutine 按创建顺序执行

我们来看一个例子(可以在 LeafServer 的模块的 OnInit 方法中测试):

上面代码执行结果如下:

这里的 Go 方法接收 2 个函数作为参数,第一个函数会被放置在一个新创建的 goroutine 中执行,在其执行完成之后,第二个函数会在当前 goroutine 中被执行。由此,我们可以看到变量 res 同一时刻总是只被一个 goroutine 访问,这就避免了同步机制的使用。Go 的设计使得 CPU 得到充分利用,避免操作阻塞当前 goroutine,同时又无需为共享资源同步而忧心。

更加详细的用法可以参考 leaf/go。

8.Log

Leaf 的 log 系统支持多种日志级别:

  • Debug 日志,非关键日志
  • Release 日志,关键日志
  • Error 日志,错误日志
  • Fatal 日志,致命错误日志

Debug < Release < Error < Fatal(日志级别高低)

在 LeafServer 中,bin/conf/server.json 可以配置日志级别,低于配置的日志级别的日志将不会输出。Fatal 日志比较特殊,每次输出 Fatal 日志之后游戏服务器进程就会结束,通常来说,只在游戏服务器初始化失败时使用 Fatal 日志。

我们还可以通过配置 LeafServer conf/conf.go 的 LogFlag 来在日志中输出文件名和行号:

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

更加详细的用法可以参考 leaf/log。

9.Module

Module 为Leaf提供模块化支持。Skeleton是Leaf的整体骨架,它聚合了Leaf中其他一些异步调用模块的功能,使得各模块之间能够协同工作。

10.Network

Network是Leaf的网络部分,这部分比较大,而且包含一个json和protobuf解包模块。

11.Recordfile

Recordfile 提供序列化和反序列化为文本的功能。Leaf 的 recordfile 是基于 CSV 格式(范例见这里)。recordfile 用于管理游戏配置数据。在 LeafServer 中使用 recordfile 非常简单:

  • 将 CSV 文件放置于 bin/gamedata 目录中
  • 在 gamedata 模块中调用函数 readRf 读取 CSV 文件

范例:

更加详细的用法可以参考 leaf/recordfile。

12.Timer

Timer主要是提供一个Cron功能的定时器服务,其中Timer是time.AfterFunc的封装,是为了方便聚合到Skeleton中。

Go 语言标准库提供了定时器的支持:

AfterFunc 会等待 d 时长后调用 f 函数,这里的 f 函数将在另外一个 goroutine 中执行。Leaf 提供了一个相同的 AfterFunc 函数,相比之下,f 函数在 AfterFunc 的调用 goroutine 中执行,这样就避免了同步机制的使用:

另外,Leaf timer 还支持 cron 表达式,用于实现诸如“每天 9 点执行”、“每周末 6 点执行”的逻辑。

更加详细的用法可以参考 leaf/timer。

13.Leaf 服务器的其他设施

主要是util提供的一些功能

  • deepcopy
    进行深拷贝,建立数据快照,并同步到数据库中。
  • map
    对原始map封装,提供协程安全的访问。
  • rand
    对原始rand的封装,提供一些高级的随机函数。
  • semaphore
    用chan实现的信号量。

四、与cocos creator 结合的一个示例


这个例子在官方的leafServer基础上改的,下载代码后很容易就能把前后端跑通。主要区别是使用了protobufjs通讯方式,与leafserver相同部分不再复述。另外,前端cocos部分代码参见原文。

1.在msg文件夹下新增lobby.proto文件

生成相应的lobby.pb.go文件,基础知识可以参考Golang protobuf

2.msg.go里换成protobuf解析器

这里打印的id是不是消息ID呢,有点困惑

3.消息ID

参考在 Leaf 中使用 Protobuf

在 Leaf 中,默认的 Protobuf Processor 将一个完整的 Protobuf 消息定义为如下格式:

其中 id 为 2 个字节。如果你选择使用 TCP 协议时,在网络中传输的消息格式如下:

如果你选择使用 WebSocket 协议时,发送的消息格式如下:

其中 len 默认为两个字节,len 和 id 默认使用网络字节序。客户端需要按此格式进行编码。

首先,Protobuf id 是 Leaf 的 Protobuf Processor 自动生成的。生成规则是从 0 开始,第一个注册的消息 ID 为 0,第二个注册的消息 ID 为 1,以此类推。

可以看到每注册一个就append到切片里,然后取len做为id.

4.客户端如何正确处理消息 ID?

由于消息 ID 是服务器生成的,因此建议服务器导出消息 ID 给客户端使用。这里提供一个简单的思路,假定客户端使用 Lua,这时候服务器可以导出消息 ID 为一个 Lua 源文件,此源文件中包含一个 table,可能内容如下:

Lua 客户端加载此 Lua 源文件,得到 msg table,然后就可以从消息 ID(网络中过来的消息)获取到消息了。当然,各位同学需要按照自己的实际情况来做实际的处理。

为了导出消息 ID 给客户端,Leaf 的 Protobuf Processor 提供了方法:

通过此方法,我们可以遍历所有 Protobuf 消息,然后(比如说按上述方式)导出消息 ID 给客户端使用。

5.为什么客户端解析出来的 id 很大?

一般来说,id 很大是因为字节序问题。Leafserver 默认使用大端序,这个设定可以配置,具体修改 Leafserver 中 conf/conf.go 中 LittleEndian 配置,true 表示使用小端序,false 表示使用大端序。

6.issues 关于protobuf msg id自动生成的设计

Q:初步接触leaf,对这个id还是不能理解为什么要自动生成?这个id是由服务器消息注册顺序决定的,那如何做到向后兼容呢?比如游戏客户端发布了1.0版本,消息id m1.0. 而后服务端注册顺序发生变化了,这样客户端消息不是不兼容了吗?每次都强制客户端升级?

A:
说一下为什么选择自动生成的方式:

  • 自动生成的方式简单不容易出错
  • 如果服务端消息发生改变客户端不改变的情况很少
  • Leaf 的自动生成方案可以支持消息兼容的问题

这里主要说一下如何做到服务器和客户端消息版本不一致。消息注册的顺序决定了消息的 ID。保持兼容的几种情况:

  • 某个消息服务器不再使用的时候。保留消息注册,不进行消息路由
  • 服务器需要增加某个消息的时候。把消息注册到最后的位置
  • 这样可以保证新的消息兼容旧的客户端。

7.issues protobuf Register allows set message id

出于以下几点原因,这个修改将不被合并:

  • 修改了接口,改变了对之前程序的兼容性:之前使用自动生成 ID 的程序不但服务器需要全部重新定义 ID,并且客户端也要做相关的改动
  • 设置 ID 的需求:一定要设置 ID 不是一个充分的应该必须支持的需求,实际中,自动 ID 更加方便和可靠,容易使用,不容易出错,完全的自动化(更多讨论参考 issues)
  • 内部改为 map 而不是用数组的方式不符合性能最优的思路:使用 protobuf 而不是 JSON 等其他编解码方式,在一个程度上表示了对性能的极度热衷,ID 和消息的关联,使用数组给人更加舒服的心理感受(因为数组的访问可以秒杀 map 的查找)