[关闭]
@Otokaze 2019-03-28T07:22:12.000000Z 字数 18481 阅读 970

游戏后端开发入门

游戏开发

客户端和服务端

游戏开发和平常的 web 开发差不多,也分为 客户端(Client)和 服务端(Server),但是和 web 开发不同的是,client 不再是浏览器,而是普通的客户端应用,这里以手游开发为例,客户端就是我们所说的各种游戏 APP,手游开发的话主要就是两个平台的 APP,也即 Android 端、IOS 端。

而游戏服务器与 web 服务器的架构其实也有很大的不同,web 服务器与 client 之间的通信基本上就是 request-response 一应一答模式,而且通常都是由 client 发起请求,然后 server 接受该请求,并进行响应的处理,最后将处理结果发送给 client 就完了。

但是游戏服务器不同,游戏服务器通常需要在多个 client 之间同步不同 client 的状态信息,client 和 server 之间一般都是 TCP 长连接,客户端可以主动向服务端发送数据,服务端也可以主动给客户端推送数据。游戏服务器还需要向一大堆客户端推送消息(广播消息),所以架构上和 web 服务器是有很大不同的;web 服务器如果要扩容,只需要横向增加硬件服务器即可(一般前面会有一个负载均衡设备,如 LVS、Nginx、HAProxy)来将客户端的请求交给后端的 web 服务器去处理。

客户端框架
这里只讨论手机游戏,主流框架有两个:

服务端框架

由于我目前主要是做 游戏服务端开发,公司用的框架是 unity3d + skynet,所以后面主要就是介绍 skynet。

流行游戏的分类

MMORPG,全称 Massively multiplayer online role-playing games,中文意思是:大型多人在线角色扮演游戏。

skynet 框架入门

skynet 是一个为网络游戏服务器设计的轻量框架。但它本身并没有任何为网络游戏业务而特别设计的部分,所以尽可以把它用于其它领域。

skynet 并不是一个开箱即用的引擎,使用它需要先对框架本身的结构有所了解,理解框架到底帮助开发者解决怎样的问题。如果你希望使用这个框架来开发网络游戏服务器,你将发现,skynet 并不会引导你把服务器搭建起来。它更像是一套工具,只有你知道你想做什么,它才会帮助你更有效率的完成。

框架

简单的 web 服务倾向于把和用户相关的状态信息(设计好数据结构)储存在数据库中,通过网络收到用户请求后,从数据库中读出关联该用户的状态信息,处理后再写回数据库。而网络游戏服务通常有更强的上下文状态,以及多个用户间更复杂的交互。如果采用相同的模式,数据库和业务处理模块间很容易出现瓶颈,这个瓶颈甚至不能通过增加一个内存 cache 层来完全解决。

在 skynet 中,用 服务 (service) 这个概念来表达某项 具体业务,它包括了 处理业务的逻辑 以及 关联的数据状态。对,使用 skynet 实现游戏服务器时,不建议把业务状态同步到数据库中,而是存放在服务的内存数据结构里。服务、连同服务处理业务的逻辑代码和业务关联的状态数据,都是常驻内存的。如果数据库是你架构的一部分,那么大多数情况下,它扮演的是一个数据备份的角色。你可以在状态改变时,把数据推到数据库保存,也可以定期写到数据库备份。业务处理时直接使用服务内的内存数据结构

由于 skynet 服务并非独立进程,所以服务间的通讯也可以被实现的高效的多。另一方面,由于这些服务同时存在于同一个 skynet 进程下,我们可以认为它们同生共死。在编写服务间协作的代码时,不用刻意考虑对方是否还活着、通讯是否可靠的问题。大多数 skynet 服务使用 lua 编写,lua 的虚拟机帮助我们隔离了服务。虽然 skynet 的基础框架设计时并没有限制服务的实现形式,理论上可以用其它语言实现 skynet 服务,但作为刚接触 skynet 的开发者,可以忽略这些细节,仅使用 Lua 做开发。

简单说,可以把 skynet 理解为一个简单的操作系统,它可以用来调度数千个 lua 虚拟机,让它们并行工作。每个 lua 虚拟机都可以接收处理其它虚拟机发送过来的消息,以及对其它虚拟机发送消息每个 lua 虚拟机,可以看成 skynet 这个操作系统下的独立进程,你可以在 skynet 工作时启动新的进程、销毁不再使用的进程、还可以通过调试控制台监管它们。skynet 同时掌控了外部的网络数据输入,和定时器的管理;它会把这些转换为一致的(类似进程间的消息)消息输入给这些进程。

例如:在网络游戏中,你可以 为每个在线用户创建一个 lua 虚拟机(skynet 称之为 lua 服务),姑且把它称为 agent。用户在不和其它用户交互而仅仅自娱自乐时,agent 完全可以满足要求。agent 在用户上线时,从数据库加载关联于它的所有数据到 lua vm 中,对用户的网络请求做出反应。当然你也可以让一个 lua 服务管理多个在线用户,每个用户是 lua 虚拟机内的一个对象。

网络

作为网络服务器框架,必然有封装好的网络层,对于 skynet 更是必不可少。由于 skynet 模拟了一个简单的操作系统,它最重要的工作就是 调度数千个服务,如何让服务挂起时,尽量减少对系统的影响就是首要解决的问题。我们不建议你在 skynet 的服务中再使用任何直接和系统网络 api 打交道的模块,因为一旦这些模块被网络 IO 阻塞,影响的就不只是该服务本身,而是 skynet 里的工作线程了。skynet 会被配置成固定数量的工作线程,工作线程数通常和系统物理核心数量相关,而 skynet 所管理的服务数量则是动态的、远超过工作线程数量。skynet 内置的网络层可以和它的服务调度器协同工作,使用 skynet 提供的网络 API 就可以在网络 IO 阻塞时,完全释放出 CPU 处理能力。

skynet 有监听 TCP 连接,对外建立 TCP 连接,收发 UDP 数据包的能力。你只需要一行代码就可以监听一个端口,接收外部 TCP 连接。当有新的连接建立时,通过一个回调函数可以获得这个新连接的句柄。之后,和普通的网络应用程序一样,你可以读写这个句柄。与你写过的不同网络应用程序不太一样的是,你还可以把这个句柄转交给 skynet 中的其它服务去处理,以获得并行能力。这有点像传统 posix 系统中,接收一个新连接后,fork 一个子进程继承这个句柄来处理的模式。但不一样的是,skynet 的服务没有父子关系。

我们通常建议使用一个 网关服务gate),专门监听端口,接受新连接。在用户身份确定后,再把真正的业务数据转交给特定的服务来处理。同时,网关还会负责按约定好的协议,把 TCP 连接上的数据流切分成一个个的包,而不需要业务处理服务来分割 TCP 数据流。业务处理的服务不必直接面对 socket 句柄,而由 skynet 正常的内部消息驱动即可。这样的网关服务,skynet 在发布版里就提供了一个,但它只是一个可选模块,你大可以不用它,或自己编写一个类似的服务以更符合你的项目需求。

客户端

skynet 完全不在意如何实现客户端应用,基于 skynet 的服务器,可以用浏览器做客户端(基于 http 或 websocket 通讯),也可以自己用 C/C++/Flash/Unity3D 等等编写客户端。你可以选用 TCP socket 建立长连接通讯,也可以使用基于 http 协议的短连接,或者基于 UDP 来通讯。这都可以自由选择,skynet 没有提供直接相关的模块,都需要你自己实现。

在 skynet 发布版的示例中,实现了一个用 C + Lua 编写的最简单的客户端 demo ,仅供参考。它基于 TCP 长连接,基础协议是用 2 字节大端字来表示每个数据包的长度,skynet 的网关服务根据这个包长度切割成业务逻辑数据包,分发给对应的内部服务处理。如果你想使用 skynet 内置的网关模块,只需要遵循这个基础的分包约定即可。

对于每个业务包的编码协议约定,在这个 demo 中,使用了一种叫 sproto 自定义协议,它包含在 skynet 的发布版中。demo 演示了 sproto 如何打包数据,解包数据。但是否使用 sproto 协议,skynet 没有任何约束。你也可以使用 json 或是 google protocol buffers 等,只要你知道怎样将对应的协议解析模块自己集成进 Lua 即可。建议在网关,或是使用一个独立服务,将网络消息解包翻译成 skynet 内部消息再转发给对应服务,内部服务不必关心网络层如何传输这些消息的。

服务

skynet 的服务使用 lua 5.3 编写。只需要把符合规范的 .lua 文件放在 skynet 可以找到的路径下就可以由其它服务启动。在 skynet 的配置文件里配置了服务查询路径,以及需要启动的第一个服务,而其它服务都是由该服务直接或间接启动的。每个服务拥有一个唯一的 32bit id ,skynet 把这个 id 称为服务地址,由 skynet 框架分配。即使服务退出,该地址也会尽可能长时间的保留,以避免当消息发向一个正准备退出的服务后,新启动的服务顶替该地址,而导致消息发向了错误的实体。

消息

每条 skynet 消息由 6 部分构成:消息类型、session 、发起服务地址 、接收服务地址 、消息 C 指针、消息长度。

每个 skynet 服务都可以处理多类消息。在 skynet 中,是用 type 这个词来区分消息的。但与其说消息类型不同,不如说更接近网络端口 (port) 这个概念。每个 skynet 服务都支持 255 个不同的 port 。消息分发函数可以根据不同的 port 来为不同的消息定制不同的消息处理流程。

skynet 预定义了一组消息类型,需要开发者关心的有:回应消息、网络消息、调试消息、文本消息、Lua 消息、错误。

集群

skynet 在最开始设计的时候,是希望把集群管理做成底层特性的。所以,每个服务的地址预留了 8bit 作为集群节点编号。最多 255 台机器可以组成一个集群,不同节点下的服务可以像同一节点进程内部那样自由的传递消息。

随着 skynet 的演进和实际项目的实践,发现其实把节点间的消息传播透明化,抹平节点间和节点进程内的消息传播的区别并不是一个好主意。在同一进程内,我们可以认为服务以及服务间的通讯都是可靠的,如果自身工作所处的硬件环境正常,那么对方也一定是正常的。而当服务部署在不同进程(不同机器)上时,不可能保证完全可靠。另外一些在同一进程内可以共享访问的内存(skynet 提供的共享数据模块就基于此)也变得不可共享,这些差异无法完全被开发者忽视。

所以,虽然 skynet 可以被配置为多节点模式,但不推荐使用。目前推荐把不同的 skynet 服务当作外部服务来对待,skynet 发布版中提供了 cluster 模块来简化开发。

编译

  1. git clone https://github.com/cloudwu/skynet.git
  2. cd skynet
  3. make linux

小结

skynet 也是单进程的服务器框架,在单一进程上启动一个线程池,其中包括多个 worker 工作线程 、一个 socket 网络线程和一个 timer 时间线程。当创建了多个 lua 服务,每个服务都相当于 ErLang 中的一个 Actor (可以简单理解为:可以并行运行的对象),每个服务都有自己的消息队列,skynet 也有一个全局的消息队列,线程池中的 worker 线程会随机从消息队列中取出消息来执行直到消息队列为空。此外,每个 lua 服务中又可以通过启动多个 coroutine(协程,所谓协程可以理解为用户态线程,协程切换不需要进入内核态,也没有所谓的锁)来实现异步操作的目的。

skynet 由一个或多个进程构成,每个进程被称为一个 skynet 节点。接下来尝试实现 skynet 节点 的启动流程。上面完成了源码编译,但是运行启动指令的时候,需要传入一个 Config 文件 名称作为启动参数,skynet 会读取这个 Config 文件 获取一些启动的必要参数,所以在运行程序之前,还需要根据要求修改配置文件,可以参考 example/config 进行修改。

  1. root = "./"
  2. thread = 8
  3. logger = nil
  4. harbor = 1
  5. address = "127.0.0.1:2526"
  6. master = "127.0.0.1:2013"
  7. start = "main"
  8. bootstrap = "snlua bootstrap"
  9. standalone = "0.0.0.0:2013"
  10. luaservice = root.."service/?.lua;"..root.."test/?.lua;"..root.."examples/?.lua"
  11. lualoader = "lualib/loader.lua"
  12. snax = root.."examples/?.lua;"..root.."test/?.lua"
  13. cpath = root.."cservice/?.so"

不难看出,这个配置文件其实是一个 lua 代码,以 key-value 形式进行赋值,skynet 启动时读取必要配置项,其他项即便用不到也会以字符串的形式存入 env 表中,所有配置项都可通过 skynet.getenv 获取。

然后在项目根目录执行 ./skynet examples/config 来启动一个 skynet 节点(进程)。例子:

  1. $ ./skynet examples/config
  2. [:01000002] LAUNCH snlua bootstrap
  3. [:01000003] LAUNCH snlua launcher
  4. [:01000004] LAUNCH snlua cmaster
  5. [:01000004] master listen socket 0.0.0.0:2013
  6. [:01000005] LAUNCH snlua cslave
  7. [:01000005] slave connect to master 127.0.0.1:2013
  8. [:01000004] connect from 127.0.0.1:36182 4
  9. [:01000006] LAUNCH harbor 1 16777221
  10. [:01000004] Harbor 1 (fd=4) report 127.0.0.1:2526
  11. [:01000005] Waiting for 0 harbors
  12. [:01000005] Shakehand ready
  13. [:01000007] LAUNCH snlua datacenterd
  14. [:01000008] LAUNCH snlua service_mgr
  15. [:01000009] LAUNCH snlua main
  16. [:01000009] Server start
  17. [:0100000a] LAUNCH snlua protoloader
  18. [:0100000b] LAUNCH snlua console
  19. [:0100000c] LAUNCH snlua debug_console 8000
  20. [:0100000c] Start debug console at 127.0.0.1:8000
  21. [:0100000d] LAUNCH snlua simpledb
  22. [:0100000e] LAUNCH snlua watchdog
  23. [:0100000f] LAUNCH snlua gate
  24. [:0100000f] Listen on 0.0.0.0:8888
  25. [:01000009] Watchdog listen on 8888
  26. [:01000009] KILL self
  27. [:01000002] KILL self
  28. ^C

skynet 主要概念

skynet 是 单进程 模型的,每个 skynet 进程在 skynet 框架中也被称为 skynet 节点,对于简单的服务器,可以只有一个 skynet 节点,当然也可以有多个 skynet 节点,skynet 框架提供了几种简单的 skynet 集群模型,可以很容易实现。

在这里就直接以一个 skynet 进程/节点为例,每个 skynet 节点都在内部维护了一个 线程池,有这些线程:worker工作线程、socket网络线程、timer定时线程。在 skynet 中,我们可以在 skynet 节点中启动和运行多个服务(service),service 一般就是指 lua-vm,当然也可以使用 C 实现一个服务(比如自带的 logger 日志服务),这里就暂且将 service 理解为 lua-vm 了,每个 service 都是互相独立的,当然每个 service 之间可以互相发送和接受消息(消息队列),而 skynet 进程就是通过调度 worker 线程、timer 线程来执行这些 service。wiki 也给了一个很贴切的形容,skynet 就是一个简单的操作系统,这个操作系统中的每个进程都是一个 lua 虚拟机,也就是我们说的 service 服务,这些 service 可以并行工作,每个 lua 虚拟机都可以接收处理其它虚拟机发送过来的消息,也可以向其它虚拟机发送消息。

通常 skynet 节点会运行和 CPU 核心数相同的线程数量,最大化的发挥多核系统的性能,每个 skynet 节点启动时都需要提供一个外部的 config 配置文件,这个配置文件本质就是一个 lua 脚本,内容都是一些 key-value 键值对。

每个服务都有一个永不重复(即使服务退出)的数字 id(也称为服务的地址,拿到一个服务的地址后就可以给这个服务发送消息)。服务之间可以自由地发送消息。每个服务都可以向 skynet 框架注册一个 callback 函数,用来接收别的服务发送给它的消息。每个服务都是被一个个消息包驱动当没有消息包到来的时候,它们就会处于挂起状态,对 CPU 资源零消耗。如果需要自主逻辑,也利用 skynet 系统提供的 timeout 消息,来定期触发(定时消息/任务)。

也就是说,skynet 节点(进程)可以管理成百上千个 service 服务,每个 service 通常都是一个 lua-vm 虚拟机,每个 service 之间可以互相发送和接收消息,通常,我们会在启动服务的时候向 skynet 注册一个 callback 回调函数,用来处理别的服务发送给我的消息,也即,service 都是由消息包来驱动的,如果当前 service 的消息队列中没有任何消息,那么这个 service 就是处于空闲状态的,不会占用任何 CPU 资源。service 是 skynet 的一等公民,就如同 java 中的 class 一样,是一个相对独立的东西,但是不同 service 又是可以互相通信的,差不多就是这样理解了。skynet 并未限定 service 要用什么语言编写,你可以自由选择 C、Lua 来实现,或者是其它语言,但通常我们使用 Lua 来编写服务。

skynet 的消息传递都是单向的,以数据包为单位传递的。并没有定义出类似 TCP 连接的概念。也没有约定 RPC 调用的协议。不规定数据包的编码方式,没有提供一致的复杂数据结构的列集 API。

skynet 原则上主张所有的服务都在同一个 OS 进程中协作完成。所以在核心层内,不考虑跨机通讯的机制,也不为单独一个服务的崩溃,重启等提供相应的支持。和普通的单线程程序一样,你要为你代码中的 bug 和意外负责。如果你的程序出了问题而崩溃,你不应该把错误藏起来,假装它们没有发生。至少,这些不是核心层做的事情。和操作系统不一样,操作系统会认为用户进程都是不可靠的,不会让一个用户进程的错误影响到另一个进程。但在 skynet 提供的框架内,所有的服务都有统一的目的,为游戏服务器的最终客户服务,如果有某个环节出了错都可能是致命的,没有必要把问题隔离开。

简单说,skynet 只负责把一个数据包从一个服务内发送出去,让同一进程内的另一个服务收到,调用对应的 callback 函数处理。skynet 保证,模块/服务的初始化过程,每个独立的 callback 调用,都是相互线程安全的。编写服务的人不需要特别得为多线程环境考虑任何问题。专心处理发送给它的一个个数据包即可。

熟悉 Erlang 的同学一眼就能明了,这就是 Erlang 的 Actor 模型(可简单地将 Actor 模型理解为可以并行的面向对象编程,没有同步锁的竞争)。只不过,我嵌入了我更为熟悉的 Lua 语言。当然,如果查阅 skynet 的代码就能发现,其实 Lua 并不是必须的,你完全可以用 C 编写服务模块。也可以方便的换成 Python 能其它可以嵌入 C 的动态语言。让 Lua 和 Python 共存也不难,这样就可以利用到我已经为 skynet 编写的一些用 Lua 实现的基础服务了。

数据包通常是在一个服务内打包生成的,Skynet 并不关心数据包是怎样被打包的,它甚至不要求这个数据包内的数据是连续的(虽然这样很危险,在后面会谈及的跨机通讯中会出错,除非你保证你的数据包绝对不被传递出当前所在的进程)。它仅仅是把数据包的指针,以及你声称的数据包长度(并不一定是真实长度)传递出去。由于服务都是在同一个进程内,接收方取得这个指针后,就可以直接处理其引用的数据了。

这个机制可以在必要时,保证绝对的零拷贝,几乎等价于在同一线程内做一次函数调用的开销。

但,这只是 Skynet 提供的性能上的可能性。它推荐的是一种更可靠,性能略低的方案:它约定,每个服务发送出去的包都是复制到用 malloc 分配出来的连续内存。接收方在处理完这个数据块(在处理的 callback 函数调用完毕)后,会默认调用 free 函数释放掉所占的内存。即,发送方申请内存,接收方释放

虽然设计上 skynet 是围绕 单进程多线程 模式进行的,但 skynet 其实并不局限于单进程。它实际是可以部署到不同机器上联合工作的。这虽然不是核心特性,但核心层为此做了许多配合。每个机器上通常只建议运行一个 skynet 进程/节点,这样才能最大的发挥集群的性能,每个 skynet 的线程数通常也建议与 CPU 核心数相同

单个 skynet 进程内的服务数量被 handle 数量限制。handle 也就是每个服务的地址,在接口上看用的是一个 32 位整数。但实际上单个服务中 handle 的最终限制在 24bit 内,也就是 16M 个。高 8 位是保留给集群间通讯用的(这 8 bit 也就是 harbor id,可以理解为不同 skynet 节点的 id)。

我们最终允许 255 个 skynet 节点部署在不同的机器上协作(28 = 256)。每个 skynet 节点有不同的 id。这里被称为 harbor id。这个是独立指定,人为管理分配的(也可以写一个中央服务协调分配)。每个消息包产生的时候,skynet 框架会把自己的 harbor id 编码到源地址的高 8 位。这样,系统内所有的服务模块,都有不同的地址了。从数字地址,可以轻易识别出,这个消息是远程消息,还是本地消息。

Skynet 维护了两级消息队列。

为了调用公平,worker 线程每次只会处理同一个 service 消息队列中的一条消息,而不是耗尽同一个 service 消息队列中的所有消息(虽然做的效率会更高,因为减少了查询服务实体的次数,以及主消息队列进出的次数),这样可以保证没有服务会被饿死(等太久了,会感觉到明显的延迟)。

用户定义的 callback 函数不必保证线程安全 ,因为在 callback 函数被调用的过程中,其它工作线程没有可能获得这个 callback 函数所属服务的次级消息队列,也就不可能被并发了。一旦一个服务的消息队列暂时为空,它的消息队列就不再被放回全局消息队列了。这样使大部分不工作的服务不会空转 CPU。

skynet 配置文件

每个 skynet 节点(进程)要运行必须提供一个 config 配置文件给它(实际上这个配置文件就是一个 lua 脚本),那么这个配置文件的具体格式是什么呢?我们来看看:

  1. -- [框架核心配置]
  2. harbor = 0 -- 0 表示 skynet 工作在单节点下
  3. thread = 4 -- 线程数量,建议与 CPU 核心数相同
  4. -- [引导相关配置]
  5. logger = nil -- skynet 启动的第 1 serviceC 服务),日志服务,nil 表示将日志打印到标准输出
  6. bootstrap = "snlua bootstrap" -- skynet 启动的第 2 个服务(lua 服务),用来启动我们的用户服务,即 start 指定的 service
  7. start = "main" -- 用户定义的 lua 服务,由 bootstrap 服务启动它,bootstrap 将我们的服务成功启动后,会把自己给退出,即“引导”
  8. -- [服务加载配置]
  9. root = "./" -- 我们定义的变量,方便后面引用
  10. lualoader = root .. "lualib/loader.lua" -- lua 服务加载器
  11. -- [模块查找路径]
  12. lua_cpath = root .. "luaclib/?.so" -- C 模块的查找路径
  13. lua_path = root.."lualib/?.lua;"..root.."lualib/?/init.lua" -- lua 模块查找路径
  14. -- [服务查找路径]
  15. cpath = root.."cservice/?.so" -- C 服务的查找路径
  16. luaservice = root.."service/?.lua;"..root.."test/?.lua;"..root.."examples/?.lua;"..root.."test/?/init.lua" -- lua 服务的查找路径

然后我们在对应目录下,运行:

  1. $ ./skynet config.lua
  2. [:00000002] LAUNCH snlua bootstrap
  3. [:00000003] LAUNCH snlua launcher
  4. [:00000004] LAUNCH snlua cdummy
  5. [:00000005] LAUNCH harbor 0 4
  6. [:00000006] LAUNCH snlua datacenterd
  7. [:00000007] LAUNCH snlua service_mgr
  8. [:00000008] LAUNCH snlua main
  9. [:00000008] Server start
  10. [:00000009] LAUNCH snlua protoloader
  11. [:0000000a] LAUNCH snlua console
  12. [:0000000b] LAUNCH snlua debug_console 8000
  13. [:0000000b] Start debug console at 127.0.0.1:8000
  14. [:0000000c] LAUNCH snlua simpledb
  15. [:0000000d] LAUNCH snlua watchdog
  16. [:0000000e] LAUNCH snlua gate
  17. [:0000000e] Listen on 0.0.0.0:8888
  18. [:00000008] Watchdog listen on 8888
  19. [:00000008] KILL self
  20. [:00000002] KILL self

前面的数字是表示当前的日志消息是由谁(哪个 service)发出的,这其实就是 service 的地址,4 字节,32 bit。

sproto 协议

sproto 是 skynet 框架中常用的协议,所谓协议可以理解为 client 和 server 之间传输的数据格式,类似于 Google 的 protobuf,但是 sproto 与 skynet 能更好的的集成,毕竟都是同一个大佬写的。sproto 和 protobuf 都是将数据序列化为二进制字符串,而传统的 xml 和 json 都是将数据序列化为文本字符串。protobuf 和 sproto 的序列化和反序列化性能要比 xml 和 json 好得多,毕竟这是专门用来序列化的库。

对于一个 json 文件,它其实就是一个 javascript 的 object/array,和 Java 中的 HashMap 差不多,就是 key-value 键值对而已。protobuf 和 sproto 也是如此,每个协议基本上就是一个 object(C 语言中的结构体),所以理解起来还是没什么大问题的。

sproto 的简单例子:

  1. .Person {
  2. name 0 : string
  3. id 1 : integer
  4. email 2 : string
  5. .PhoneNumber {
  6. number 0 : string
  7. type 1 : integer
  8. }
  9. phone 3 : *PhoneNumber
  10. }
  11. .AddressBook {
  12. person 0 : *Person
  13. }

通常,每个 *.sproto 协议文件的内容就是这样,里面有一个或多个 .CustomType 这种“结构体定义”,前面的 . 表示这是用户自定义的一个类型,比如上面我们定义了两种类型:PersonAddressBook,我们先看 Person 类型里面,它定义了 4 个 key,即:idnameemailphone,你可能已经注意到了每个 key 后面都有一个非负整数,这个数值其实是 sproto 内部使用的一个序号,一般我们从 0 开始递增就行了,不用刻意去理解他的含义,protobuf 里面也有这种序号。然后冒号后面的是这个字段的数据类型,比如 name 字段的类型就是 string 字符串,id 字段的数据类型就是 integer;然后注意一下 phone 的数据类型,是 *PhoneNumber,前面的 * 表示这是一个 List(数组、列表),然后后面的 PhoneNumber 就是数组的元素类型,PhoneNumber 是一个自定义类型,可以知道,sproto 允许嵌套定义数据类型;然后 AddressBook 里面的 person 也是一个 list,list 元素类型为 Person。

如果没有这个 * 号,那么表示这个字段的数据类型就是 Person(以 AddressBook 为例),除此之外,还有一个复合类型,那就是 dict,所谓 dict 也就是一个 key-value 键值对,比如我现在这个项目中,有如下的定义:

  1. .FriendLineUpInfo {
  2. hero_info 0 : HeroInfo
  3. pos_id 1 : integer
  4. lineup_id 2 : integer
  5. unlock_status 3 : integer
  6. equip_dict 4 : i$RoleItem
  7. strengthen_master_lv 5 : i$integer
  8. refine_master_lv 6 : i$integer
  9. }

注意 equip_dict 这个字段,它的类型是这样声明的:i$RoleItem,这个就是一个 dict,key 类型是 integer(i 是缩写,string 的缩写为 s),而 value 的类型为 RoleItem(自定义类型);所以 sproto 中存在两种复合类型,一种是 list、一种是 dict,语法分别为:*elementTypekeyType$valueType

sproto 的注释和 shell 一样,就是使用 # 号,以井号开头的行就是注释行,用法同 shell。

注意:上面的这些 .UserDefinedType 是结构体的定义,也就是数据类型的定义,但是光有这些是没什么用处的,我们还需要定义协议,即 Protocol,因为 client 和 server 之间的这种数据交互其实就是 RPC 远程过程调用,其实 RPC 很好理解,就拿我们平常的调用函数这个例子来说,一般来说,我们要调用的函数都是本机的,而且通常都是同一个进程内的,要调用的函数与我们现在的函数处于同一个地址空间,所以调用也很简单,直接就是跳转到目标函数的入口处,创建栈帧结构,然后各种处理就行了;但是如果我们要调用的函数不在当前地址空间内,比如在公网上的服务器,那么当我们在本地调用外部服务器上的函数时,这种行为就叫做远程过程调用,即 RPC;所以 RPC 有 request 数据,也有 response 数据,request 表示调用者要传递过来的数据,而 response 表示被调用者需要返回给调用者的数据,这样一个规定,就是所谓的协议

来看 sproto 定义 protocol 的语法(完整例子):

  1. # This is a comment.
  2. .Person { # . means a user defined type
  3. name 0 : string # string is a build-in type.
  4. id 1 : integer
  5. email 2 : string
  6. .PhoneNumber { # user defined type can be nest.
  7. number 0 : string
  8. type 1 : integer
  9. }
  10. phone 3 : *PhoneNumber # *PhoneNumber means an array of PhoneNumber.
  11. height 4 : integer(2) # (2) means a 1/100 fixed-point number.
  12. data 5 : binary # Some binary data
  13. }
  14. .AddressBook {
  15. person 0 : *Person(id) # (id) is optional, means Person.id is main index.
  16. }
  17. foobar 1 { # define a new protocol (for RPC used) with tag 1
  18. request Person # Associate the type Person with foobar.request
  19. response { # define the foobar.response type
  20. ok 0 : boolean
  21. }
  22. }

这里定义了一个名为 foobar 的协议,通常一个协议会对应一个 lua 函数,而且它们是同名的,也就是说,我们还会定义一个名为 foobar 的 lua 函数来处理该协议对应的消息,不然光有一个协议也没啥用啊,必须要有对应的协议处理函数来处理它,这个同名的 foobar 函数就是这个协议处理函数。

然后你还是会注意到 Protocol 后面也有一个整数值,这也是 sproto 内部使用的,不用去关心她,我们只要保证每个 Protocol 的数值不同就行。

注意,即使 request/response 可能只需要一个参数,也请定义一个 dict,不要像上面这样,定义一个 request Person,因为如果以后你要更改请求的数据的话,比如添加一个请求参数,那么你就要将标量改为 dict 了,如我假设还要传一个 Address,那么就要改为这种:

  1. foobar 1 {
  2. request {
  3. person 0 : Person
  4. address 1 : Address
  5. }
  6. response {
  7. ok 0 : boolean
  8. }
  9. }

这样我们对应的协议处理函数就要做更改了,因为从 scalar 变为了 dict,所以最好就是一开始的时候就写为 dict,即使只有一个 key,这样能够做到最好的兼容性。

Lua 语法细节

请尽量使用 local 变量,在一切可能的情况下,都是要 local 变量,因为性能更好。

对于 字符串,可以使用 # 操作符来获得字符串的长度,如 #'hello' 的值为 5;
对于 table,这个要进行细分,这里面有很多隐含规则,一不小心就会被坑,请细读。

放在 java 中,list 就是 array,dict 就是 hashmap。list 的 key 必须是有序的,从 1 开始,而 dict 没有要求,就是键值对,但有一点不同,如果 entry 的 value 为 nil,则表示删除当前 entry,也就是实际上这个 entry 不存在。

添加元素

删除元素

遍历元素

获取长度

list 拼接为字符串
table.concat(list, ', '),如 table.concat({1, 2, 3}, ', ') 的值为 '1, 2, 3'

list 数组元素排序
table.sort(list[, func]),默认是自然排序,可以自己传递一个排序函数以实现自定义排序。

list 数组解构赋值

  1. -- 等价于 v1, v2, v3 = list[1], list[2], list[3]
  2. list = {1, 2, 3, 4, 5}
  3. v1, v2, v3 = unpack(list)
  4. print(v1, v2, v3)
  5. function func(...)
  6. -- 1, 2, 3, 4, 5 包为 list
  7. print(table.concat({...}, ', '))
  8. end
  9. func(1, 2, 3, 4, 5)
  1. 1 2 3
  2. 1, 2, 3, 4, 5

在给 table 命名的时候,一定要带上 dict/list 后缀,不然很容易搞混哦!

list 的元素一定不能为 nil 值,否则会有非常多的问题出现,切记切记!
dict 的键值如果为 nil 值(赋值或初始化),那么就是删掉这个 entry!

table 初始化、赋值语法规则:

  1. function print_list(list)
  2. print('==> list')
  3. for index, value in ipairs(list) do
  4. print(index, value)
  5. end
  6. end
  7. function print_dict(dict)
  8. print('==> dict')
  9. for key, value in pairs(dict) do
  10. print(key, value)
  11. end
  12. end
  13. list = {
  14. [1] = 1,
  15. [2] = 2,
  16. [3] = 3,
  17. }
  18. dict = {
  19. ['k1'] = 'v1',
  20. ['k2'] = 'v2',
  21. ['k3'] = 'v3',
  22. }
  23. print_list(list)
  24. print_dict(dict)

list 和 dict 都是 table,list 的 key 是数值,dict 的 key 是任意类型,但一般就是数值和字符串。key 需要使用 [] 来包住。但是 lua 提供了几种简写方式,比如上面的 list 可以写作下面这种,它们是完全一样的:

  1. list = {
  2. 1,
  3. 2,
  4. 3,
  5. }

lua 允许在 table 后面的多余逗号,没有任何影响,建议带上这个逗号,防止后期修改忘记加逗号,然后出现语法错误。对于 list,lua 会自动给每个元素生成 key,从 1 开始递增。对于 dict,lua 也提供了对应的简写形式,下面的写法与上面的完全相同:

  1. dict = {
  2. k1 = 'v1',
  3. k2 = 'v2',
  4. k3 = 'v3',
  5. }

再次强调,list 是有序的,对其进行添加、删除等操作,依旧是有序的,不会改变;但是对于 dict,因为存在 rehash(自动扩容之类的操作),所以不是有序的,而且顺序在运行期间还会改变,反正和 hashmap 差不多,存在 rehash 操作。

看到 list,你就只需要关心 value,前面的 [N] 不要去管它,会扰乱视线。

面向对象编程
obj.func(obj, args)obj:func(args) 是一样的,这其实是 lua 对 OOP 编程的一个语法糖:

  1. obj = {}
  2. function obj.func1(obj, args)
  3. print(obj)
  4. print(args)
  5. end
  6. function obj:func2(args)
  7. print(self)
  8. print(args)
  9. end
  10. obj.func1(obj, 'hello, world')
  11. obj:func1('hello, world')
  12. obj:func2('hello, world')
  13. obj.func2(obj, 'hello, world')

执行结果

  1. table: 0xac4aa0
  2. hello, world
  3. table: 0xac4aa0
  4. hello, world
  5. table: 0xac4aa0
  6. hello, world
  7. table: 0xac4aa0
  8. hello, world

隐含传递的参数名为 self,其实和 Java 中的 this 指针是一样的概念。

lua 是值传递还是引用传递
lua 有 8 种数据类型:nilbooleannumberstringtablefunctionthreaduserdata,其中:

但是注意:Lua 和 Java 一样,只有值传递,没有引用传递,对于值类型很好理解,对于引用类型,因为这些值里面保存的实际上是一个“指针”,所以传递的是一个指针,但是它们都还是值传递,因为引用传递一定存在一个 获取引用解除引用 的过程,如在 C 语言中,int *ptr = &num 获取引用,printf("%d\n", *ptr) 解引用。一定要注意这个区别。

所以 table 和 function 变量的赋值不存在昂贵的内存拷贝开销,因为值传递过程中,传递的是一个指针。

这里说明一下 lua 中的 string 字符串,lua 的字符串是不可变对象,而且对于字符串字面量,lua 只会在 vm 中保存一份拷贝,字符串是引用类型,因此字符串变量保存的其实是对应字符串的引用,所以字符串赋值也很快速,因为仅仅是传递一个引用/指针而已。这点和 C、C++、Java 是一样的。

lua 的日期时间处理函数

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注