July 2006


在 PHP 开发领域,不断在讨论讨论 OO ,讨论框架、讨论设计模式、讨论 MVC 模型,讨论这些所带来的种种好处。我不对这些好处进行否认,我只是认为不能盲目跟随某种开发方式,一切方法都是有适用范围的, PHP 开发也不例外。PHP 开发根据受众、服务目标等可以大致可以分为三种不同的开发领域:行业商业软件通用共享软件私有专用软件。在这些不同的领域,所主要采用的开发手段也是有所区别的。明确自己产品所在领域并确定下来一种开发方法也是很有必要的。需要说明的是这个三个分类严格说来并不是完全并列,泾渭分明,希望这不会给大家带来困扰。领会精神~^_^

另限于个人的水平及观点的狭隘,有些看法难免有失偏颇甚至偏激,还望方家不吝赐教。

首先来说一下行业商用软件

这类软件主要面向特定行业或企业的某种应用,项目设计较为复杂。一般为某个开发公司独立承接,几乎没有竞争对手。目前主要以 CRM、CMS、OA 等为代表。这类软件的客户并不关心系统的运行速度有多快,而是关心这个系统能否协调一致完成所需要的功能。由于是面向特定的客户,所以该类软件使用面较为狭窄,若换了另外一家客户通常就不能很好的运行(这里的运行并非指代码的执行,而是指功能的实现),就必须推倒重来。为了减少在开发不同系统当中所作无谓的基础性的重复劳动,我们就必须把这些不同的系统应用中相同的部分给提取出来。这些相同的部分既含有代码技术上的相似性,也包含设计流程上相似性。这是一种将问题进行抽象的过程。我们现有的这些框架、模型就是前人在这些抽象过程的劳动成果。由于几乎每个 Java 项目通常都是较为大型的复杂的应用,所以我们在这些项目中处处可见框架,处处可见模式。你不采用这种开发方式,那就几乎无法前行。PHP 在开发这类应用时是跟 Java 很相似的,唯一不同的就是各自运行环境(主要是指各自的语言解释器,下同)不同。PHP 是一种脚本语言,其支持各种 OO 语言特性的代价很沉重。无论是在空间还是在时间上。所幸对于这类行业商用软件性能是次要的,并且可以自己决定运行环境,因此采用对 OO 特性支持良好的 PHP5 是必然的选择。而且采用一些框架也是必须的。

再来说说通用共享软件

这个概念从传统桌面型共享软件的概念而来,它的主要特点就是客户(包括潜在的客户)众多,同一类型的软件用户的选择也较多,竞争较为激烈。这类软件目前以论坛社区程序为代表。为了赢得客户,那你必须要做得比一般竞争对手更好。对这类软件来说,竞争主要在一下几个方面:

  1. 界面
  2. 界面是你的客户(包括客户的客户)对你产品的第一印象。因此界面必须要友好。界面不单指外观,还包括可操作性。界面必须要考虑到大多数人的习惯,操作必须要简单、顺手。外观虽然是萝卜白菜,但你也必须留一个选择权(接口)给客户,让客户能非常方便地修改使用。

  3. 性能
  4. 良好的界面当然会给你的产品加分。但在这可以 Ctrl+C 和 Ctrl+V 的世界,再优秀的界面都会被竞争对手瞬间所“学习”。如果说界面是第一印象,那么性能将是致命的考察。因为界面可以更换,但你不能指望客户自己去完善代码。在 PHP 开发中,性能很大程度上是指代码的运行速度,另外一个重要的表现就是对系统资源的损耗程度。每个处理进程的资源占有率越低,系统就越有时间来同时处理更多的请求。这些都是一个细微之处见真章的功夫。希望有机会再和大家详细探讨。但其中我个人有个大致的原则就是避免使用类。PHP 中的类真是性能杀手(注:在 PHP 5.2.x 以后情况有了极大的改善)。避免使用类的直接后果就是避免使用框架。有人说这样做会影响开发效率。我承认,是可能会造成一些这样的效果。但我认为,效率分两种:开发效率和运行效率。在行业商用软件中我们这样做是不合适的,但在通用共享软件里面,我们的竞争对手很多。况且客户才不会管你使用什么框架、采用什么模式,客户只关心他们自己的体验。雨和熊掌不可兼得,我们必须要舍弃一点开发效率来保证运行效率。这也是不得已而为之。

  5. 兼容性
  6. 这里的兼容性主要是指代码在不同 PHP 版本之间的兼容性。我们注意到,通常情况下,PHP 版本越新就意味着性能和稳定性就越强。因此我们应尽可能采用新版本所具有函数和语法。但另一方面,由于用户众多,我们无法对每一个用户的运行环境做出假设。并不是每个客户都拥有独立的服务器,很多使用通用共享软件客户都是采用虚拟主机作为运行平台。况且也不是每个虚拟主机提供商都能支持最新版本的 PHP 解释器。兼容性还有一个副作用就是限制了一些开发手段。毫无疑问,在 PHP4 平台想得心应手地使用各种 OO 特性与技巧是很困难的。这就有一个平衡问题。如何处理这种平衡,我想一些关于 PHP 版本运行情况分布的调查或许可以作为一个有力的参考。

最后是私有专用软件

私有专用软件是指具有一定研发实力的公司根据自身的业务特点而独立开发的应用系统。专供自己使用,很少作为产品出售。这类系统复杂性并不亚于企业商用型软件,但其对性能的要求更高,几近苛刻。这些系统以 sina 的新闻发布系统、淘宝的物品买卖管理这些为代表(虽然所举的这些并不全都是采用 PHP 开发)。这类系统的特点是通常企业自身也拥有独立的服务器,可以对服务器自身有针对性的配置和优化。若想对付这些应用,必须从企业业务自身特点出发,并根据实际的服务器情况专门进行对 PHP 模块进行优化编译。然后还采用 PHP 扩展甚至是 Zend 扩展来代替脚本中实现一些功能。这就要求 PHP 程序员同时要具备一定的 C 语言知识(虽然理论上其它的语言也可以,但无疑 C 是最安全和方便的)。

对于开发一个不考虑跨平台,只在 Windows Server 环境下运行的高性能服务器来说,IOCP 无疑是一个最优的解决方案。最近一个项目要用到 IOCP ,特地找了些资料。网上的资料很多,但很多都是以基础性的介绍为主,代码也是些经典书籍上的标准代码。这些代码对理解 IOCP 无疑是很重要的,但对于高性能服务器开发来说,细节的实现则似乎更加重要。根据自己最近做的一个项目,有几点体会,特记录下来,以备后查。

  1. 是在写服务端而不是在写客户端
  2. 服务端与客户端绝对是两码事。在客户端我们提倡 Create/New 和 Free/Dispose,随用随申请,不用即释放。但在服务端要尽量避免这样做。在客户端可以随时使用 string 类型,但在服务端也必须尽量避免使用 string 。string使用起来异常方便,但我们看看编译后的代码恐怕就会只冒冷汗:原来编译器为string的方便做了那么多额外的工作。客户端要为客户解决内存,但服务端能“浪费”则“浪费”。

  3. 内存管理
  4. 不得不再次佩服一下某大牛说的话:“玩服务器就是玩内存”。
    内存管理不当就会造成内存泄漏和内存碎片。对于客户端而言,内存碎片几乎不算是问题。内存泄漏那么一点点也可以接受。但对于 24 * 7 的服务器而言,这却绝对致命,其重要性甚至超过了 IOCP 本身。

    关于内存泄漏,只要记得保证申请和释放动作的对称性即可,外加一系列的测试工具,基本就可以把这个问题解决。
    其次就是内存碎片。内存碎片问题的重要性绝不亚于内存泄漏。造成碎片的原因也是防不胜防。简单的如每次的 New 和 Dispose ,Create 和 Free ,隐晦一点的如 string 类型的操作。

    解决办法:

    首先对于Create和Free,尽量少用。换句话说,尽量少用封装。适当的封装是可以的,只要封装的层次不是太深。Delphi 提供了 VCL 源码,我们可以看看即使是直接继承 TObject 那也会多做多少工作!对于频繁调用的函数,不要采用虚拟函数。这些晚绑定的函数,想调用就得查找 VMT,很费时间。对于类的普通函数,由于进行了早绑定,这个和其他非类的常规一样,不会降低效率。

    其次,相应的,尽量使用结构和函数来代替类。对于结构,New 和 Dispose 也要尽量少用。要集中的使用来避免内存碎片。我们应该一次性把所预料的内存都申请完,服务器就得有服务器的样,放着那么多内存干什么。早晚都得申请,为什么在服务端启动的时候不一次性申请完,在服务端关闭的时候一次性释放掉?既避免了内存碎片又避免了以后的再申请操作,一举两得,何乐而不为?要知道内存分配和释放是非常昂贵的操作。不论是从时间上还是从稳定性上而言。再具体些,怎么保存这些申请到的内存?怎么保证在必要的时候可以很方便的再申请或及时的释放一些内存?我采用的是链表。在每次为一个数据结构申请内存的时候,先查看这个链表是否为空,如果不为空,就从这个链表中取出一个内存块,不需要真正调用函数申请。如果为空,再动态分配。使用完成后,把这个数据结构不释放,而是再把它插入到链表中去,以便下一次使用。

    再次,不用 string 用什么?用数组!用字符数组!就像C中的字符数组一样。就是这么简单~

  5. 使用使用 AcceptEx 代替 accept
  6. AcceptEx 函数是微软的 Winsosk 扩展函数,这个函数和 accept 或 WSAAccept 是阻塞的,一直要到有客户端连接上来后 accept 才返回,而且,accept 本质上是在接受一个连接的同时再创建一个套接字。而创建一个套接字,对于 Windows 的网络模型而言,代价是非常大的。而 AcceptEx 则避免了这两个问题。首先它是异步的,直接就返回了。其次可以也是必须事先要和某一套接字绑定在一起。这样在接受一个连接时就不必再创建套接字了,而这个套接字我们可以事先使用 WSASocket 函数申请好,就像上面的预申请内存一样。总而言之一句话,“准备工作”一定要做好,到时需要拿来就是了。

    这里面还有一个问题,刚开始创建和投递多少 AcceptEx 调用?万一不够用怎么办?这个问题我们可以把 FD_ACCEPT 事件和一个 Event 对象关联起来,然后用 WaitForSingleObject() 等待这个 Event ,若预投递的套接字不够用的话就会触发 FD_ACCEPT 事件, Event 受信,WaitForSingleObject() 返回,我们就重新再发出一些 AcceptEx 调用。

  7. 要利用好 GetQueuedCompletionStatus() 函数中的 lpCompletionKey 参数
  8. 这个东西传递的是“单句柄数据”,换句话说,是和每个连接/套接字本身而不是在某个连接的 I/O 操作绑定的。在服务端设计当中,不可避免的,我们都会有一些只和该连接本身关联的一些数据(比如这个连接的套接字、客户端的IP,连接的会话密钥等等),如果采用传统的操作手法,将会不可避免采用一些查询机制在每次收发数据时来获取这些信息(比如数据的解密密钥),但现在我们只需要再创建完成端口时把包含这些信息结构体的指针传入就行了,下次直接使用 GetQueuedCompletionStatus() 取得结构体指针就行了,无需再次查询。方便和高效之极。

  9. 关于 Delphi 下 WinSock 函数库的封装
  10. 这是 Delphi 相对于 C/C++ 特有的问题。这些库 M$ 都是以 C 头文件(.h文件)形式给出的。因此若想在 Delphi 上调用就需要把其中的 C 表达形式转换为 Delphi 表达形式。问题就出在转换这里。抛开在转换期间可能会转换错误以外,由于没有一个强制性的转换标准,所以就会造成好几个转换版本,既便他们都是正确的。这就造成调用时所采用的代码不同。就拿最常用的 GetQueuedCompletionStatus() 函数来说,M$定义如下:

    BOOL GetQueuedCompletionStatus(
      HANDLE CompletionPort,
      LPDWORD lpNumberOfBytes,
      PULONG_PTR lpCompletionKey,
      LPOVERLAPPED* lpOverlapped,
      DWORD dwMilliseconds
    );

    其中第二、三、四个参数都是需要传入指针形式的。某个 Delphi 转换版本如下:
    function GetQueuedCompletionStatus(CompletionPort: THandle;
      var lpNumberOfBytesTransferred, lpCompletionKey: DWORD;
      var lpOverlapped: POverlapped; dwMilliseconds: DWORD): BOOL; stdcall;

    当然这个转换是没问题的,但关键是对于第二、三、四个参数它采用 var 而使得参数进行了引用传递。在调用时只需要填入参数,而不必再使用取运算符 @ 来填入参数地址。另外一个转换版本如下:
    function GetQueuedCompletionStatus(CompletionPort: THandle;
      lpNumberOfBytesTransferred, lpCompletionKey: PDWORD;
      lpOverlapped: PPOverlapped; dwMilliseconds: DWORD): BOOL; stdcall;

    这个版本没有采用 var 引用传递,而是采用的指针的值传递。调用时必须先使用运算符 @ 来取得参数地址。那么这种差异就可能导致我们更换一个 WinSock 声明文件就不能正常编译的问题。更可怕的是编译通过,但是误把指针当引用而引起的运行时错误,更是防不胜防。所以我觉得在一个项目当中有必要统一一下。

    那么两种声明哪个更好些?虽然第一种在调用时代码可能书写较为美观,但我还是推荐第二种,不采用 var 引用传递的那种。原因有两点:一是这样最接近原 .h 头文件的表达形式,二是调用时也会明显看到是需要传入一个类型还是需要该指向该类型的一个指针。

  11. 其他的一些小问题:
    1. 既然决定采用 IOCP 了,那就不要考虑跨平台了。尽量采用 M$ 提供的扩展版本的 Winsock 函数。这通常会给程序带来一些性能方面的优化。
    2. 同样的代码,在不同版本的 Windows Server 上表现也是不一样的。通常版本越高级,负载能力越强。
    3. 虽然 IOCP 是不分 TCP 和 UDP 的,但 IOCP 通常不用在 UDP 服务端上。原因也很简单,UDP 服务端总共就需要一个套接字,但 TCP 每个连接都需要一个套接字。“绑定”在 UDP 上 IOCP 根本没有 IOCP 的感觉。:)

'