« March 2009 | Main | May 2009 »

April 19, 2009

为什么说不要编写庞大的程序

《Unix 编程艺术》中总结的 Unix 哲学中有这么一条:除非确无它法,不要编写庞大的程序。并且在第 13 章 花了一章讨论复杂度的问题。(第 13 章 复杂度:尽可能简单,单别简单过了头)

下周一,是我们项目的一个进度线。所以,周末我安排了加班。当然,我最近两年,每个休息日都给自己安排的加班,无所谓周末。不过给团队安排加班还是比较稀少的事情。

由于种种原因,不是每个人都能够把自己的休息时间贡献出来的。作为团队负责人,我的原则是,生活大于工作。如果有生活上的事情,就可以拒绝加班。对于项目,也不应该把某件事情依赖到特别的某个人身上,虽然某些东西由特定的人去做(比如维护自己写的模块/程序)会效率高一些,但是其他人也应当可以顶替。

所以,偶尔维护一下不是自己写的代码,且能够在很短时间进入状态,就是团队每个人应该具有的能力。在这方面,我们的团队做的还不是很好,不是每个人都有这样的能力。但、这也并非个人能力单方面的因素。

同样,光靠提升代码质量也不是能完全解决问题的。整个项目的模块切分、文档定义等等更为重要。

说起来,我的感触就是:”除非确无它法,不要编写庞大的程序“ 。简洁小巧的程序总是好维护的。尤其是容易进入状态的。我说的进入状态即:在出现 bug 的 30 分钟内可以弄明白那些不是自己写的、陌生的没有读过的代码的脉络。并且开始着手解决问题。

强调进入状态的速度,是因为,如果需要去读几个小时的代码才摸清楚结构的话,还不如等到下个工作日,让原作者给你解释或由原作者去解决问题。

其实,不仅仅是这个周末。已经有太多周末以及夜晚,办公室里人员不齐,遇到问题必须自己解决了。虽然不是所有问题都可以自己弄明白,偶尔也需要打电话给相关代码的作者问清楚。但是,大部分问题都还是可以搞定的。我觉得很大程度上,就是因为,我们从来没有编写庞大的程序,即使是迫不得以编写庞大的程序(比如游戏的 client ),也把层次和模块分的很清晰,每个都不大。

最后想说的是:不要把问题推给别人。即便不是为自己的项目工作,作为程序员,发现问题就应该着手考虑解决。这也是开源的精神(当然,使用闭源软件就很难做到了)。作为程序员,解决问题,是自身能力提高的源泉。

有时,修补他人的代码,甚至是不被你的美学所接受的代码,更能增强对代码的把握能力。比如:我放弃用 C++ 编写软件,但我不排斥阅读 C++ 项目。即便我一边用嘲讽的语气去评论那些“精巧的”设计,另一方面也会客观的去理解是什么需求导致了如此的结构。


最近,工作太忙。没时间看书。只是把《Unix 编程艺术》放在手边,累了就翻一下。其实呢,许多道理本就明白,可每次看见白纸黑字,还是觉得的确很有道理。最近一年,觉得自己悟了许多。悟:并非明白了以前不明白的东西.知识还是那些知识,道理还是那些道理,似乎没有学到新的,又胜过学了新的东西。


一则有趣的对比:

我们的项目在 Windows 下,使用 mingw make 全部需要 47 秒(很干净的 Windows XP ,没有装任何杀毒软件)。

但是在 freeBSD 上用同样的机器,做同样的事情只用 18 秒。

谁可以解释这个差异?

因为 Windows 开进程太慢?文件系统太糟糕?Console 速度太慢?mingw 的 gcc 太慢?

April 08, 2009

两个 bug

今天查出两个 bug ,都很典型。值得记录,警示后人。

其一是,我们的编辑器服务器的数据储存部分,采用向一个文件追加的方式,保证数据不丢失,同时用一个 index 文件记录每个数据的最新版本在这个数据文件中的偏移量,加快检索。

实现的时候,考虑过数据文件过大(比如超过 4G)的情况。但是由于疏忽,index 里的偏移量全部用的 32bit 而不是 64bit 。结果在服务器工作了很久后,终于碰上了溢出,index 文件损坏。

后果倒不严重,重建一下索引即可。

其二是,前段时间改进了 client engine 的初始化流程。

因为 opengl 模块的初始化需要有窗口先创建出来,而窗口的创建跟逻辑又有关系。以前一直设计的不好。(没有做到沉默的把事情做完)

为了让模块初始化过程内聚性更高。我换用了一个简单的办法。无论用户有没有创建窗口,都先创建一个假窗口,先把 opengl 初始化好。待到用户自定义窗口的时候,再销毁掉假窗口,换成新的。

由于失误,这个过程中把 opengl 的 context 也删除了。

正确的做法是,先创建的假窗口可以销毁,但是不能销毁 opengl 的 context ,而是把以前创建好的 context 切换到新的窗口上即可。

解决起来很简单,但是这个问题很隐蔽,平时没发现,直到今天才暴露。我在用户自定义窗口创建出来前,构造了几个 opengl 对象,随着 opengl context 一起删除了。因为 opengl 的 handle 都是整数,新创建出来的 context 里也有这几个 handle ,程序没有崩溃,只是显示不正常而已。

April 06, 2009

为 lua 插件提供一个安全的环境

wow 开放了一套用户自定义的插件系统,很多人都认为,这套系统是 wow 成功的因素之一。反观国内乃至韩国的网游,至今没有一款游戏能提供相当自由度的用户自定义插件系统。

最开始,暴雪是想让用户可以由用户甚至第三方自定义操作界面。后来,这套基于 XML 和 lua 的插件系统不仅仅用来做界面了。

从我在网游行业从业这么多年的经验,游戏界面相关的开发也是颇费人力的。甚至于,Client 开发到了维护期,几乎都是在 UI 上堆砌人工。一套自由的插件系统,对于开发商和用户是双赢的。

但是,插件系统也是双面剑。最典型的负面问题就是安全。越是自由,越是给机器人制作开放了自由之门。这里暂且不提这个方面的问题。首先关注一下另一个:尽可能的保护系统中不想被插件系统访问的数据,避免利用插件编写木马。

lua 有第三方库提供了很好的解决方案。

比如可以考虑 Lua Rings ,通过它,可以提供多个 lua state 做 sandbox 。如果在 state 间有高交互性,那么或许有一些性能问题。

如果不使用第三方库,我们就回头看看语言本身,其实 lua 语言已经提供了很多机制来隔绝模块间的数据交换。

最常用的是 setfenv ,为函数设置环境。环境可以限制函数能访问的数据范围。但是,我们也需要警惕这方面的漏洞。比如你想给插件的环境提供一些基本 api ,例如 pairs print 等等。简单的把这些 api 复制到插件环境是不行的。

因为用户可以用 getfenv 取出这些 api 的环境,从而突破你的封锁。除非,你干脆不提供 getfenv 给用户使用。

正确的方法是用 metatable 做一个自动封装,当用户调用权限允许的 api 时,生成一个 closure 去调用这些 api ,并给 closure 加上受限的插件工作环境。

另一个问题是如何把内部对象暴露给插件系统。简单的把系统中的 lua 对象交给用户是不安全的。用户用一个 for 就可以迭代出所有的细节。

正确的做法是做一个 proxy ,设置元表来做权限检查,只让用户访问可以访问的数据。我的个人推荐是,先为对象实现一套属性机制(采用 index 和 newindex 元方法),仅把属性机制暴露给用户,而避免用户直接调用操作对象的方法。对属性做权限检查要简单的多。(我想,开发商一定不愿意让用户可以通过插件获得密码框里的输入数据吧?但是你的引擎却一定办的到)

剩下的问题还剩一个:如何避免用户通过 getmetatable 去获取元表?

阻止用户获取系统对象的元表的方法很简单:为你的元表设置 metatable 元方法。那么 getmetatable 就不会再返回真实的元表,而是返回元表中 metatable 项的东西。

而制作这样的 proxy 对象,你可以简单的用个空表,加上经保护的元表即可。注意:不要把真实对象放到这个 proxy 表内,而应该另外建一张插件环境访问不到的弱表做映射。

还有另一个选择是使用未公开的 api :newproxy 。不要被 unsupported and undocumented 的字样吓到。如果你有需要用它,即使以后官方取消了对其的支持,你也可以在 C 里自行实现一个。

在保护 metatable 方面,userdata 比 table 更安全。

有人问,为什么 newproxy 的行为这么怪异,为不同的(0 字节)userdata 赋予相同的 metatable ?其实看过上面的应用就应该明白了。我们需要的仅仅是对象的唯一性而已。即使不用 userdata 制作 proxy ,也是用张空的 table 而已。真实的对象不应该放在 proxy 里面,而是在外面放张弱表做索引(防止用户得到真实的对象)。使用 userdata 也一样,newproxy 返回的对象是 unique 的,正好做索引用。至于元表,一类对象公用一个就足够了。

最后,需要注意的是:如果你的内部机制需要传递内部对象。比如在我们的系统中,对象的组织关系以树结构管理。就可能出现下面这样的代码:

a.parent = b

把 a 挂在 b 的分支上,其中 parent 是 a 的一个属性:最终,engine 调用了 api :

a:setParent(b)

其中,b 就是 link 方法的参数,类型是我们自定义的对象。

把这些放到插件环境中,a 和 b 都不可能直接暴露给用户,暴露的只能是 a 和 b 的 proxy 。

proxya.parent = proxyb

最终会被转义为:

a:setParent(proxyb) 而不是 a:setParent(b)

这里的问题根源是,lua 的元表做不到 C++ 里那种对象类型的隐式转换。(即不能象 C++ 里那样,为 proxyObject 定义一个 operator Object() 的操作)

解决方法有二:

其一:属性赋值的时候,检查右值是不是 proxy 对象,若是,则转换成真实对象。这个方法直接,但是需要在底层暴露 proxy 这个概念。

其二:定义一个 plug 的内部属性,返回真实对象。并在权限控制上,禁止插件环境访问这个内部属性。即把

proxya.parent = proxyb

转义为:

a:setParent(b:getPlug())

April 05, 2009

卡牌中的数学

卡牌对决 封闭内测结束的时候,我们的策划为了奖励第一批玩家,每个帐号送了一张特别的卡,唤作 开天辟地

这个游戏的规则是,每个玩家自选 50 张卡入场,系统洗乱后,每回合每人从自己的卡堆里摸一张出来拿在手上。另外,游戏开始前,每个人先摸 5 张手牌。

除了一些特殊卡外,大部分卡在游戏中用掉就放入弃牌堆。只有等 50 张卡摸完才可以重新洗牌重摸。但开天辟地改变了这个规则,使用后立刻把游戏中每个人的卡重置到开始状态。(弃掉手牌,把所有 50 张重洗重发)

许多新玩家挺痛恨这张卡的(出现后很容易破坏别人的战术,并且可以让自己卡组里的强卡更高频率出现)。更过分的是,有一种叫作“开天党" 的玩家,一拿到 "开天辟地" 就立刻使用。今天 游戏论坛 上有个玩家想计算下,”开天党" 在一局游戏中可能使用开天辟地的次数的数学期望值

我觉得这个问题满有趣,就帮忙计算了一下。

由于卡牌游戏的复杂性,影响摸牌的因素很多(有些卡用掉了不是放到弃牌堆,有些卡会加快摸牌的速度,等等),所以我们先简化了问题:


一局游戏按 60 回合计算。

一个牌组有 50 张卡牌,里面有且只有一张 “开天辟地" 。

第一回合前有 5 张手牌(未用牌堆 45 张),每个回合摸一张卡。(由于有 “开天辟地" 的存在,不可能把牌摸完重洗。

摸到 “开天辟地" 后,立刻使用,洗牌,重新摸 5 张在手上,本轮不再摸牌,若这 5 张中又出现 "“开天辟地" 必须等下一论方能使用。


看起来这个概率问题不算复杂,但是我用笔算了半天算不清楚,只好求助计算机。写了个程序去解我列出来的递推式。最后终于得到结果。

如果游戏中有一个 开天党党员。游戏中出现 "开天辟地" 的次数的数学期望为 2.63 次 。 如果有 2 人,数学期望是 4.62 次 3 人是 6.84 次 4 人是 9.29 次

貌似和玩家体验很接近。:D 结果应该是对的吧。


btw, 我们的玩家中喜欢研究数字问题的好象还不少,功略版还有人写了三篇:

不仅仅是攻城略地——卡牌中的数学

不仅仅是龟兔赛跑——卡牌中的数学(第二期)

不仅仅是追涨杀跌,卡牌中的数学(第三期)