让错的程式看得出错

作者:周思博(Joel Spolsky)
译:Paul May 梅普华
Wednesday,May 11,2005
A part of Joel on Software,http://www.joelonsoftware.com

时间回到1983年九月,我第一个真正的工作是在以色列的Oranim。这家大型面包工厂每晚都用六个货机般大的巨型炉子烤出为数十万的面包。

我第一次走进那家面包厂时觉得里头实在脏得离谱。炉壁发黄机器生锈而且到处都是油。

「这里一直都这么脏吗?」我问道。

「什么?你讲这什么话?」经理回答说。「我们才刚打扫过。这已经是几周以来最干净的时候了。」

说得真好!

我花了好几个月每天早上打扫才真正了解他们的意思。对面包工厂来说,干净是指机器里没有生面团在烤,垃圾堆里没有发酵的面团,而且地板上也没有堆生面团。

干净并不是指炉子漆得雪白亮丽。炉子大概十年才会漆一次,并不会每天都来一回。干净也不是说把油擦得干干净净。事实上很多机器都得定期上油,一层薄净的油通常暗示机器刚做过清洁保养。

DoughRounder.PNG

面包工厂里这整套干净的概念都得经由学习而来。圈外人不可能走进去就能说出哪里干净哪里脏。圈外人绝不会想到要看面团滚圆机(把方面团滚成球形的机器,见右边附图)内壁有没有刮干净。圈外人会觉旧炉子外壁镶板掉色是有问题的,因为镶板很 很显眼。不过面包师傅根本不在意炉子的涂漆开始发黄。因为面包的味道还是一样棒。

在面包工厂待两个月,你学会如何「看出」干净。

程式码也是一样的。

当你刚开始写程式或尝试读用新语言写的程式时,所有程式码看起来都一样神秘不可解。而在了解该种程式语言前,你连明显的语法错误都看不出来。

在学习的第一阶段,你会开始发现一种我们通常称为「编程风格」的东西。于是你开始注意那些不遵循缩排标准的程式码和使用多个大写字母的变数。

也就是这个阶段你会说:「该死的混蛋,我们这里_一定_ 要定出一些一致的编程风格!」 然后第二天写出一份你们团队用的编程风格,接下来用六天来讨论One True Brace Style(译注:就是K&R style),然后再花三星期把旧程式码改写成符合One True Brace Style,一直做到经理发现并责怪你把时间浪费在不能赚钱的事为止。你想想其实不需要一次全部改好,看到哪里改到哪里也没什么关系。于是有一半的程式码已经改成True Brace Style,而没多久你就忘记这件事了。接下来你就开始满脑子想着其他与赚钱无关的事,比如把某个字串类别换成另一个字串类别等等。

当你对某特定环境下的程式愈来愈精通时,就会开始学着看到其他东西。那些东西可能完全合法并符合编程风格,却又会让你担心不已。

举例来说在C语言里:

**char* dest,src;**

这是合语法的程式码;这可能符合你的编程规范,甚至可能是故意这样写的,不过如果你写C的经验够,就会注意这种写法把 **dest** 宣告成**字元** 指标 却把 **src** 宣告成 **字元** 而已,这_可能_ 是你的意思,不过也可能不是。反正这段程式看起来有点不对劲。

来看更细微的例子:

**if (i != 0) foo(i);**

这段程式是百分之百正确的;它符合大多数的编程规范也完全没有错误,不过你可能会质疑if叙述所接的单叙述主体并未用大括号包起来,因为你脑子里想到有人可能会插入另一行程式码

**if (i != 0) bar(i); foo(i);**

…又忘记加上大括号,结果让 **foo(i)** 变成永远会执行!所以当你看到没有用大括弧包起来的程式码区段时,可能就会感觉到一丝丝让你不舒服的气味。

好啦,到目前为止我已经提到三种程式师的成就层级:

1。你不知道干净和脏有什么分别。

2。你对干净有粗浅的认知,主要以是否符合编程规范为准。

3。你开始能嗅出藏在表面下不对劲的蛛丝马迹。你会察觉这是问题并且找出来修正。

不过其实还有更高的层次,而这也就是我真正要说的:

4。你有计划地架构程式码,借助能察觉问题的灵眼让程式码更正确。

这是真正的艺术:仔细地_设计_ 让错误显而易见的_编程规范_ ,借此制作出稳固的程式。

所以现在我要带你看一个小例子然后再展示一个通用的规则。你可以利用这个通则设计出创造增加程式稳固的编程规范。最后我会把主题导引到为某种匈牙利命名法(可能不是让人们晕到的那种)进行辩护,并且批判某些环境(也可能不是你最常用的那种环境)下的例外处理。

不过如果你深信匈牙利命名法不是好东西,认为例外处理是从自巧克力奶昔以来最棒的发明,而且完全不想听听其他意见,没问题,你可以改去罗力那里看看好看的漫画;反正你在这里也没什么好看的;事实上在一分钟内我就会拿出实际的程式码范例,这些范例很可能会让你在不爽前就晕睡过去了。没错。我想我的计画是把你哄到沉沉入睡,趁你睡着无法抵抗时把「匈牙利命名法=好,例外处理=坏」的想法偷偷塞进你脑子里面。

一个例子

Umbria.JPG

好了。提到这个例子。让我们假装你正在写某种web应用程式,因为这阵子小朋友似乎都流行写这玩意。

现在有一种叫跨站脚本漏洞(Cross Site Scripting Vulnearability)的安全漏洞,缩写为XSS。我在这里不谈细节:你只需要知道在写web应用程式时,一定要小心绝不能把使用者填入表单的任何字串直接传回来。

举例来说,如果你有一个网页会让使用者在编辑框输入姓名,传送后就会跳到另一个写着「你好啊,张三!」(假设使用者的名字是张三)的网页。很好,这就是个安全漏洞,因为使用者可能不输入「张三」而输入某种奇怪的HTML及JavaScript,这些奇怪的JavaScript就可能会做些低级事情,比如读出你写的cookie内容转送到坏人的坏网站去。而这些低级事现在看起来就是你搞的鬼。

让我们把程式用虚拟码的方法写出来。想像以下的程式

**s = Request("name")**

会由HTML表格读取使用者输入(一个POST的参数)。如果你曾经写出下面的程式码:

Write "你好," & Request("name")

那你的网站已经有让XSS攻击的漏洞了。光这样就够了。

你必须在复制回HTML之前先编码才能避免这个漏洞。所谓编码就是把"换成", 把>换成>,如此类推。所以

**Write "你好," & Encode(Request("name"))**

是绝对安全的。

所有来自使用者的字串都是_不安全的_ 。任何不安全的字串都得先编码后才能输出。

让我们尝试设计一组编程规范,确保当你犯这种错时程式码_看起来_ 就是错的。如果程式码有错(至少_看起来_ 错),就很有机会被修改或审视这段程式的人抓到。

可能方案一

方案一是将所有字串立即编码,由使用者取得后马上就进行:

**s = Encode(Request("name"))**

所以我们的规范会写着:如果你看到没有被 **Encode** 包住的 **Request** ,程式一定是错的。

你开始训练自己的眼睛找寻落单的 **Request** ,因为它们违反规范。

这是有用的,因为只要你遵循规范就不会有XSS问题。不过这并不是最好的架构。比方说你可能想要把这些使用者字串存到资料库里,这时候储存以HTML编码过的字串并不合理,因为字串有可能会用在HTML网页以外的场合。假如是信用卡处理程式要用时编码过的资料就会产生问题。大部份web应用程式开发都会依循一个原则:所有字串在内部都是 编码的,要等到送至HTML网页的_前一瞬间_ 才会处理,因此这可能并不是正确的架构。

我们真的要能让字串维持在不安全格式一段时间。

好吧,我再试看看。

可能方案二

如果建立一种编程规范,要求在_写出_ 任何字串时必须加以编码,是否可以满足要求吗?

**s = Request("name") **`` **// 很后面: ** Write Encode(s)

现在当你看到一个落单没有 **Encode** 跟着的 **Write** 时就知道有有问题了。

唉,这也不太好……有时候你的程式里会有一小段的HTML码,这种情况下是_不能够_ 编码的:

If mode = "linebreak" Then prefix = " <br>" // 很后面: `` Write prefix

这照我们的规范来看是错的,我们必须要在输出时加以编码:

Write Encode(prefix)

不过现在应该要新增一行的" **< br>**"却被编码成 **& lt;br>**,结果变成使用者可以看到的字元` **< br

**`。这样的解法也不对。

所以说有时候你不能在读入字串时编码,有时候你也不能在输出时编码,这两种提案都不能用。可是没有适当的编码规范,我们还是有出下列问题的风险:

**s = Request("name")**` ……好几页之后……
**name = s** ……好几页之后……
**recordset(“name”) = name // 把名字存在资料库中的姓名栏** ……好几天后……
**theName = recordset(“name”)** ……好几页甚至好几个月之后……
`` Write theName`

我们还会记得要对字串编码吗?你在任何单一的地方都看不到问题。连可以嗅的地方都没有。如果这种程式有一大缸子,要一大票侦探才能追踪出所有字串的来源并确认是否已编码。

正解

所以让我提议一种能用的编程规范。我们只有一个规则:

所有来自使用者的字串都必须存在以"us”(表示Unsafe String,不安全字串)为字首的变数(或资料库栏位)中。所有经HTML编码或来自确认安全来源的字串都必须存在以"s”(表示Safe String,安全字串)为字首的变数中。

让我们重写程式,只是依规范重新命名变数,其他完全不动。

us = Request("name") ……好几页之后…… **usName = us**` ……好几页之后……
recordset(“usName”) = usName ……好几天后……
**sName = Encode(recordset(“usName”))** ……好几页甚至好几个月之后……
Write sName`

新规范中值得注意的是,只要遵循编码规范,不安全字串相关的错误_一定可以由单一行的程式码看出来_ :

**s = Request("name")**

是之前的错误,因为你可以看到Request的结果被指派给以s开头的变数,这违反了规则。Request的结果一定是不安全的,所以必须指派给以"us"开头的变数。

us = Request("name")

一定没问题。

**usName = us**

一定没问题。

**sName = us**

一定是错的。

**sName = Encode(us)**

一定是对的。

**Write usName**

一定是错的。

**Write sName**

没问题,下面也一样没问题

**Write Encode(usName)**

每一行程式光是看_程式码_ 本身就足以检查,而且如果每一行程式都对,组合起来整个程式也是对的。

终于好了,利用这套编码规范,你的眼睛学着看到 **Write usXXX** 就知道是错的,而且你也立即知道要如何修正。我知道一开始要看到错误的程式是有一点难,不过进行三个星期后你的眼睛就会习惯,就像面包厂的工人看到大面包工厂就会马上说:「搞什么鬼,这里都没人在扫哦!这算啥面包厂。」

事实上我们可以再把规则延伸一点,把 **Request** **Encode** 函数改名(或封装)成 **UsRequest****SEncode** ……换句话说,传回不安全字串以及安全字串的函数要和变数一样,分别要用 **Us** **S** 作为字首。现在看看程式码:

**us = UsRequest("name") **`` **usName = us **`` **recordset("usName") = usName ** sName = SEncode(recordset("usName")) `` Write sName

看到我们的成果没?现在你可以看看等号两边的字首是否相同就能找到错误。

**_us_ = _Us_ Request("name") **// 没问题,两边都以US开头 `` ** _s_ = _Us_ Request("name") **// 错 `` ** _us_ Name = _us_ ** // 对 `` ** _s_ Name = _us_ ** // 一定错。 `` ** _s_ Name = _S_ Encode(us) **// 一定对。

我还能再进一步把 **Write** 改名成 **WriteS** 并把 **SEncode** 改名成SFromUs

**_us_ = _Us_ Request("name") **`` ** _us_ Name = _us_ **`` **recordset(" _us_ Name") = _us_ Name **_s_ Name = _S_ From _Us_ (recordset(" _us_ Name")) ``Write _S_ _s_ Name

这使得错误_更加_ 显而易见。你的眼睛会学习「看出」可疑的程式码,另外这也能协助你经由一般撰写或阅读程式码的动作找到隐藏的安全漏洞。

让错的程式看得出错是很棒没错,不过却不是所有安全问题的最佳解答。它无法找到所有可能的问题或错误,因为你可能没法子看过每一行程式码。不过绝对比什么都不做要好,而我很希望有套编码规范能让错误的程式码至少看起来是错的。你马上就能获得好处,每当程式师的眼睛扫过一行程式,就能检查并防止某些特定的错误。

一个通则

这种让错误程式看起来错的作法有个前提,就是要让对的东西在萤幕上紧靠在一起。当我看到某个字串时并要决定 程式码正确与否,我必须知道字串出现的所有位置以及字串是安全的还是不安全的。我不希望这些资料出现在另一个档案或是要卷动画面才能看到的另一页。我必须能_当场_ 看到,而这说的就是一套变数命名规范。

有很多其他的例子可以说明,只要把某些东西搬在一起就可以改善程式码。大多数的编程规范都有如下的规则:

  1. 保持函数名称简短。
  2. 变数宣告的地方离使用的位置愈近愈好。
  3. 不要用巨集建立你个人专属的程式语言。
  4. 不要使用goto
  5. 不要让右括弧离左括弧超过一个画面。

这些规则有一个共同点,就是尽量让一行程式码实际作用的相关资讯在画面上愈近愈好。这样能提高眼球找出程式实质运作内容的机会。

大体上我得承认我有点害怕会藏东西的程式语言功能。当你看到程式码

**i = j * 5;**

……就C来说你至少会知道j 会乘以5而结果会存到i

不过如果你在C++里看到相同的片段,你什么都不知道。在C++中唯一能知道真正发生什么事的方法就是找出 **i** **j** 所属的型别,而这个型别可能会在完全不一样的地方宣告。因为 **j** **运算子***​​ 可能有过荷,在你要做乘法时会做些很机灵的事。而**i** **运算子=** 可能也是过荷的,而两者型别可能是不相容的,于是又呼叫到某个自动型别强制转换的函数。光是检查变数的型别还不足以确认,还得检查实作该型别的程式码才行,万一实作时又有继承其他型别就更麻烦了,因为你得回溯类别继承的祖宗八代才能找到真正的程式码,不巧又有用到别处的多型就 真的 有大麻烦了,因为光是知道ij 宣告 的型别并不够,还得知道它们_此刻_ 的型别,这不知道要看多少的程式码,而且依照计算理论的停机问题,你永远都不能真的百分之百确定自己已经看完所有地方了(啊啊啊啊啊!!!)。

当你看到C++的 **i=j*5** 时你只能自求多福了,兄弟。这对我来说就降低了光看程式码找出在问题的能力。

当然啰,理论上这应该没什么关系。当你做些重载运算子*之类聪明事时,只要为了要提供一个优美而安全的抽象罢了。天啊,其实j是个万国码字串型别,一个万国码字串乘以一个整数 显然 是把正体中文转成简体中文的良好抽象作法,对吗?

问题当然出在没有绝对安全的抽象方法。我已经在抽象出错定律里讨论很多了,所以不会在这里重复。

Scott Meyers示范了各种抽象出错(至少是C++)的型式以及所造成的伤害,他靠这个主题就创出一番事业了。(顺便一提,Scott的书Effective C++第三版刚刚上市;整本书都重写过; 今天就去买一本吧!)

好吧。

有点失焦了。我最好回顾一下到目前为止的内容:

找出能让错误程式看起来错的编程规范。让正确的资讯集中在程式码中相同的地方,方便你看出某些问题并立即修正。

我是匈牙利

Lugnano.JPG

我们现在回到恶名昭彰的匈牙利命名法。

匈牙利命名法是微软程式设计师Charles Simonyi发明的。Simonyi在微软做的主要计划是Word;事实上他还主持了世界上第一个所见即所得的文书处理器(在Xerox Parc名为Bravo计划)。

在所见即所得的文书处理中会用到可卷动的视窗,所以座标值有两种意义:相对于视窗或相对于处理页。两种座标的差异很大,所以好好安排是非常重要的。

我猜这正是Simonyi开始采用某些之后被称作匈牙利命名法的原因之一。它看起来像匈牙利文,而Simonyi是从匈牙利来,所以以匈牙利为名。在Simonyi版本的匈牙利命名法中,每个变数都会加一个小写的字首,表示变数内容的种类。

打个比方,如果变数名为rwCol,rw就是字首hungarian.png

我是故意用_种类(kind)_ 这个词,因为Simonyi在他的文章中误用了_型别(type)_ ,结果好几世代的程式师都误解了他的意思。

如果你仔细读Simonyi的文章,就会发现他所讲的和我之前范例所用的命名规范是一样的,在我的范例中把 **us** **s** 分别定义为不安全字串和安全字串。这两者的型别都是 **字串** 。如果你把某种字串指派另一种,编译器并不会给任何警告,Intellisense也不会说些什么。可是他们的语意是不同的;他们解读和处理的方式都不同,要把两种字串互相指派时还要某些转换函数做转换,否则就会有 执行时期 的问题。 你好运。

微软内部称Simonyi对匈牙利命名法的原始概念为应用匈牙利命名法,因为它用于应用程式部门,也就是Word及Excel。在Excel的原始程式码里有大量的**rw** **col** ,你看到这些字首就知道它们指的是行(row)和列(column)。没错,它们都是整数,可是两者间的转换完全没有意义。有人告诉我说Word的程式码里有大量的**xl** **xw** **xl** 代表相对于排版页面的水平座标,而**xw** 则代表相对视窗的水平座标。两者都是整数但却是不能互转的。两个程式里都有很多cb,意思是位元组的个数。没错,这也是整数型别,不过光看变数名就可以得到更多资讯:这是位元组的个数,也就是缓冲区的大小。另外如果你看到**xl = cb** 就可以拉警报了。这显然是错的程式,虽然 **xl** **cb** 都是整数,可是把以像素为单位的水平位移设成位元组个数绝对是疯了。

在应用匈牙利命名法中字首可以用于函数和变数。因此虽然我真的没看过Word的原始码,我还是敢打赌Word里一定有个叫 **YlFromYw** 的函数,可以把垂直方向的视窗座标转成垂直方向的排版页座标。应用匈牙利命名法用 **TypeFromType** 取代传统的 **TypeToType** ,这样每个函数名就会以传回的型别开头,这正与我稍早在范例中把Encode 改名为SFromUs的作法相同。事实上在正规的应用匈牙利命名法中Encode函数_一定_ 要改名为 **SFromUs** 。应用匈牙利命名法在该函数命名上并没有提供其他选择。这其实是件好事,因为你少一件事要背,另外也不必担心Encode究竟是用什么型别。程式也变得精确多了。

应用匈牙利命名法非常有用,特别是当初C语言盛行,而编译器尚未提供很有用的型别系统时。

不过接下来却出了一些问题。

黑暗世界占用了匈牙利命名法。

似乎没有人知道为什么或是如何发生的,不过似乎是视窗团队中写文件的人不小心创造出后来名为系统匈牙利命名法的东西。

某处有人读了Simonyi的文章看到里面用了「型别」这个字眼,因此认为作者指的就是型别,意思就像是类别或是型别系统中,或是编译器所做的型别检查。 其实不然。作者很小心并精确的解释他用「型别」这个字的意义,不过没有用。伤害已经造成了。

应用匈牙利命名法的字首很有用而且有意义,“ix"表示阵列索引,“c"表示个数,“d"表示两个数字间的差(比如"dx"表示「宽度」),如此类推。

系统匈牙利命名法的字首作用就差多了,“l"表示长整数,“ul"表示正长整数而"dw"代表双字组(呃,事实上就是正长整数)。在系统匈牙利命名法中,字首只能告诉你变数真正的资料型别。

这误解了Simonyi的意图和实作,差异虽细微实质上却是完全不同。这件事唯一的教训是让你知道,如果你写出些没人能懂的艰深难解学术文章,你的想法可能会一再被误解,结果变得非常荒谬,完全违背你的原意。所以在系统匈牙利命名法中会出现大量的dwFoo表示「双字组的某某」,可恶的是某个变数是双字组这件事对你几乎是完全没用的。难怪大家都很讨厌系统匈牙利命名法。

系统匈牙利命名法的流传既深又广;它是整个视窗程式设计文件的标准;Charles Petzold的视窗程式设计(学习视窗程式设计的圣经)等书籍更为它广为宣扬,很快的它也成为匈牙利命名法的主要势力,即使在微软内部也一样。在微软内也只有少数不在Word和Excel团队的程式师了解他们搞出什么样的错。

接下来就是大反抗了。有群程式师们从一开始就没搞懂过匈牙利命名法,他们发现自己用的竟是烦人又几近无用的分支,于是就起来反抗。不过系统匈牙利命名法里还是有些好东西可以帮你看出问题。如果用系统匈牙利命名法,至少会在使用时知道变数型别。不过没应用匈牙利命名法那么有价值就是了。

大反抗在.NET第一版发行时到达巅峰,那时微软终于告诉大家「不建议使用匈牙利命名法」。这还真是欢声雷动啊。我根本不认为微软会花心思解释原因。他们只是扫瞄文件中命名指引的章节然后加上「不要使用匈牙利命名法」的字句。当时匈牙利命名法非常不受欢迎所以没有人会真的抱怨,而除Excel及Word以外的人都因为不必再用这么麻烦的命名规范而松了一口气,他们认为在有强型别检查及Intellisense的时代也不需要这种规范。

不过应用匈牙利命名法还是很有价值的,它加强了程式码的连结让程式码更易阅读,撰写,除错及维护,最重要的是它让错误的程式看得出错。

在继续之前还有一件事我说过要做,就是再骂一次例外处理。我上次这样做惹来很多麻烦。我在周思博趣谈软体首页上一篇即兴的评论中说我不喜欢例外处理,因为它实际上就是隐藏的goto,我认为这比看得到的goto更糟糕。当然就有几百万人跑出来痛骂我。全世界唯一跳出来替我辩护的当然也就是Raymond Chen。顺带一提,他既然是世界上最好的程式师,当然得出来讲讲话,对吗?

这篇文章讲到例外处理的重点了。你的眼睛学着看到错误的程式码,这样就能防止问题发生。为了让程式能变得真正稳固,进行程式码检视时得有一套能集中资讯的命名规范。换而言之,你眼前有关程式运作的资讯愈多,寻找错误的结果愈好。当你看到以下的程式码时

dosomething(); cleanup();

…你的眼睛会说没什么问题啊。我们总是要做清除的动作!不过 **dosomething** 有可能会引发一个例外,所以有可能不会呼叫**cleanup** 。用 **finally** 等很简单就能修正这个问题,不过这并不是我的重点:问题在于要知道 **cleanup** 一定会被呼叫到的唯一方法,就是调查整个 **dosomething** 呼叫树,看看是否有任何场合会产生例外。这也还好,可控制式例外处理(checked exception)可以让你不用那么辛苦,不过重点是例外处理把资讯分散开来了。你得去看_其他地方_ 才能知道程式能正确执行,所以无法运用你眼睛天赋的功能去学习看出错的程式码,因为根本没东西可看。

如果我写个小脚本程式,只是每天一次到处收集资料然后印出来,这时候例外处理好用得不得了。我只想忽略所有可能出错的地方,直接把整个程式用一个大try/catch包起来,如果有出什么问题就用catch把错误电邮给自己。例外处理对简单随便写的程式很有用,对脚本程式或是不是非常重要或无关生死的程式也不错。不过如果你在写一套作业系统或核电厂程式,或是用于开心手术的高速电锯,例外处理可是危险的很。

我知道大家会认为我是个无法正确理解例外处理的笨程式师,完全不知道只有当我衷心接纳例外处理后它才能改善我的生活。这种想法真是太糟糕了。想要写出真正可信赖的程式码,应该要尝试用考虑到人有弱点的简单工具,而不是靠那些提供有问题的抽象并把副作用隐藏起来,还认为程式师绝不出错的复杂工具。

补充读物

如果你还是衷心于例外处理,读读Raymond Chen的文章更干净更优雅,不过更难读。「例外处理用得正确与否,很难由程式码看得出来… 例外处理太难了,我实在不够聪明无法掌握。」

Raymond对致命巨集的文章A rant against flow control macros讨论了另一个让资讯分散导致程式无法维护的例子。「当看到使用[巨集]的程式码时,你必须看遍各个标头档才能了解它们的作用。」

想要了解匈牙利命名法的历史背景,可以由Simonyi的原文[匈牙利命名法](http://msdn.microsoft.com/library/default.asp?url=/library/en- us/dnvs600/html/hunganotat.asp)开始。Doug Klunder在另一篇比较清楚的文章中[把它引进Excel团体](http://www.byteshift.de/msg/hungarian- notation-doug-klunder) 。想知道更多匈牙利命名法的故事以及如何被文件撰写人破坏的始末,可以去看Larry Osterman站上的贴文,特别是Scott Ludwig的评论,或是Rick Schaut贴的文章

我不写软体文章时就在制作FogBugz:一套名字笨笨的聪明专案管理软体。现在就去看看(还有免费线上试用)。我们才刚推出大升级版FogBugz 4.0!

这些网页的内容为表达个人意见。
All contents Copyright © 1999-2005 by Joel Spolsky。All Rights Reserved。