« December 2011 | Main | February 2012 »

January 20, 2012

libuv 初窥

过年了,人都走光了,结果一个人活也干不了。所以我便想找点东西玩玩。

今天想试一下 libev 写点代码。原本在我那台 ubuntu 机器上一点问题都没有,可在 windows 机上用 mingw 编译出来的库一个 backend 都没有,基本不可用。然后网上就有同学推荐我试一下 libuv 。

libuv 是 node.js 作者做的一个封装库,在 unix 环境整合的 libev ,而在 windows 下用 IOCP 另实现了一套。看起来挺满足我的玩儿的需求的。所以就试了一下。

这东西没有文档,暂时没看出来作者有写文档的打算,恐怕他是自己用为主。我 google 了一下,就是 github 上有源代码,.h 文件里有还算比较详细的注释。当然最主要是有些 test 程序,可以大概浏览其设计思路。

编译倒挺顺利,照着 test 写点小东西也不复杂。所以我也就逐步开始了解这个东东了。

老实说,对于一个别人写的库,我爱用不爱用主要是考察其 API 设计。也就是该怎么用,设计的好不好,有没有冗余设计。文档什么的其实不太所谓,反正有代码可以看嘛。

libuv 大体上我还算满意,用 C 实现可以加很多分。不过有些小细节我觉得还是有点小遗憾的。(这个遗憾是指,如果我自己来设计,绝对不会像这个样子)

最关键就是接口对 C 结构体布局的依赖性。这点我曾经因为 hiredis windows 版的缘故吐槽过一次了。以我自己的经验,似乎大多数 Windows 出身的程序员都有一点这种坏习惯。好吧,我也不知道怎么把这点和 windows 联系起来的,纯粹感觉而已。因为我自己以前做设计也有这个习惯。

为什么我觉得这样不好?

因为我觉得一个库,若想被人当成黑盒子去使用,以后也作为黑盒子来维护,甚至可以用别的盒子去替代它。关键的一点就是接口简单。这个简单包括了使用最少的概念、需要最少的知识去理解它。

文档通常是对接口无法自描述所需知识的一种补充。对一些例外的说明。而这些当然是越少越好。

我倾向于不在对外接口(对于 C 库来说,就是 .h 文件)中定义所用数据结构的具体布局。通常只需要一个名字即可。这个名字是用来做强类型约束的。

过多的结构定义导致了过多的概念,增加了接口复杂度。

接口的最小知识表达就是用一致的 C 函数调用约定。有明确的输入参数、输出参数。对于接口函数,应该是无全局相关状态的。这不仅仅是为了线程安全,而是可以保证没有隐式约定(额外的知识)。

如果某些行为需要用户设置或读取某个结构体的一个特定域,我觉得就是在 C 函数调用这一方式外,增加了一种改变模块行为的接口形式。或许这样做的成本比 C 函数调用要来的低,但我以为得不偿失。

尤其是、你的模块如果相当依赖这种形式:直接对结构体的特定域赋值的形式来交换信息。这种依赖可能来至于你对性能的追求。那我觉得一般都是整个模块的需求定义出了什么问题。一个独立模块需要解决的问题,通常对外界的信息交换应该是低频的,它应该是可以独立工作解决更复杂的问题的。而不应该是不断的要求外部告知它新的状态变换。

ps. 对于接口中的结构体定义问题。有另一种情况需要区分开。就是有大量的输入参数或输出参数需要一次性交换时,可以考虑定义一个结构体来做。这样比在 C 函数调用前压一大堆的数据去堆栈里要干净的多。


写了这么多,我是想说说我初步阅读 libuv 代码的感受。我碰到的第一个问题就是:libuv 用了大量 callback 机制来完成异步 IO 的问题。而这些 callback 函数通常都带有一个参数 uv_stream_tuv_req_t 等。这个数据表示这次 callback 绑定的数据 。

我们知道, C 语言是没有原生 closure 支持的。若有的话,closure 应是 callback 机制最佳解决方案。而 C 语言模拟 closure 的方法是用一个 C Function 并携带一个 void * ud 。此 ud 即原本应该在 closure 中绑定的数据块。

这里,libuv 用的 uv_stream_t 大致上等同于这个 ud 。

问题出来了。用户在用这类异步 IO 库的时候,每次 IO 事件都需要绑定的行为需要的数据不仅仅是一个 stream 。还需要一些围绕这个 stream 做的动作所需要的一些其它数据。

我在阅读 test/echo-server.c 时看到这么一段:

static void after_write(uv_write_t* req, int status) {
  write_req_t* wr;

  if (status) {
    uv_err_t err = uv_last_error(loop);
    fprintf(stderr, "uv_write error: %s\n", uv_strerror(err));
    ASSERT(0);
  }

  wr = (write_req_t*) req;

  /* Free the read/write buffer and the request */
  free(wr->buf.base);
  free(wr);
}

这里用了一次强制转换,把 uv_write_t 转换为 write_req_t 。为什么可以这样干,是因为 write_req_t 被定义成:

typedef struct {
  uv_write_t req;
  uv_buf_t buf;
} write_req_t;

这里根据 C 结构布局,req 是第一个域,所以排在最前面。

这样做有点晦涩,我只能说感觉不太好。因为如果约定了 uv_write 接口传递的是一个 uv_write_t 类型的数据,这就明显是利用 C 语言特性来夹带私货了。

如果这是作者推荐的惯用法的话,我则这样理解:

libuv 其实在 API 上有个隐含约定。即回调函数的参数指向的地址偏移量为某个数值以后的数据是用户数据。这个数值为类型的尺寸。这类似 c++ 的继承。数据类型尺寸数值是编译时通过编译器来约定的。

而且,单就现在的用法,我认为更严谨的做法应该是类似 socket API ,显式的把传递的结构尺寸在函数接口表达出来(参考 socket connect 的接口定义中的第三个参数 addrlen)。 这样对库的接口稳定有好处。库可以知道用户有可能扩展数据,长度信息提示了库,传入数据体的真实大小。

btw, C++ 在用继承来完成类似设计时,则依靠了语言对 cast 的约定。C++ 语言的知识概念太多,很难完成简洁的模块接口约定。在我看来,这直接导致了 C++ 很难设计通用库,而只能设计专有框架。


我着一些疑惑阅读了不少 libuv 里的实现代码,尤其是 uv.h 的细节。我发现这样一个结构定义。

#define UV_HANDLE_FIELDS \
  /* read-only */ \
  uv_loop_t* loop; \
  uv_handle_type type; \
  /* public */ \
  uv_close_cb close_cb; \
  void* data; \
  /* private */ \
  UV_HANDLE_PRIVATE_FIELDS

/* The abstract base class of all handles.  */
struct uv_handle_s {
  UV_HANDLE_FIELDS
};

注意这里有一个 data 域。从我的经验判断,这个域应该就是用来在一个 handle 上夹带用户数据的。由于没有文档确认,我只能从有限的代码阅读中来确认我的判断。我很奇怪没有定义一个明确的 api 出来绑定用户数据。因为在库的实现代码中也确实库自己用到过这个域,所以估计也不是库的使用者可以自由使用的。

当然对应的还有几处类似设计:

#define UV_REQ_FIELDS \
  /* read-only */ \
  uv_req_type type; \
  /* public */ \
  void* data; \
  /* private */ \
  UV_REQ_PRIVATE_FIELDS

/* Abstract base class of all requests. */
struct uv_req_s {
  UV_REQ_FIELDS
};

还有

struct uv_loop_s {
  UV_LOOP_PRIVATE_FIELDS
  /* list used for ares task handles */
  uv_ares_task_t* uv_ares_handles_;
  /* Various thing for libeio. */
  uv_async_t uv_eio_want_poll_notifier;
  uv_async_t uv_eio_done_poll_notifier;
  uv_idle_t uv_eio_poller;
  /* Diagnostic counters */
  uv_counters_t counters;
  /* The last error */
  uv_err_t last_err;
  /* User data - use this for whatever. */
  void* data;
};

这个 struct uv_loop_s 的 data 域倒是明确的注释可以随便使用了。


话说回来,这个绑定用户数据的需求我在早年阅读 Windows 的 MFC 实现时倒是见过另外一种解决方案。

Windows 的窗体有一个 SetWindowLong 的 API 可以让用户去设置一个用户数据。这样可以方便用户在用 C++ 封装的时候把一个 C++ 对象指针绑定在窗体 Handle 上。这样在窗口消息回调函数中就可以取回这个对象指针。

MFC 封装这些系统 API 时,可能是为了更通用,没有占用这个内置域,而是自己建立了一个全局的映射表。每次窗体消息回调时,查表来找到对应的窗体对象。这种非侵入式的方案,也凑合用吧。就是对于用 C/C++ 编写代码的追求性能的同学来说,或许有些小小不爽。


这就是我初步阅读 libuv 代码的一些简单看法。当然,我觉得 libuv 是个很不错的东西,不然我也不会饶有兴致的玩了一晚上。只是由于在这块投入时间精力不多,错误难免。有行家看到,一笑了之吧。

January 19, 2012

一个链接 lua 引起的 bug , 事不过三

今天花了将近 3 个小时帮同事看一个崩在 lua VM 中的 bug 结果打乱了进度,没有在年前把预想的东西做完。其实说起来这不是个大问题,以前也碰到过。我检讨自己没有在看到出错时的调用栈时去看一眼 lua 相关的代码。如果是那样,因为以前遇到过同样的问题,所以就可以条件反射出问题原因,而不用荒废宝贵了数小时时间了。

唉,这下整合的进度没接上,过年不能自己一个人接着做下面的活了。

下面记录一下这个 bug ,提醒自己第三次遇到时不用再花时间找问题:

问题的缘由是因为的不同的 lua 扩展库链接的时候多链接了一份 lua 库,导致进程内有超过一份 lua 库。这是在写 Makefile 时不小心造成的。知道原因后自然很容易解决。要么把 lua 编译成 .so ;要么只有嵌入的主框架使用 -E 参数静态链接 lua 的静态库。这里就不展开说了。

用了 lua 十年,绝对不是第一次帮其他用 lua 的同学指出这个问题。下面要讨论的是错误产生后,程序是怎么崩溃的。

lua 的代码中几乎没有使用全局变量。就是说,lua 的 api 的相关状态仅存在于参数 L 中,而和库无关。这点 lua 实现的是很漂亮的。对于一个无外部状态的 api ,理论上无论代码段在进程内被链接再多次,都不会有副作用的。但副作用的确又发生了,为何?

问题出在 ltable.c 中引用了一个静态全局变量 dummynode_ 。这个东西是做什么用的呢?

在 lua table 实现中,为了不让 hash part 为空的时候不引用 NULL 指针(一种优化),而引用了这个 dummynode ,这样就可以减少操作表时的判断操作。

由于这个 dummynode 是静态分配出来的特殊节点,所以是不能调用内存管理函数去释放它的。lua 另外用一个宏来判断一个节点是否为 dummynode 。

#define isdummy(n)      ((n) == dummynode)

当进程中链接了多份 lua 库时,就出现了多份 dummynode 对象,自然 isdummy 的行为就可能不正确了。那么接下来的释放工作则会崩掉,看起来段错误会发生在 lua_Alloc 中。

问题多出现在运行过程中,向空 table 插入第一个 hash 值时,也就是 luaH_resize 函数的最后一行:

 if (!isdummy(nold))
    luaM_freearray(L, nold, cast(size_t, twoto(oldhsize))); /* free old array */

或是关闭 lua state 时,释放 table 时,的 luaH_free 函数最前面:

  if (!isdummy(t->node))
    luaM_freearray(L, t->node, cast(size_t, sizenode(t)));

之所以我把代码和出错位置列出来,是因为我今天不是第一次专门分析这个点了。如果我下午在刚出问题时,能够在看到 stack traceback 信息的函数名)luaH_resize 打开 lua 的源代码看一眼的话,我相信我能立刻回想起上次分析这个问题的情景,从而节省掉后来浪费在查别的地方上的无尽时间。

希望经过这次,下次有人向我反应类似问题时,我能直接对应到问题。


4 月 19 日补充:

如何在出现这个问题时立刻检查出问题呢?

可以在每个为 lua 写的 C 扩展库的第一行调用一下 luaL_checkversion , 它可以检查有没有多次链接 lua 库, 以及其它一些版本问题.

开发笔记 (9) :近期工作小结

最近主要就是一些琐碎的事儿,基本上正事没啥进展。

那个模拟网络环境框架的 skynet 早在一周半前的周末就完成了。一直在等蜗牛的真框架架好。看起来那个昨天也弄好了,但是需要细节磨合需要两天,希望在年前能整合好客户端和服务器框架,过个好年。

我觉得理想情况下,这部分进度能快上几天。但我也觉得目前的进度可以接受。集成这些个不同人写的东西本来就是个麻烦事儿。怪物公司的客户端的网络部分也因为方案调整花了好几天事情修改。

我的想法是尽量隔绝客户端和服务器的具体设计方案。在知识点上不要紧密耦合。就是说只交代很少的一点通讯基本协议,然后由不同的人分别设计代码的架子,看最后再怎么合起来。这样可以保证各自的独立性。适应将来的分工和扩展。

毕竟,自己设计自己实现是最保险不太出问题的做法。尤其对于已经有多年开发经验的程序员来说更是如此。我给自己的定位就是尽量让每个人写的东西可以不出太多问题的粘在一起。

客户端那边基本可以操控一个 3d 角色在场景中走跑跳了,以 wow 为标杆的话,可以说操作手感几乎和 wow 无二。这算是完成了我们第一阶段的目标。我知道做到这点其实不算容易,小 V 同学花了不少精力。每天我都看到他是 22 点以后才离开办公室的。晚上都在调动画控制、镜头控制方面的程序。

前几天为策划设计的 DSL 经过这周的应用加了诸多需求,我的代码改了又改,发觉我终于成功的把函数式编程的理念部分灌输给了没写过程序的策划。并且他表示写的很爽,可以在我做的沙盒里测试战斗结果了。我觉得这也算是把战斗逻辑独立开的一次小成功吧。性能方面的问题等后期或许还要再考虑一下。当然,设计 DSL 的目的之一也是为了日后性能优化留下空间。毕竟如果是一个通用语言,恐怕我只能求助于类似 luajit 这样的东西了。

服务器逻辑方面,我希望年后把前面想的诸多部分顺利整合起来。还是自己先做原型,探一下水的深浅。大约就是采用一开始想采用的 agent 模式。把对单个 client 服务的代码集中写在 agent 服务中。由 agent 再和内部其它服务沟通。数据加载使用共享内存的方案,由 agent 向持久化模块发出信号,做加载或纯盘处理,通过共享内存得到结构化数据块。

逻辑处理流程中的共享状态问题,采用简单的生产消费模式。修改每个玩家数据的操作全部放在 agent 中,而其它服务,尤其是场景服务通过只读的方式消费这些(共享内存中的)数据。年后,(或者有空的话,在过年期间)花大约 10 个小时写出一个粗略的游戏场景服务器看看可能会碰到哪些当初没想到的问题。并把交给策划去写的战斗公式模块整合进来。当然,这部分的设计还需要理出一份详细的文档再开始干活。


今天去岩馆攀岩累了个半死,手都爬软了。就剩下脑子还算比较清醒。精神亢奋,正好花点时间码点字做个工作记录。

January 18, 2012

今天终于爬先锋了

说来很惭愧,接触攀岩运动有 7,8 年了。一直没好好玩。话说线也开过了,结组爬长线路也试过了。就是一直不敢爬先锋。

估计是被人教育的,说先锋这要注意,那要小心等等。说着说着,想着想着,心里就越来越畏惧。

ps. 写给不懂攀岩的同学:

一般运动攀岩,初学的时候,都会用一跟绳子吊在顶上,你攀爬的时候,有一个同伴在下面拉着绳子不断收紧。一旦你攀爬脱手,人会有绳子吊着,所以非常安全。一般说来,攀岩运动比骑自行车都要安全很多。可以算是挂着极限运动名号的最安全的游戏了。

但一旦你出去野外攀爬,就不大可能山顶有绳子垂下来了。许多不懂的同学总是问我,你们是不是每次要先绕到山背后去把绳子挂好的?

我说,其实是边爬边把绳子挂上去的。许多同学表示不理解。他们老把一些 free solo (不做保护的自由攀爬)的视频和先锋攀登在想像中混为一谈。其实那些玩法,玩的人极少,而且也是看似危险,其实也不然。当然我这辈子都不会有 free solo 的打算的。

先锋攀登简单说就是在爬到 2 米以上后,就用快挂(一种锁具)挂在事先用膨胀螺栓在岩壁上打好的挂片中。然后再把绑在腰间的绳子挂在快挂的环里。然后每向上爬几米,就不断的挂快挂、挂绳子到快挂中。

下面做保护的同学一点点放松绳子,让攀爬的同学一点点把绳子拖上去,直到顶端。

中途任何一个位置脱手的话,人有可能做几米的自由落体,但一系列的快挂会挂住绳子,不会让你直接落到地面。总的来说,还是非常安全的。当然前提是,攀爬者和保护者要有长期默契,保护人也有许多技术要掌握,需要一些经验。在发生冲坠时,危险的不只是攀爬的人。下坠的冲力有可能把下面的人带起来撞到石头上。

细节就不多解释了。不懂问 google 。

今天是我第一次爬先锋,心里压力有点大。阿文让我在下面练了一个小时的挂快挂的手法。果然是个熟能生巧的活。我在下面练的好好的,结果到上面没气力的时候脑子就懵了,不知道该用什么动作把绳子挂进去。

这个对心理的考验挺大。到最后感觉再上一步就可以挂下一把快挂时,对自己的手就没信心。觉得手心都冒汗,担心自己冲上去了也没力气把绳子挂进去。

今天在屋檐下爬了两小时,最后一趟刚摸石头就下来了。担心自己手软挂不到第 2 把有危险。可是下来后挂了顶绳又哧溜一下上去把快挂都收下来了。看来这心里障碍还需要慢慢克服。

不过今天享受了几把自由落体,居然跳的时候一点不害怕的。比预想的好多了。反而觉得小刺激的。

再练上两个月,应该能自己背绳子上白云山了吧。

January 12, 2012

12306 可望成为中国最大的 SNS 网站

现在有哪家 SNS 网站可以帮用户制造几小时甚至几十小时的共处时间? 还能有共同话题方便搭讪?

没错, 12306 有这个潜质. 它可以帮助适龄男女(通过身份证) 共搭一趟列车, 并肩回家过年。

过于矜持,没留电话?没关系。12306 帮您联系上偶遇的 TA 。

深入挖掘一下,很多潜力(收费项目)可以有。单这一点,就值得铁道部把 12306 做的更好一些,直接通过电子商务创收。单独 IPO ,去骗广大股民的钱。这个概念大家一听就明白,果断上钩啊。


昨天说的排队系统,很多同学提出若干质疑。最多的问题是:黄牛来了肿么办?

本来这么简单的问题,可以有无数方案来解决。我都不忍心说出来几个剥夺您思考的乐趣。不过就着今天 SNS 这话题我提一个。

领排队 ticket 前,需要先提交身份证。排到了必须用这个身份证买一张,或多张同一车次的票。如果排队时间过长,我们可以把排队的人放在一个(多个)大聊天室里。可按年龄段啊,IP 段啊区分一下。

这不就是一同城交友么?

如今还有什么地方好凑这么多人一起聊天的?还不用太担心人妖、装嫩啊等等。谈的投机再想一个方法买一趟车回家,多好。

各位同学可以自由发挥,随便 YY 。

January 11, 2012

铁路订票系统的简单设计

其实铁路订票系统面临的技术难点无非就是春运期间可能发生的海量并发业务请求。这个加上一个排队系统就可以轻易解决的。

本来我在 weibo 上闲扯两句,这么简单的方案,本以为大家一看就明白的。没想到还是许多人有疑问。好吧,写篇 blog 来解释一下。

简单说,我们设置几个网关服务器,用动态 DNS 的方式,把并发的订票请求分摊开。类比现实的话,就是把人分流到不同的购票大厅去。每个购票大厅都可以买到所有车次的票。OK ,这一步的负载均衡怎么做我就不详细说了。

每个网关其实最重要的作用就是让订票的用户排队。其实整个系统也只用做排队,关于实际订票怎么操作,就算每个网关后坐一排售票员,在屏幕上看到有人来买票,输入到内部订票系统中出票,然后再把票号敲回去,这个系统都能无压力的正常工作。否则,以前春运是怎么把票卖出去的?

我们来说说排队系统是怎么做的:

其实就类似我们去热门馆子吃饭拿号。只不过要防止别人伪造号插队而已。

如果你来一个人(一次 HTTP 请求),我就随机产生一个我做过一些签名处理的号码返回给你。暂时称为 ticket id 。这个 ticked id 是很难伪造的。

系统在内存里开一个大数组(32G 内存够排上亿人了吧),就是一循环队列。把这个 ticket id 放在队列尾。

用户现在拿着 ticket id 向网关发起请求。网关利用一次 hash 查询,在内存中的数组队列里查到它的位置,立刻返回给用户。用户的前端就可以看到,他在这个网关(售票大厅)前面还有多少人等着。

这里的关键是,整个队列都在本机的内存中,查询返回队列中的位置,可以实现的比一个处理静态文件的 web server 还要高效。静态文件至少还要去调用文件 IO 呢。静态文件 web server 可以处理多少并发量,不用我介绍了。

同时,前端会控制用户拿着 ticket id 查询队列位置的频率。高负载时可以 1s 一次,甚至更长时间。为了防止用户自己写脚本刷这个请求(虽然没有太大意义,因为刷的多也不会排到前面去),如果见到同一个 ticket id 过于频繁的查询。比如 10s 内查询了 20 次以上。就直接把这个 ticket id 作废。持有这个 ticket 的人就需要重新排队了。

对于最后排到的人,系统会生成一个唯一的不可伪造的 session id ,用户下面就可以通过这个 session id 去做实际的购票流程了。可以连去真正的购票服务器,也可以通过网关中转。非法的 session id 会立刻断掉,用户一旦知道伪造 session id 几乎不可能,只有通过 ticket id 排队拿到,除非是恶意攻击系统,不然不会有人乱拿 session id 去试。

我们再给每个 session id 设置一个最长有效时间,比如半小时。如果超过半小时还没有完整购票流程,那么就重新去排队。

至于同时开放多少个 session id ,也就是相当于开放多少个购票窗口,就取决于购票系统能承受的负载了。不过简单计算一下,就知道有排队系统保证了良好的次序,再以计算机的吞吐能力,解决不过几亿人的购票请求,即使这些人都同来排队,也就是一组机器几小时的处理量而已。

这票 blog 也就是随便写写,可能不太严谨,但意思达到了。中间有很多数据需要估算,也不是太难的事情。

为什么现在的购票系统这么滥?关键在于大量的网络带宽,计算力浪费在了“维持次序”上。系统不稳定时,大量的只做了一半的无效的购票流程浪费掉了这些。要响应高并发的 HTTP 请求,关键就在于迅速反应,不要什么都想着从数据库绕一圈。排队的队伍维持就完全不需要使用数据库。如果所有 HTTP 请求都立刻返回,在短时间内可以处理的 HTTP 请求量也会非常大。而如果你一下处理不了这个请求,又把 TCP 连接保持在那里,就莫怪系统支持不住了。

另外,用户看到了不断在减少的队列前面的人数,他们也会安心等待。只要网站页面刷新流畅(只处理队列信息很容易保证),用户体验会很好。


最后补充几句废话:因为铁路购票系统很多年前就实现了内部网络化,有成熟系统支撑,运作多年。这次做互联网版本,一定不能放弃原有系统新来一套。不然实体购票点也在网页上刷不出票就崩溃了。

所以要做的仅仅是怎么做一个系统和原有系统对接。这样风险最小。两套系统可以分别优化处理能力。基于这个设计起点,所以我才不看好所有企图取代原有系统的方案。

January 09, 2012

开发笔记 (8) : 策划公式的 DSL 设计

今天很早就起床了,以至于到了办公室还不到 11 点。中饭前有一个多小时可以做各种杂事。

我把周末做的工作和蜗牛同步了一下信息,然后得到了几个新需求。主要就是还是需要在协议定义中加入 protobuf 中等价于 service 的东西。思索了一下,觉得有必要,就花了一个小时把特性加上。 C Binding API 方面还有一点疏漏的地方。大概是源于基于 Erlang 框架下的一些小困难。略微修改了下 C 接口协议就 OK 了。然后很 happy 的去食堂吃饭。

然后我暂时就可以转向 Client 方面的一些需求分析以及解决了。

在生成动画树的数据方面,我们的交换格式使用了某中文本中间格式,最终利用 protobuf 来做二进制持久化。在解析文本格式方面,我操起了多年前耍过的 LPEG 。(我曾经用 lpeg 写过 protobuf 文本的解析工具)这个绝对是解析文本的利器啊。午饭时在食堂大家围在桌子边吐槽 Java ,说道 Java 社区最二的莫过于抓着个 XML 当救命稻草不放,所以便有了各种淡疼的基于 XML 的框架。如果早期 Java 社区能多那么几个受过 Unix/C 传统熏陶过的程序员,就能知道设计 DSL 来解决问题是多么爽快的事情。也不至于在 XML 的树上吊死了。唉,搞得现在积重难返了。

下午正式和策划进行沟通,观看他们这段时间写的各种 excel 表格。我说,你们放开了想怎么把问题表达清楚吧,只要逻辑清晰有条例,信息不要漏掉,怎么表达那些公式都行。我慢慢看,然后规范写法,最终方便程序解析。

以前见过许多项目,有的设计出繁杂的 excel 表格式,然后 export 给程序用;有的干脆让策划写程序代码;甚至有的做一堆漂亮 UI 的公式编辑器。我想最快也最方便达到效果的,莫过于设计一个最小需求集合的 DSL ,让策划认同其语法,然后使用 DSL 来编辑了。

为什么是 DSL 而不是特定语言代码?因为通用语言往往是为了解决更繁杂的需求,有很多多余的语法会干扰策划的思维。他们会将大量时间浪费在学习语言、检查语法错误上。

另外,限制于特定语言也会局限项目的发展。很有可能以后你会换一种方式去解析公式。比如在性能无所谓的时候,你想用 lua 代码就好。但性能敏感了,你又想用 C 实现,等等。有个中立的 DSL ,为以后留下更多的优化空间。

我们把公式定义和计算这个模块的知识依赖尽量的做小了,只依赖一种简单的 DSL 实现。

当然 DSL 的定义也是在不断发展的,这个需要语言设计的经验以及对问题域的理解。这两点我都不太够。只能试试。我相信对于这样一只小团队,是可以承受某种程度的变化的。更何况 DSL 是我自己实现的,当一些重大修改发生后,我可以自己写工具来批量修正历史代码。

我几个小时来了解需求,并定义 DSL 草稿。

策划需要的大概是列出一些可以做基本四则运算的公式,依靠一些变量(通常是人物属性),计算出新的值,赋给新变量。当公式比较复杂的时候,他们希望可以自定义一些函数,这些函数几乎都是 n:1 的。输入 n 个参数,得到一个值。

最常用的两个外部函数(策划往往不像程序那般思维,他们不把数学运算以外的数据处理称为函数,但程序员却这么看,我们也容易灌输这个观念),一是查表。就是在 excel 里做一张单独的二纬表,查询第几行第几列的值。我想了一下,其实最终这个是一个三纬向量:表名本身是一个维度、行列是另两个;其二是随机数。

有了这些,几乎就可以满足策划的所有计算要求了。

但是有些计算还需要有一点流程控制。以某策划同学爱玩的魔兽世界为例,就需要先产生一个随机数,判定攻击是否被躲闪;一旦被躲闪,后面的计算就不需要了。如果不被躲闪,则算下去。当然在 DSL 中设计流程控制也不无不可。但我觉得仅仅是这种需求还没有必要增加它。我想,如果顺序执行每条表达式的功能足够的话,那么最好不要加新的知识。所以我决定向策划推销 bool 运算规则。

毕竟大家都是理科出生,很快就明白了。lua 风格的 and or not 的短路规则很简单,写几个范例大家就懂了。最后我设计了这么一个粗陋的东西。

DODGE := 50
PARRY := 30
CRITICAL := 20
RACE := "战士"
LEVEL := 23
DPS := 100

dodge = roll(DODGE)
hit = not (dodge or roll(PARRY))
critical = hit and roll(CRITICAL)
_critical (race , level) -> table("cri" , race , level) * 0.1 + 1
damage = hit and (DPS * (critical and _critical(RACE , LEVEL) or 1))

里面有一张 cri 表,我先用文本格式表达,以后再花半小时去支持 excel 格式。

     10-19  20-29   30
战士 1      2       3
牧师 2      4       6

今天就不在 blog 上解释了,反正日后总要花时间写文档的。等明天先口头教一下我们的设置策划去用。

实现这么一套 DSL 解析大约花掉了我大半个下午。应该感谢 lua 和 lpeg 的便利,100 多行代码就把整个模块和应用工具写完了。主要是要方便策划测试使用。

今天的流水账就先记在这里了。

开发笔记 (7) : 服务器底层框架及 RPC

很久没有写工作笔记了,如果不在这里写,我连写周报的习惯都没有。所以太长时间不写就会忘记到底做了些啥了。

这半个多月其实做了不少工作,回想起来又因为太琐碎记不太清。干脆最近这几天完成的这部分工作来写写吧。

我在 开发笔记 第四篇谈到了 agent 的处理流程。但实际操作下来还是觉得概念显得复杂。推而广之,对于不是 agent 的服务,我需要一个通用的消息处理框架。

对于每个服务器,可以看成是对一组约定的服务协议进行处理。对于协议分组,之前我有许多想法,可做下来又发现了若干问题。本来我希望定义出一个完整的 session 概念,同一个 session 下,可以分不同的步骤,每个步骤都有一个激活的协议组。协议组之间可以共享状态,同时限制并发。做下来发现,很难定义出完整的事务处理流程并描述清楚。可能需要设计一个 DSL 来解决这个问题更好一些。一开始我也是计划设置这个小语言的。可一是时间紧迫,二是经验不足,很难把 DSL 设计好。

而之前的若干项目证明,其实没有良好的事务描述机制,并不是不可用。实现一个简单的 RPC 机制,一问一答的服务提供方式也能解决问题。程序员只要用足够多经验,是可以用各种土法模拟长流程的事务处理流。只是没有严格约束,容易写出问题罢了。那么这个问题的最小化需求定义就是:可以响应发起请求人的请求,解析协议,匹配到对应的处理函数。所有请求都应该可以并发,这样就可以了。至于并发引起的问题,可以不放在这个层次解决。

我谨慎的选择了 RPC 这种工作方式。实现了一个简单的 RPC 调用。因为大多数服务用 Lua 来实现,利用 coroutine 可以工作的很好。不需要利用 callback 机制。在每条请求/回应的数据流上,都创建了独立的环境让工作串行进行。相比之前,我设计的方案是允许并发的 RPC 调用的。这个修改简化了需求定义,也简化的实现。

举例来说,如果 Client 发起登陆验证请求,那么由给这个 Client 服务的 Agent 首先获知 Client 的需求。然后它把这个请求经过加工,发送到认证服务器等待回应(代码上看起来就是一次函数调用),一直等到认证服务器响应发回结果,才继续跑下面的逻辑。所以处理 Client 登陆请求这单条处理流程上,所有的一切都仅限于串行工作。当然,Agent 同时还可以相应 Client 别的一些请求。

如果用 callback 机制来表达这种处理逻辑,那就是在发起一个 RPC 调用后,不能做任何其它事情,后续流程严格在 callback 函数中写。

每个 RPC 调用看起来是这样的:

  local salt = service.call(addr ,
     "login.salt" , { username = username }).salt
  local response = md5(username .. ":" .. password .. ":" .. salt)
  local ok = service.call(addr, "login.challenge" ,
     { username = username , response = response }).ok
  print(ok)

而 service 编写是这样的:

-- login.lua
function salt(username)
  local salt = gen_salt(username)
  return { salt = salt }
end

function challenge(username , response)
    local salt = gen_salt(username)
    local password = service.call("authdb",
       "query" , { username = username }).password
    local result = md5(username .. ":" .. password .. ":" .. salt
    return { ok = (result == response) }
end

大家需要约定中间的协议,采用 protobuf 格式描述:

package login;

message salt {
  optional string username = 1;
  message response {
    optional bool salt = 1;
  }
}

message challenge {
  optional string username = 1;
  optional string response = 2;
  message response {
    optional bool ok = 1;
  }
}

btw, 我没有用 protobuf 的 serivce 特性。觉得不太方便,而且也没有太多必要引入过多特性。

当然这只是 RPC 范例代码,不设计认证协议的具体设计。实际应用中,校验过程会比这个更完备一些。

在 login 服务中,收到用户来的请求,会去向 authdb 服务索取密码信息。这个 RPC 调用是阻塞的,直到 authdb 返回结果。但是,login 服务并不被阻塞,可以接受其它请求。每个不同请求的回应对应关系,是在低一个层次上的 session id 来区分。这些隐藏在框架实现中了。

设计细节今天先不列了,也没太多难度。

最后,我对 RPC 的使用是谨慎的。必须留意 RPC 会带来的问题。这些在《Unix 编程艺术》7.3.2 有论述,我再说多少次也超不出这个范畴。


做这套东西,我和蜗牛是有一些分工的。服务器数据流框架并不是我来实现。蜗牛用 erlang 做的。

这些东西我们折腾了好久,这里面有我的很多工作失误。最终,我觉得应该把工作严格划分开,需要有一个清晰的模块需求定义。

本来是初步定好了,每个服务工作在一个独立的 Lua State 中(并不一定在独立进程/线程中),包括为每个 Client 服务的 Agent 。一开始是在 Lua 的层次来划分工作的。蜗牛写了一部分的 Lua 驱动代码。我觉得是我在分工问题上的错误。这个需求很难稳定下来。后来,我整理了一下思路,决定定义明确的 C 接口,然后我来做 Lua 封装。

服务器的大框架主要解决玩家的接入问题,各个服务间的数据流向问题,服务的启动和停止等等。我设想,每个服务都有单一的输入输出流,只面对框架。实现上可能框架并不是在做复制转发,有可能直接对接两个服务的输入输出。尤其是两个服务可能是在同一个线程中,以 coroutine 的形式工作。这个优化空间可以留下来。

我在做后面的工作之前,先写了一份文档,描述需求:

  • 向 skynet 发送一条指令, 并立刻获得一个结果. 目前提供以下三条指令:
    • 注册自己到 skynet , 可以给出一个 well-known 的名字 (name), 也可以不给出. skynet 返回一个 unique 名字 (uid)
    • 向 skynet 查询系统时间
    • 设置 timer , skynet 会在 timeout 时回调
  • 向 skynet 发送数据, 由 skynet 决定把数据送达何处.
  • 注册一个 callback , 当 skynet 有消息送达的时候触发.

注:skynet 是框架的开发代号。

然后是接口的 C 定义

void * skynet_context(void); void skynet_exit(void * context); void skynet_step(void * ud); const char * skynet_command(void * context, const char * cmd , const char * parm); void skynet_send(void * context, const char * addr , const char * msg, size_t sz); typedef void (*skynet_cb)(void *context, void *ud, const char * uid , const char * msg, size_t sz); void skynet_callback(void * context, skynet_cb cb);

注: command 我选取了最简单的字符串风格。是因为考虑到字符串的知识依赖最小,很容易 binding 到任何语言中。也最容易实现网络传输。不同 command 的协议定义也写了文档,包括文本协议的定义。这里就不一一列出了。

有了文档之后,我开始编写 skynet 的黑盒。基本上就是一个本地单一进程,用来调度同一进程下的不同服务。这个很好写,大约两三小时,不到 300 行 C 代码就搞定了。虽然限制很多,性能也很低下,但可以实现以上的接口。

之后的代码就不再是假盒子了。是把这几个 C 接口 binding 到 lua 里,然后在 lua 层面写真实的应用。工作量比较大,但也好做。一旦蜗牛那边真正的 skynet 工作正确,整合工作只是替换前面几个接口的实现库而已。


Blog 写这种系列, 不太方便整理。 这点还是 wiki 比较好。我把这个系列整理出一个列表了

ps. 这段时间还做了许多琐碎的工作,包括帮客户端解决一些问题。中间闲了两天,想玩一下 LaTeX ,结果就写了这个:Lua 源码欣赏 。结果工作一忙就没时间写下去了。估计这个半拉子工程会这么太监掉的。

January 07, 2012

关于分工合作

最近工作有点感触, 关于如何分工的。

我觉得所谓设计和实现是无论如何都很难分拆出去的。就是说你不实现你设想的结构,永远都很难知道哪里有问题;即使没有问题,换一个人来实现你想的东西,也无法把设计意图全部传达过去。如果可以做到,那么耗费的时间和精力足够你自己来实现了。

这也是为什么我之前说,软件项目需要很多人一起完成可能是一个骗局 。但毕竟,一个人精力有限,项目时间也有限。分工是无奈之举。可这件事情怎样做才对呢?

我最近有所体会的还是那些被嚼过很多年的老道理。就是模块划分清晰,强内聚,低耦合之类。想强调的是,模块的层次一定要适中,同一层次上规模不能太大,有严格输入、输出接口。

这些并不是为了方便测试,检验工作正确性,而是为了拆分工作。

软件可以有若干层次,总体设计一个人来做没有问题,但在每个层次上应该有足够独立的接口。接口数据要严格控制,并有最小化的知识依赖。包括接口引用的数据类型,接口的参数数量。最好是单一语言的,如有可能,只使用 C 语言的函数和 C 语言的基础类型最为通用。即使各个组件的实现不是用 C 来编写的。

每个层次对上或对下都应该是黑盒子,有比较单一的输入输出点交互。同一层次接口数量过多,应该想办法切分成多个模块。判断模块是否应该切分的标准,不在于实现的代码行数,而在于模块的接口的数量。

这样,我们可以清晰的文档化模块的需求定义。方便把工作分拆后,其他人可以利用文档自行编写假盒子,来让自己实现的部分可以工作起来。

编写一些其他人正在做的模块的假盒子很重要。即使他人的工作已经完成,可以用真盒子来整合也不要轻易去用真的那份。因为编写假盒子的过程,这样可以增进对自己的模块的理解,也可以检验接口设计是否合理。如果假盒子太难编写,很可能是设计有问题,把交互特别繁杂的模块强行分开了。

基于这些点,就能发现,其实单一模块的规模最终控制在一个人可以完成的规模最好,然后设计和实现同一个人来完成就好了。对于不同的人合作时,只是在最后做一些接口粘合和小修正工作。