« June 2014 | Main | August 2014 »

July 30, 2014

skynet 中如何实现邮件达到通知服务

skynet 中可以独立的业务都是以独立服务形式存在的。昨天和同事讨论如何实现一个邮件通知服务。

目前大概是这样的:有一个独立的邮件中心服务,它可以处理三条协议:

  1. 向一个 mailbox 投递一封邮件。
  2. 查询一个 mailbox 里有多少封邮件。
  3. 收取 mailbox 里指定的一封邮件。

用户读了多少邮件没有放在邮件中心,而是记在玩家数据里的。

用户的界面上需要显示是否有几封未读邮件,如果有新邮件达到,这个数字会自动变更。你可以想像成 iOS 上的那种带数字的小红点。

当然,在 skynet 的设计惯例中,每个用户在服务器上有一个 agent 代理,所以我们不单独考虑和客户端数据交互的问题,而只用考虑 agent 如何和邮件中心的交互。

现在的做法是,在用户上线的时候,就去邮件中心查一次,比较邮件数量后知道是否有新邮件,然后推送给玩家。

在玩家特定的操作后,比如进出副本等,都会重新查询一次。如果玩家在一个场景停留太久,客户端也会定期发起查询请求。

如果邮件必须在新邮件达到时,立刻通知给玩家怎么办呢?那么系统中另外有个用户中心的服务。邮件服务可以把消息推送到那里;用户中心发现玩家不在线,就扔掉消息;如果在线就做消息推送。


我觉得这个方案有那么一点点不好,所以提出了我的想法。

首先我不希望邮件中心服务只处理请求,而不要对外发送消息。因为这样,就必须让邮件业务了解更多的外部知识。

其次,定期查询显得很愚笨,也多了很多无谓的查询,很容易造成处理能力过载。因为外界无法确定邮件服务的处理能力(可能涉及外部数据库的查询),查询频率高于处理频率必定造成过载,而过载很容易雪崩,尤其是发起查询是其它独立系统决定的。

我认为在 skynet 框架里,更合适的做法是,当玩家上线时,agent 向邮件中心发送一条查询,附带自己已读邮件数量。

如果没有新邮件到达,邮件中心就不回复这条查询请求,而不是回复一条没有新邮件。而 agent 不收到上一条查询的回应就不要提出下一条查询。

简单的做法是使用 skynet.fork 一个线程来 while ture 查询更新邮件数量。而玩家请求邮件数量时,只需要查询本地的变量即可,不要去邮件中心查询。

这样的好处是,一个查询者同时永远都只有一个查询请求。而如果查询对象变更频繁,也不会推送变更消息多次。对过载的防范要好的多。

但实现的复杂点在于,邮件中心需要在收到请求不能立刻回复时,要挂起回复操作,等新邮件达到再回复。

好在 skynet 做这个并不复杂,记录下请求的 source 和 session ,之后发送消息即可。我觉得这种模式很普遍,所以新增加了一个方便的 api skynet.response 来简化处理。和 skynet.ret 立刻回应消息不同,skynet.response 返回的是一个 closure 。需要回应消息的时候,调用它即可;而不需要在同一个 coroutine 里调用 skynet.ret 。

skynet.response 返回的函数,第一个参数是 true 或 false ,后面是回应的参数。当第一个参数是 false 时,会反馈给调用方一个异常;true 则是正常的回应。


这类方案之所以适合于 skynet 框架,是因为:创建业务线程,以及挂起请求,推迟回应这些,对于 skynet 都是非常廉价的操作。而 skynet 使用的粒度较小的 lua 沙盒可以高效的管理它们。

不过还是还有一个小问题。如果邮件中心是一个需要长期运行的服务,那么如果 agent 频繁上下线,发起 query 请求,而却一直没有新邮件达到的话,就会挂起很多空请求。当然,每个请求仅仅是几十字节的而外开销而已,通常不足为虑。如果你真的在乎,可以做一个定时器,每几个小时清理一次(没有新邮件也回应)即可。

或者 response 还有一个方法,第一个参数可以传入 "TEST" ,查询要回应的对象是否还存在,而不是真的把消息发出去。

其它的事情,skynet 框架已经做得很完备了。目前的机制是:

当 A call B 时,如果 B 在回应前就退出了,A 会收到一条异常,并正确的传播到 A 里的 call 调用处;

当 A call B ,而 B 在回应前,A 自己退出了,B 也会收到一条异常,提示 A 已经不在了。但不会影响 B 的执行流程,只是让框架回收一些必要的相关资源。


这种模式,可以用在很多场合。比如你可以用它来监控好友名单的上下线消息(好友在线状态);你可以用来监控聊天频道的新消息(而不需要由聊天频道推送消息给你);你可以用来监控你在拍卖行寄卖的东西有没有售出或是流拍,等等。

当一个模块是独立实现的时候,仅给出它可以给出的请求接口,而不定义这个模块可以向外推送的消息类型,是非常利于模块化的。

比如,在 skynet dev 分支上(打算在 0.6.0 版提供)有一个新特性叫 sharedata 。

它可以提供在同一个节点中,不同的服务共享一个结构数据。数据提供方可以发布这个数据的新版本,并通知给所有数据持有方更新版本。由于 sharedata 是基于内存共享的,数据提供方只是简单的把老版本数据的一个标志翻转为脏(不可逆转)。

这里我就使用了这个模式。因为 sharedata 是以库形式封装的,而数据的读方不必专门处理数据更新的消息。

注:在 erlang 中,每个服务都有一个 mailbox ,可以主动去过滤出需要的消息。skynet 没有这个机制,虽然它有另一个类似机制就是 message type ,但并不适合在这种场合用。因为多个库维护有限的 message type (上限 256 个)实在是太麻烦了。

我们也不想在任何读到数据脏标记时都去发起一个新数据获取的请求(这里会有比较复杂的异步调用的问题);且在数据变更频繁时,我们永远只关心最后一版的数据。那么用这个模式就非常合适了。

代码见:lualib 下 sharedata.lua 中的 monitor 函数 ;服务提供方见 service 下 sharedatad.lua 中的 update 函数

July 28, 2014

sproto 的实现与评测

这个周末,我实现了上周设计的简化版 protocol buffers 协议 ,并重新命名为 sproto

在实现过程中,发现了许多编码格式上可以优化的地方,所以一边实现一边做调整,使结构更适合编码和解码,并且更紧凑。

做了如下改动:

由于这个东西主要 binding 到 lua 这样的动态语言中使用,所以我不需要按 Cap'n Proto 那样,直接访问编码后的数据结构(直接把数据结构映射为 C/C++ 对象),所以数据对齐是不必要的。

编码时的 tag 如果要求严格升序也可以更快的处理数据,减少实现的复杂度。数据段也要求按持续排列,且不准复用。这样可以让数据中有更多的 0 方便压缩。

把 boolean 数组按位打包的意义也不太大(会增加实现的复杂度)。

暂时先不实现 64bit id 的类型。(以后再加)

最终的 Wire Protocol 是这样的:

所有的数字编码都是以小端方式(little-endian) 编码。

打包的单位是一个结构体(用户定义的类型) 每个包分两个部分:1. 字段 2. 数据块

首先是一个 word n,描述字段的个数,接下来有 n 个 word 描述字段的内容。这个结构体的前半部分的长度就是 (n+1) * 2 字节。

字段的 tag 从 0 开始累加,每处理一个字段,将 tag 加一。

如果一个字段 v 为奇数,则把当前 tag 加上 (v-1)/2 + 1 ,并继续处理下一个字段值。 如果一个字段为 0 ,表示这个字段引用后面的一个数据块。 如果一个字段不为 0(且为偶数),这个字段的值为 v/2 - 1。(可以表示 [0, 32767] 的值)

接下来是被上面字段引用的数据块。

数据块用于描述字段中的大数据。它是由一个 dword 长度 + 字节串构成。通常用来表示数组或结构。大于 32767 的整数和负整数用 4 字节或 8 字节长的数据块表示(取决于需要和实现)。

数组的编码就是把同一类型的数据依次打包成数据块。如果是布尔数组,按 1 字节一个编码。如果是整数数组,它比较特殊,会根据需要打包成 4 字节或 8 字节一个数字;第一字节是 4 或 8 ,指明后面的整数宽度。

最后,数据中的 0 将被压缩的。压缩算法见上一篇 blog


我的实现分两部分:C 库,和 Lua 绑定。

C 库最主要的用途是用于其它语言的绑定,所以并没有提供类似 pbc 那样丰富的 api 。而仅仅提供了回调模式的 encode 和 decode 。

#define SPROTO_TINTEGER 0
#define SPROTO_TBOOLEAN 1
#define SPROTO_TSTRING 2
#define SPROTO_TSTRUCT 3

typedef int (*sproto_callback)(void *ud, const char *tagname, int type, 
  int index, struct sproto_type *, void *value, int length);

int sproto_decode(struct sproto_type *, const void * data, int size, sproto_callback cb, void *ud);
int sproto_encode(struct sproto_type *, void * buffer, int size, sproto_callback cb, void *ud);

encode 的时候,将回调用户传入的 callback 函数,通知现在要打包哪个字段(tagname),以及数据的类型。如果数据类型为 SPROTO_TSTRUCT ,表示这是一个结构,这个时候有另一个参数 sproto_type 指明结构类型。用户可以递归调用 encode 编码。

callback 函数给出了 buffer 地址和长度,如果 buffer 长度不够,应该返回 -1 ;如果正确的打包,返回使用掉的 buffer 的长度。

如果需要打包的是一个数组,index 大于 0 ,表示要把数据打包到 tagname 指向的数组的 index 索引处(base 1);对于非数组的数据,index 等于 0。

encode 函数本身不会分配任何内容,当你的 buffer 给的不够大时,请适当加大,再调用一次。

decode 的过程和 encode 类似,只不过这个时候,buffer 指针指向的是解码器解开的数据。callback 函数只需要把数据复制到 tagname 指明的位置即可。


这个库尚未全部完工,但基本功能都已经全了。我把 sproto 和我自己实现的 pbc 的 lua binding 做了一个简单的性能测试:

首先我定义了这个一组协议。

.Person {
    name 0 : string
    id 1 : integer
    email 2 : string

    .PhoneNumber {
        number 0 : string
        type 1 : integer
    }

    phone 3 : *PhoneNumber
}

.AddressBook {
    person 0 : *Person
}

如果用 google protocol buffers 描述,大概长得是这样的:

message Person {
  required string name = 1;
  required int32 id = 2;        // Unique ID number for this person.
  optional string email = 3;

  message PhoneNumber {
    required string number = 1;
    optional int32 type = 2 ;
  }

  repeated PhoneNumber phone = 4;
}

message AddressBook {
  repeated Person person = 1;
}

完全一致,只是语法上略有区别而已。

我编写两段 lua 代码测试。处理以下数据:

local ab = {
    person = {
        {
            name = "Alice",
            id = 10000,
            phone = {
                { number = "123456789" , type = 1 },
                { number = "87654321" , type = 2 },
            }
        },
        {
            name = "Bob",
            id = 20000,
            phone = {
                { number = "01234567890" , type = 3 },
            }
        }
    }
}

编码很简单,对于解码,另外还加上了遍历所有的字段,这是因为 pbc 库对于二级以上的结构是惰性展开的(不访问就不解码),而 sproto 则是全部展开。

编码 1M 次解码 1M 次体积
sproto3.18s14.30s83 bytes
sproto (nopack)2.45s13.20s130 bytes
pbc-lua11.71s30.60s69 bytes
lua-cjson19.46s15.17s183 bytes

注1:解码比较慢,主要在于 lua 表结构的创建。另外遍历 lua 表的开销为 3.6s ,这块时间和解码无关。

注2:pbc-lua 解码比较慢,还在于它需要为每张表生成 metatable 以支持 default 值。

注3: protobuffer 的数据本身不带长度信息,实际使用时,必须再加上长度信息才可以正确解码。

另外,json 是 Schemaless 的,这种结构处理起来一定会快的多(处理 schema 需要额外的时间),所以放在一起比较是不公平的。同样 schmaless 的结构还有 bson msgpack 等等。


7 月 29 日补:

前面的测试在 mingw32 上完成 (CPU i5-2500 @3.3GHz) ,而操作系统是 Windows 7 (64 位) ,在 64 位系统下跑 32 位程序对测试结果有很大干扰。

今天重新在 Linux 3.8.0 64 位 上重新测试 (CPU E5-2407 0 @ 2.20GHz)

编码 1M 次解码 1M 次体积
sproto2.47s9.52s83 bytes
sproto (nopack)2.45s8.60s130 bytes
pbc-lua9.54s23.0s69 bytes
lua-cjson5.32s9.72s183 bytes

7 月 30 日 补:

前面 mingw32 生成的代码在我的系统上有很大的性能问题,所以我用 mingw64 重新编译测试了一次:

编码 1M 次解码 1M 次体积
sproto2.15s7.84s83 bytes
sproto (nopack)1.58s6.93s130 bytes
pbc-lua6.94s16.9s69 bytes
lua-cjson4.92s8.30s183 bytes

July 24, 2014

设计一种简化的 protocol buffer 协议

我们一直使用 google protocol buffer 协议做客户端服务器通讯,为此,我还编写了 pbc 库

经过近三年的使用,我发现其实我们用不着那么复杂的协议,里面很多东西都可以简化。而另一方面,我们总需要再其上再封装一层 RPC 协议。当我们做这层 RPC 协议封装的时候,这个封装层的复杂度足以比全新设计一套更合乎我们使用的全部协议更复杂了。

由于我们几乎一直在 lua 下使用它,所以可以按需定制,但也不局限于 lua 使用。这两天,我便构思了下面的东西:

我们只需要提供 boolean integer (32bit signed int) string id (64bit unsigned int) 四种基础类型,和 array 以及 struct 两种用户定义的复合类型即可。

为什么没有 float ? 因为在我们这几年的项目中,使用 float 的地方少之又少,即使有,也完全可以用 string 传输。

为什么没有 enum ? 因为在业务层完全可以自己做 int 到 enum 的互转,没必要把复杂度放在通讯协议中。

为什么不需要 union ? 因为按 protocol buffer 的做法,结构中的每个域都可以用一个数字 tag 来标识,而不是用数据布局来指示。不需要传递的域不需要打包到传输包中。

为什么不需要 default value ? 我们的项目中,依赖 default value 的地方少之又少,反而从我维护 pbc 的大量反馈看,最容易被误用的用法就是通讯协议依赖一个字段有没有最终被打包。所以干脆让(不打包)等价于 default value 就好了。明确这个(在 google protocol buffer 中是错误的)用法。


我设计的这个新协议,命名为 ejoyproto ,它的协议描述成人可读的文本大约是这样的:

.person {
    .address {
        email 0 : string
        phone 1 : string
    }
    name 0 : string
    age 1 : integer
    marital 2 : boolean
    children 3 : *person  #  这是一个 person 类型的数组
    address 4 : address
}

所有涉及命名的地方,都遵循 C 语言规则:以英文字母数字和下划线构成,但不能以数字开头,大小写敏感。

自定义类型用 . 开头。自定义类型可以嵌套。自定义类型的名字不可以是 integer, string 和 boolean 。

每个类型由若干字段构成,每个字段有一个在当前类型中唯一名字和一个唯一 tag 。tag 是一个非负整数,范围是 [0,32767] 。不要求连续,但建议用比较小的数字。

每个字段必须有一个类型,如果希望它是一个数组,在类型前标注 * 。

协议定义次序没有要求,但建议引用一个类型时,类型的定义放在前面。

符号 # 以后是注释。

换行和 tab 没有特别要求,只要是空白符即可。

同时,协议文件里可以描述 RPC 协议,范例如下:

foobar 1 {
    request person
    response {
        ok 0 : boolean
    }
}

这里定义了一条叫做 foobar 的 RPC 协议,赋予它一个唯一的 tag 1 。(在网络传输的时候,可以用 1 代替 foobar )

每条协议都是由两个类型 request 和 response 构成,其中,response 是可选的。

这两个类型都必须是结构,而不能是基本类型或数组。这里可以在 request 或 response 后直接写上引用的类型名,或就地定义一个匿名类型。

这样,一组协议描述数据就可以用 ejoyproto 本身描述了:

.type {
    .field {
        name 0 : string
        type 1 : string
        id 2 : integer
        array 3 : boolean
    }
    name 0 : string
    fields 1 : *field
}

.protocol {
    name 0 : string
    id 1 : integer
    request 2 : string
    response 3 : string
}

.group {
    type 0 : *type
    protocol 1 : *protocol
}

最终提供的 api 会类似这样:

local tag, bytes = encode("foobar.request",
   { name = "alice", age = 13, marital = false })   

可用来打包一个 foobar 请求,返回 foobar 的 tag 以及打包的数据。然后再将它们组合起来构成最终的通讯包(可能还需要置入 session size 等信息)。


Wire Protocol

所有的数字编码都是以小端方式(little-endian) 编码。

打包的单位是一个结构体(用户定义的类型) 每个包分两个部分:1. 字段 2. 数据块

对于数据块,用于描述字段中的大数据。它是由一个 dword 长度 + 按 4 字节对齐的字节串构成。也就是说,每个数据块的长度都一定是 4 的倍数。 对齐时,用 0 填补多余的位置。

字段必须以 tag 的升序排列, 可以不连续; 数据块的次序必须和字段中的引用次序一致。

对于每个字段,由两个 word 构成。

第一个 word 是 tag 。记录的是当前字段的 tag 相较上一个的差值 -1 (对于第一个字段,和 -1 比较)。如果被打包的字段的 tag 是连续的,那么这个位置通常是 0;如果不连续,则记录的跳开的数字差。

第二个 word ,是这个字段的值。如果值为 0 ,表示数据放在数据区;否则这个值减 1 就是这个字段的值(只有是整数和布尔值才有效)。

注:在解码的时候,遇到不能识别的 tag ,解码器应选择跳过(不必确定字段的类型)。这对协议新旧版本兼容有好处。

数据类型在协议描述数据中提供,不在通讯中传输。根据 tag 可以查询到这个字段的类型。如果是对数据块的引用,且数据类型为:

  • integer : 数据块长度一定为 4 ,数据内容就是一个 32bit signed integer 。
  • id : 数据块长度一定为 8 ,数据内容就是 8 个字节的 id 。
  • string : 数据块的长度就是string 的长度, 内容就是字符串。
  • usertype : 那么数据块里就是整个结构。
  • array : 那么数据块就是顺序排列的数据。对于 integer array ,每 4 个字节是一个整数;对于 boolean array ,每个字节可表示 8 个布尔量,从低位向高位排列;对于 string 和 struct ,都是顺序嵌入数据块(长度+内容)。

下面有两个范例:

person { name = "Alice" ,  age = 13, marital = false } :

03 00 01 00 (fn = 3, dn = 1)
00 00 00 00 (id = 0, ref = 0)
00 00 0E 00 (id = 1, value = 13)
00 00 01 00 (id = 2, value = false)
05 00 00 00 (sizeof "Alice")
41 6C 69 63 65 00 00 00 ("Alice" align by 4)
person 
{
    name = "Bob",
    age = 40,
    marital = true,
    children = {
        { name = "Alice" ,  age = 13, marital = false },
    }
}

04 00 02 00 (fn = 4, dn = 2)
00 00 00 00 (id = 0, ref = 0)
00 00 29 00 (id = 1, value = 40)
00 00 02 00 (id = 2, value = true)
00 00 00 00 (id = 3, ref = 1)

03 00 00 00 (sizeof "Bob")
42 6F 62 00 ("Bob" align by 4)

03 00 01 00 (fn = 3, dn = 1)
00 00 00 00 (id = 0, ref = 0)
00 00 0E 00 (id = 1, value = 13)
00 00 01 00 (id = 2, value = false)
05 00 00 00 (sizeof "Alice")
41 6C 69 63 65 00 00 00 ("Alice" align by 4)


0 压缩

这样打包出来的数据的长度必定 4 的倍数,里面会有大量的 0 。我们可以借鉴 Cap'n proto 的压缩方案

首先,如果数据长度不是 8 的倍数,就补 4 个 0 。

按 8 个字节一组做压缩,用一个字节 (8bit) 表示各个字节是否为 0 ,然后把非 0 的字节紧凑排列,例如:

unpacked (hex):  08 00 00 00 03 00 02 00   19 00 00 00 aa 01 00 00
packed (hex):  51 08 03 02   31 19 aa 01

当 8 个字节全不为 0 时,这个标识字节为 FF ,这时后面跟一个字节表示有几组 (1~256) 连续非 0 组。 所以,在最坏情况下,如果有 2K 的全不为 0 的数据块,需要增加两个字节 FF FF 的数据头(表示后续有 256 组 8 字节的非 0 块)。

July 23, 2014

给 skynet 增加 http 服务器模块

一直没给 skynet 加 http 协议解析模块是因为这个领域我不熟悉,而懂这块(web 开发)的人很多,随便找个人做都应该比我做的好。世界上的 web 服务器实在是太多了,足见做一个的门槛也不高,我也没什么需求,所以就这样等着有需要的人来补上这一块。

但这两天实在等不了了。我们即将上线的一款游戏,运营方要求我们提供一组 web api 供运营使用。固然我们可以单独写一个进程挂在 nginx 的后面,并和 skynet 通讯,但游戏开发组的同学觉得不必把简单的事情做的这么绕。监听一个端口提供 http 协议的服务又不是什么特别麻烦的事情,结果就打算直接在 skynet 里提供。

之前已经有非我们公司的同学在用 skynet 的时候自己写过一个 http 服务器。我觉得没有必要把 http 协议解析的部分和 socket 管理的部分搅到一起。我更希望有一个纯粹的 http 协议的应答模块。

在网上找不到合适的 lua 版的 http 协议解析模块,我便花了一个晚上,我实现了大概。

我希望外部只提供两个 api 注入:一个用于读一个数据流,另一个用于把数据写到数据流上。由于 skynet/lua 有很不错的 coroutine 支持,这组 api 便可以以阻塞模式提供,而不需要搞成 node.js 那样的 callback 也可以充分利用 cpu 。

在 dev 分支上,有这个 httpd 的实现 ,也就是 200 来行代码吧。

对 skynet socket 的封装放在这里,它仅仅是在 socket 的 read 和 write 的基础上,把错误返回变成了抛出异常。

依靠这些,我搭建了一个简单的 web server ,见 skynet 的 examples 。这个范例监听了一个端口,并启动了 20 个 agent(worker) 等待 http 请求,这些 agent 会轮流接管新到的请求,使用 httpd 库解吸协议并回应。

这个库仅考虑了最基本的安全问题:http 请求非法、http header 过大都会主动返回(而不会让恶意攻击者制造一个特别的数据流来空耗服务器内存或占用服务器的 CPU )。使用这个库的时候,也可以设置 http body 的上限(如果不需要接受 client POST 的大文件的话)来限制从客户端发送过长的请求。


我没有做细致的性能测试,因为这个 web 服务本来就不是面对最终用户的,不会有什么性能上的要求。但有同学反馈说,他们编写的服务,在他的开发机(一台普通的 4 核台式机)上可以有 4K+ qps 的处理能力,应该是满不错了。当然,我一直对 skynet 的性能还是颇有信心的。

July 15, 2014

skynet 消息服务器支持

周末终于把上周提到的 短连接服务 实现了。由于本质上是一个消息请求回应模式的服务器,并没有局限于长连接还是短连接,所以不打算用短连接服务来命名。

用户的登陆状态不再依赖于是否有连接保持,所以登陆服务也顺理成章的分离出来了。

用户先去统一的登陆服务器登陆,获得令牌,然后去游戏服务器连接握手。如果用户和游戏服务器的连接断开,只需要重新用令牌握手即可,不必重新回登陆服务器登陆。

当然用户也可以重新登陆,清除登陆状态,完成一个传统意义上的下线再上线的过程。

和常规的 HTTP 协议不同,我们可以在同一条连接上发起多个请求,服务器也不必按次序回应它们。每个请求有一个 session ,用来匹配请求包和回应包。

由于是基于 session 的模式,所以可以轻易的实现原本觉得比较麻烦的服务器向客户端的消息推送:

只需要建立连接后,马上向服务器发一个请求,索取服务器可能推送的数据即可。如果服务器暂时没有推送需求,可以挂起这个 session ;直到有消息推送时,再以这个 session 回应即可。客户端收到回应后,立刻发送新的请求,重复这个过程。若是担心这样效率比较低,可以一次发送多个请求,服务器依次使用即可。

对于多次连接间丢消息这件事,每次新建立连接,可以把上次没有得到回应的包重新发送出去,session 保持不变;服务器收到重复的 session 且不是当前连接过去发来的,只需要把 cache 的回应包重发回去。如果 cache 失效,简单的踢掉用户,要求它重新走一次登出登陆流程即可。


使用写好的 msg server,业务层仅需要写一个 lua 函数处理请求。不用关心连接这件事。这个 lua 函数处理传入的请求串,返回一个回应串即可。

细节和范例可见我在 skynet wiki 上写了三篇主要的文档:

登陆服务器 网关服务器 消息服务器

July 11, 2014

计划给 skynet 增加短连接的支持

不基于一个稳定 TCP 连接的做法,在 web game 中很常见。这种做法多基于 http 协议、以适合在浏览器中应用。

运行在移动网络上的游戏,网络条件比传统网络游戏差的多。玩家更可能在游戏进行中突然连接断开而导致非自愿的登出游戏。前段时间,我实现了一个库来帮助缓解这个问题

如果业务逻辑基于短连接来实现,那么也就不必这么麻烦。但是缺点也是很明显的:

每对请求回应都是独立的,所以请求的次序是不保证的。

服务器向客户端推送变得很麻烦,往往需要客户端定期提起请求。

安全更难保证,往往需要用一个 session 串来鉴别身份,如果信道不加密,很容易被窃取。


即使有这些缺点,这种模式也被广泛使用。我打算在下个版本的 skynet 中提供一些支持。

所谓支持、想解决的核心问题其实是上述的第三点:身份验证问题;同时希望把复杂的登陆认证,以及在线状态管理模块可以更干净的实现出来。

我不打算基于 HTTP 协议来做,因为有专有客户端时,不必再使用浏览器协议。出于性能考虑,建立了一个 TCP 连接后,也可以在上面发送多个请求。仅在连接状态不健康时,才建议新建一个 TCP 连接。


由于不依赖长连接,所以登陆和游戏业务可以分到独立的地方去做。

整体系统有这样四类实体:登陆服务器、认证服务器、游戏服务器、客户端。

客户端进入游戏的流程是这样的:

  1. 客户端先去认证服务器获得令牌。

  2. 客户端把令牌交给登陆服务器,换取一个秘密。

  3. 客户端利用获取的秘密,和游戏服务器建立加密通讯通道,进行游戏。

对于第一环节、通常是在第三方平台进行的(对于手游、通常是通过接入第三方平台的 SDK 完成)。这个令牌里通常包含用户身份 ID ,和用以校验令牌有效性的数据。

第二环节的登陆服务器,可以帮我们做系统的过载保护(当玩家人太多时,可以排队)。把登陆服务器独立出来,也方便不同的项目共享,不必每个项目都实现一次。它主要的工作是维持用户的在线状态,至于当用户已经在登陆状态时,再获取登陆请求时的处理:是顶掉老号,还是拒绝登陆,或是允许同时登陆,都放在第三环节中进行。

由于游戏服务器的登陆点可以有多个,我倾向于由客户端事先选好他的登陆点,然后把登陆点和第一环节得到的令牌交给登陆服务器。登陆服务器拿到令牌后,从中提取出用户唯一 id 并校验令牌是否有效(可以是本地校验,也可以是去第三方平台校验,这取决于第三方平台的协议)。

登陆服务器保持着用户在线状态,从设计上是允许同一用户有多个在线实体的(虽然不一定用的上),这可以仿造 XMPP 协议,为每个有效令牌的持有者分配一个唯一 id :uid@登陆点/subid 。subid 是随机生成,且不重复的,当单个登陆允许多重登陆,subid 有实质意义,而在频繁登陆时,处理一些边界情况时也能发挥作用;uid 可以直接是第三方平台分配的 id ,也可以是登陆服务器自己生成的。自己生成有利于同时接入多个第三方平台。

登陆服务器可以接受在线状态查询,通过 id 可以查到该所有在线的完整关联 id 。

一旦用户完成登陆,登陆服务器就认为此玩家在线,再次收到同一玩家的登陆请求时,应该向所有已经在线的关联 id 所在的游戏服务器发送 RPC 查询请求(由于关联 id 中包含有登陆点信息,所以它知道该去哪里查询)。由游戏服务器来决定是否拒绝这次登陆还是踢掉前一个登陆,接受新的;当然也可能是玩家已经刚刚离线。

如果玩家主动离线、游戏服务器应该向登陆服务器发送 RPC 请求,注销在线状态。

客户端和登陆服务器的交互应该使用加密信道,如果不想使用标准 https 协议,也可以做简单的 DH 密钥交换,加上 RC4 XOR 加密信息流。

玩家登陆成功后,除了收到他所属的唯一 id 串外,还要接受一个秘密,用于和游戏服务器通讯。这个秘密事先在登陆服务器和游戏服务器交互时分发到游戏服务器了。游戏服务器也会提前做好准备某个特定 id 即将登陆。

在第三环节,客户端和游戏服务器握手时,先明文发送 uid@node/subid:id:randomkey 表明自己是谁,这里 id 表明是第几次握手;如果要重新建立连接,握手时 id 必须比之前的大,用过的 id 都会被服务器拒绝。

之后的信息都用 secret+id 来密钥来加密。randomkey 用于握手,游戏服务器在加密流的开头,先回应这个 randomkey ,如果用不匹配的密钥,会被游戏服务器检查出来断开连接。

如果客户端想重复和游戏服务器建立连接,它不需要再次去登陆服务器登陆。只需要把上次的 id 递增,并重新生成一个 randomkey ,去和游戏服务器握手即可。游戏服务器可以自己限制同一个用户能够同时建立通讯连接的上限,以节约服务器资源。

July 09, 2014

一个游戏的想法

自从去年底,我们公司的主要开发力量转移到移动平台游戏开发以来,我们的第四款游戏也快上线了。

第一款游戏:陌陌争霸、算是有所小成。这款初期复制 COC 的游戏为公司未来发展赚到了一笔钱,可以供我们稳定发展几年了。目前正朝着自己的游戏特色改变。

第二款游戏,天天来战、从亡灵杀手中获得启发,我们努力寻找着适合触摸屏操作的动作游戏形式。目前已经在腾讯的应用宝平台开始做初步测试,测试数据还不错,想来可以收回开发成本了。

第三款游戏,小倩去哪儿、综合了 Monster Strike 的战斗模式和刀塔传奇的数值系统;目前在陌陌平台做小规模测试。在第一批用户中拥有了最早的粉丝。我个人也在这款游戏中花了十多个小时的时间,除了初期难度过于简单外,还是对品质很满意的。

至于第四款游戏,争取在两个月内推出吧。


因为正在进行中的项目开发都还算顺利,我就不自觉的构思后面的产品了。目前公司没有生存之忧,我们的客户端和服务器引擎都非常稳定了。那么下一款产品,我希望能尽量的原创,而不是过多借鉴已存在的游戏。

当然,这不是我的主业。在我们公司内部,也有策划专值在考虑这类事情,做项目预研。我这纯粹是个人兴趣所致。所谓游戏创意这种东西,只要没有实现,是最不值钱的东西。我也不介意公开写写自己的想法,算是整理思路、做个记录。


我对移动平台游戏的兴趣来至于 Tiny Tower 这个小游戏,但真正打动我的是 Clash of Clans 。它让我重新体会到游戏的乐趣。所以我一直在试图思考移动平台上的游戏应该具备哪些特点。

  1. 游戏要始终保持有 30 秒到 5 分钟的持续玩法。强制过长的连续游戏时间是不合适的。

  2. 在第一次接触游戏时,应该有至少 1 个小时的流畅体验。中间可以让玩家有少许的等待,一两分钟的等待可以加强玩家的期待感。但不适合连续 1 、 2 小时给玩家灌输新概念。要留给玩家自我思考的空间。

  3. 当玩家疲惫时,让玩家主动离开,但游戏内容是带成长的、且成长不是随玩家的暂时离开而暂停的。同时需要有游戏机制可以召回玩家。召回时间分布在 4 小时、10 小时、24 小时的周期。

  4. 当玩家因为睡眠而需要长时间离开游戏时、一定要制造使命感和紧迫感。需要自我要求在睡觉前完成游戏任务。

  5. 游戏中要有一定的可挖掘的技巧性。玩家可以通过改良自己的操作和规划而提升游戏水平。


下面记录想法之一(非常粗糙,同时有不少设计上的 bug 需要推敲):

大航海时代

这是一个核心玩点在于物流规划的游戏,前期伴随着城市发展元素,后期可加入一些军事对抗元素,但辅助元素都受玩家的物流规划的影响,以自动计算和呈现为主。

每个玩家都拥有一个由海洋和星罗棋布的岛屿构成的平行世界。每个平行世界的岛屿拓扑关系是一致的,玩家的出生点也相同。玩家当开发出岛和岛之间的航线后,玩家需要为每一艘船规划出一条经历诸岛的航线。每个岛屿是一站,在每个站点都可以装货和卸货。

玩家在规划出航线后,关注点只在货运上。而货运有两种模式:

  1. 手动模式。 每次船到站后都停下来,玩家选择装什么货,卸什么货,或者开往下一站。每段航程,船会消耗一定能量。在船靠站停泊时,能量随时间补充。

  2. 自动模式. 每次船到站后,都固定停一段时间,由 AI 决定如何补货。自动模式下,船的能量不补充只消耗。能量消耗完后,自动模式停止,直到玩家主动激活。(自动模式在玩家离线后会工作一段时间)

当玩家在线时,可以自由切换手动模式和自动模式。


玩家可以在城市岛屿建设工厂,用来消耗原材料。当工厂建设好后,系统会自动定期发布需求订单。订单通常是工厂所需原料。城市发展到不同的规模,系统会发布一定产品订单,需求一些工业产品。

玩家的货运主要就是为了满足这些订单。另外,系统会随机发放一些特定物流任务,要求把一个邮包从一个岛屿运送到另一个岛屿。奖励更为丰厚:可以是钻石(RMB 道具)。

城市发展受人口以及就业岗位的双重限制。玩家需要定期满足食物订单和工业订单来促进城市发展。

游戏的内在经济循环是:

  通过航运        获得奖励

材料 ---------- 工厂 ---------- 再建设

最终玩家需要发展 每个岛屿的仓库, 船队, 城市 三个维度。


游戏的交互体现在交易市场。

每个岛屿都有一个独立的交易市场,这个交易市场是连接平行世界玩家的纽带。也就是说,你在 A 岛屿购买其他玩家在 A 岛屿售卖的商品,在 A 岛屿交割。通常你还需要自己把它们运输到需要的地方。

由于玩家的运载量与仓库容量有限,而岛屿产量和产品种类高于运载量,且任务种类不同,最终制造出交易需求。 由于地域的差别,会导致不同岛屿间,同样的商品有不同的价格。

整个世界地图被分割为若干大区域,每个区域有若干岛屿。一个区域有一个独立的市场界面,在上面列出该区域商品的所在地和价格。


AI 行为是游戏设计的重点。

货运的 AI 是不同于以往许多类似模式的单机游戏的点。因为这是一个网络游戏,所以提供 AI 模式在玩家下线后也能获得收益。玩家可以通过干预 AI 的参数来优化策略。

  1. 玩家定义一艘船可以装什么货物,默认是装所有类型的货物。

  2. 每个站点可以设置仓库属性:每种货物最低保留个数,排空下限,以及最多保留上限。默认是 0 到仓库上限。 当货物数量低于最低保留个数的时候,这个站点需求这个货物。当货物数量高于排空下限时,这个站点输出这个货物。

  3. 每个站点可以接受一些系统任务,这些任务会在一长段时间需求某种货物。高于最低保留个数的货物将被优先填补本站的同样需求。每个站点可能生产一些货物。货物生产出来会被堆积在仓库。当货物超过最多保留上限时,站点将停止生产。

当船在自动模式下工作时,停靠在一个站点时,系统首先看船上有没有货物可以满足这个站点的需求,如果有,就卸货满足。

然后,统计接下来的航路上所有站点的需求,把它设置为船的需求。然后从当前站点装载不足的需求。当需求货物种类较多时,依次满足不同货物,直到船装满。

当能量耗完后,船停靠在最后一站休息,恢复 CD 。玩家必须再次上线触发自动巡航指令。(减少离线计算压力,增加玩家日活跃度)


在游戏中后期,玩家会进入不安全区域,有一定几率被海盗等突发事件侵袭,导致货运的不稳定因素。但战斗不作为本游戏的玩点,只以数值方式呈现。

注:保镖和截镖或许是一个增进玩家互动的有趣玩法,但在一个强调物流规划乐趣的游戏中,过多的玩法可能不太合适?


ps. 这个想法的游戏原型有很多:EVE 、铁路大亨、Sid Meier's Railroads、Pocket Train 、Boom Beach 、法老王、等等都是灵感的来源。

我希望它如果真的被实现出来的话,能保持操作的简洁。玩家只需要做有限的操作来规划每天的游戏任务,不用强制理解特别多的游戏系统(玩家必须理解的系统要比比设计的系统少得多),但游戏本身又有深度可以被挖掘。玩家可以通过找到更好的策略来改进游戏中的收益。

物流规划游戏以及大亨类游戏,或许还是过于小众,而无法聚拢足以产生交互乐趣的玩家群体规模。这也是是否将这个游戏立项我最为担心的因素。