首页 > 代码库 > 客户端聊天室建设思路独家分享
客户端聊天室建设思路独家分享
推动的原理已经确定了,现在来看具体实现。相对于服务器端的实现,客户端聊天室的实现可能更棘手。因为客户端聊天室不像网页聊天室那样怎么开发都 行,在服务器端的开发自由度是相当大的,虽然有点夸张,但是只要你愿意,使用核心代码也未尝不可;而客户端却截然不同。在此,实现的空间又被限制在了一个 狭小的范围内,实现中最好只 使用HTML,这在基于刷新的聊天室中可以实现,但是在基于推的聊天室中几乎不可能。为了实现它,就必须放宽对客户端实现空间的限制,有必要使用活动脚 本,或者说使用IE的JScript,结合其DOM(Document Object Model),来实现一个嵌入IE的“客户端”。
在 IE中,一个页面可以是“未完成”(pending)的,当一个页面的URL提交后,随着服务器端数据的不断到达,该页面的状态(readyState属 性)一直为“interactive” ,即可以交互的。此时其DOM已经初始化,可以访问了。该页面使用的连接即可作为推信道,信息可以被不断的推到该 页面上。
但遗憾的是IE并没有提供通知未完成页面内数据到达的事件,可能与此有关的三个事 件:“onreadystatechange”、“ondataavailable”和“onpropertychange”在这里都不适用或不可用。因 而,不得不设置一个定时器,周期的来检测是否有新数据到达。与基于更新的客户端聊天室所使用的定时刷新机制所不同的是,这里的检测是完全发生在客户端的, 它检测的是本地的页面;而基于更新的客户端聊天室则是检测服务器。因此,这里的定时检测不会加重网络和服务器的负担。定时器的精度将影响到用户界面上显示 的延迟,这里 将间隔设置为0.5秒,该间隔造成的延迟是可以接受的,而且其不会对客户端处理器造成负担。
DOM是用来管理文档的好方法,为了便于客户 端检测新信息时利用DOM,将服务器推来的每条消息嵌到一对HTML标记中(这里使用“”标记,原因是其占的空间少)。依据微软文档,使用DOM,可以从 一指定的根处取得针对某一标记的集合,该集合中每个元素为一个该类型的标记,集合有指明集合内元素数目的属性——“length”。每个元素都可由其在该 集合中的索引定位,而且索引是按照该元素对应的标记其在HTML文档中的位置递增分配的,间隔为1。
客户端总是记录着上一次检索时标记的 数量,当一次定时器嘀哒到来时,只要检测当时标记的数量,与之前记录的数量相对照,就可以判断是否有新消息到达。又由于索引是递增分配的,新的信息的索引 便可以预测,依据其索引便可以将其标记从集合中取出,对每个(可能在两次定时间隔中到达多条信息)取出的标记,都可以通过其innerHTML属性取出消 息内容,然后送脚本分析。
既然推信道实质上是一个TCP连接,就不能保证其不会中断,必须设法应付推信道的意外中断,否则,一旦中断发 生,用户将接收不到任何信息。处理意外中断的方法很简单,一旦连接中断,页面的readyState属性将变为complete,只要检测该属性的值,再 根据客户端当前的实际状态,便可判断是否发生意外中断。一旦中断发生,客户端脚本应自动重新提交页面,以便再次打通推信道。此时也应复位客户端标记计数 器。
对每条送分析的消息,根据其内容,由脚本进行相应的操作,与用户进行交互,如显示一条发言内容,或者因为一个用户退出客户端聊天室而从成员 列表中删除该用户。因而整个用户界面的主体都是由脚本根据当前状态动态生成的。要使用脚本生成页面内容,仍然要使用DOM,使用内置document对象 的createElement方法,以及各个标记的appendChild方法、insertBefore方法和removeChild方法来操作DOM 树,操作结果将在用户界面上实时的表现出来。之所以不使用innerHTML属性直接插入标记,是因为其性能相当低下,有显著的延迟,而且不如DOM方 便。
脚本负责的另一个工作就是与服务器进行交互,用户的发言、进入和退出等动作都要籍由脚本来代理操作,因为用户不可能(或者不方便)与 服务器直接交互。与服务器交互的方法很多,最简单的是使用表单(form)。但是,提交表单会刷新其所在页面,而基于推的聊天室是不需要也不应该刷新的。 如果要使用表单,就必须内嵌一个或多个IFRAME,在其内部载入另外的包含所需表单的页面,使用脚本将用户的输入从主页面拷贝到这些表单中,然后通过调 用表单的submit方法提交它们。这时就又遇到了与实现推时类似的问题,主页面无法得知其提交的页面是否已经返回,这时又需要定时检测,这会增加实现的 复杂度,同时会带来更多的不稳定因素。另一种可行的方法是在每个返回的页面中都嵌入脚本,挂接该页面的onload事件,然后通过跨框架调用的方法通知主 页面该页载入完成。但这些方法都不够理想。最佳的方法还是使用IE5新支持的XMLHTTP组件。该组件的ProgID为 “Microsoft.XMLHTTP”,正如其名,它主要操作XML数据。重要的是它支持异步操作和回调函数,使用它便可以通过纯脚本的方式与服务器进 行交互。美中不足的是,在回调函数的上下文中没有对其XMLHTTP对象的引用,必须使用其它方法(例如全局变量)才能获得该对象。如果微软能让回调函数 带一个引用其所挂接XMLHTTP对象的参数,或者让this变量引用该对象就更好了。
有关使用脚本操作UI与用户交互,以及使用脚本与服务器交互的详细内容,不是本文的重点,这里不再赘述。
服务器端在服务器端,最重要的是实现一个符合上述标准的推机制。这个机制最显著的特征就是异步性,它经常,或者说绝大多数时间是不工作的,只在有新信息到达时才变为活动的,并为用户发送这些新信息。
Windows 是一个对异步操作支持得很好的操作系统,针对上述要求,很自然的让人想到使用多线程,每个线程负责一个用户连接,其大部分时间是挂起的,当新信息到达时, 它被以某种机制唤醒,然后为用户推数据。因而,看起来,只要写一个使用多线程的服务器端处理用户请求即可。但实际上,客户端并不只是接收服务器推来的信 息,它们还要与服务器进行交互,例如向服务器发送用户的发言内容。很明显,处理这些交互的最简单的途径是使用ASP,ASP脚本功能可以方便的分析和处理 用户请求,组织和生成回复内容,而且具有很好的弹性,维护非常方便。但是,ASP却不具备完成服务器推所需的能力,它在设计时就不是用来处理大量得异步操 作的。为了能鱼和熊掌兼得,有必要将二者结合起来,在所有让ASP与二进制代码能够互访的机制中,最好的就是微软的COM模型,ASP完全支持COM,只 要让二进制代码实现COM,便可以与ASP进行良好的交互。
既然选定COM,就要选定可执行文件模式和线程模型,为性能考虑,最好的方式 是将组件做成DLL,让其加载到IIS进程(实际上是IIS的子进程,DLLHOST.EXE)的地址空间中直接调用,这样就省去了跨进程边界所需的 marshaling,因而提高了性能。既然使用DLL,则实现推的部分也必然要做到该DLL中,否则,如果实现推的部分运行在其它进程中,则该DLL无 异于一个proxy,进程间通讯仍然无法避免,DLL形式的组件所带来的性能提升将被抵消。由于推部分已经结合到组件中,而推部分的实现必须是多线程的, 就是说整个程序使用多线程已经是无可避免的,更加上性能的考虑,组件无疑应该选用多线程模型,即Free或Both。现在,按照设计,实现推的代码将在 IIS的进程执行,要在IIS进程内运行的代码中实现推,方法有很多,但最优秀的方法莫过于使用IIS的ISAPI接口。使用该接口,应用程序直接与 IIS进行交互,而不是客户端,IIS负责线程和客户端连接的管理,同时负责信息的发送和接收以及一些分析工作。这样可以显著的减少代码量,降低开发复杂 度,更能与Web服务器紧密结合,提高整体性能。
ISAPI部分指ISAPI扩展(Extension),作为推的实现部分,可以使用普 通请求.dll的方式来请求它的服务。也可以将其配置为脚本引擎,将某个文件扩展名映射到该引擎上,让它控制一个子区域,用户只要请求一个该区域下具有该 扩展名的任意名称文件即可获得服务。
这样,该DLL中实际上是两个部件,一个作为与ASP交互的组件出现,另一个作为与IIS交互的 ISAPI扩展出现。从IIS的角度看,两部分是分别被加载和初始化的——各自都是在第一次被调用时,只不过加载的是同一个DLL。因为可能出现虽然在同 一DLL中,但一部分已经在工作,而另一部分还未初始化的情况,在设计时应考虑到这两部分的同步问题。
接下来设计服务器端结构,服务器端设计的重点是使服务器端的性能达到最高。其中最关键的是合理的使用多线程技术。另外,尽量使用操作系统已有的功 能,以简化程序实现。例如这里选用的操作系 统是Windows 2000,Windows 2000已经内置了哈希表的实现,所以,在使用大量字符串时就没必要再编写自己的哈希表。
客户端聊天室组件应该能够支持多个聊天室,而不是单个,以便对聊天内容分门别类。因而要求有一个“大厅”,用户刚登录客户端聊天室时,处于大厅中, 在此可以检视各个聊天室状态,决定进入哪个聊天室。为了管理每个开放的聊天室,需要一个类来描述那些开放的聊天室,这里称之为聊天室描述符。同样,为了管 理每个在线的用户,也 需要一种称之为用户描述符的类来描述它们。最后,为了管理这些客户端聊天室和用户描述符的多个实例,需要一个总目录来对它们进行索引。
由于客户端聊天室描述符和用户描述符都是多实例的,尤其是用户描述符,在运行过程中要频繁的分配和释放,极易产生内存碎片,因而有必要对它们使用特殊的管理方式。通过对这些类使用其自己的私有堆,便可以解决碎片问题。
当用户登录后,就立即发起一个到服务器的连接,该连接请求组件的ISAPI部分,ISAPI部分验证用户身份后找到用户的描述符,然后将该请求的 ECB(Extension Control Block)作为推信道挂接到描述符中。最后函数向IIS返回PENDING,表示处理未完成。
当最终用户发言时,发言内容被传输到服务器,IIS将请求交给ASP来处理,ASP验证用户身份、对请求做一些分析和处理后,通过COM组件的界 面,调用相 应的方法,通知组件某个用户发言。组件检查该用户所处的位置,对其所在的聊天室内的每个用户都发送该发言内容,通过调用IIS的ISAPI接口,发言内容 经由每个用户事先建立的连接作为回应发送出去——这就是整个“推”的过程。
对于发送信息的过程,如果按照前面的设想,每个用户占用一个线 程,固然可以使用一次WriteClient()调用来完成发送。但是,尽管一个线程相对于进程所消耗的资源是相当少的,但如果用户数目很多,服务器就要 维护相当数量的线程,其造成的资源消耗仍将是非常可观的。更重要的是,这些线程绝大多数将处于休眠状态,什么也不做,却空消耗服务器资源。因而,需要一种 更好的方式来使用线程,而不是简单的让每个线程陷入等待。
在这种情况下,最好的解决方法就是使用线程池,暂时不需要工作的线程并不是陷入 等待,而是被回收。被回收的线程进入一个线程池中,当有新任务时,便从线程池中启用一个线程来完成它,该线程完成任务后又将返回线程池,如此往复。同时, 线程池应该能够根据当时的负载自动增减线程数,理想情况是能够保证其缓存的线程不多不少,恰好够用。另外一种管理线程池的方法是创建等于CPU数目的线 程,目的是获得最大并发能力,而又不浪费线程。但是,该方法在这里不适用,这里主要是希望能够同时处理所有用户的请求(尽管会浪费一些资源),而不是最高 效的利用CPU而不顾由此造成的多个用户间的不公平性。换句话说,在多个用户同时请求服务时,尽管对每个用户的服务都将变得缓慢,但仍然希望该实现能平均 分配服务器的服务能力;而不是处理完一个再处理一个,如果这样,某些用户将等待过长的时间,而客户端将可能超时。
按照自动调整的规则自己编写线程池是比较复杂的,而实际上Windows 2000内置了对这种线程池的支持。这里完全可以利用Windows 2000的线程池,其好处是不言而喻的。实际上,IIS处理ISAPI程序时也在使用线程池。
使用线程池,就意味着对同一个会话,将由不同的多个线程来处理它,因而处理会话的程序必须是线程无关的,这还要求IIS的支持,因为IIS的相关数 据也是会 话处理的一部分。 幸运的是IIS的ISAPI对此提供了很好的支持。ISAPI具有异步操作能力,初始的处理程序通过返回PENDING,可以声明会话 未结束,以使会话进入异步状态,当会话处理完成时通过调用指定的接口来结束会话。正如前面所述,ISAPI的每个会话被一个ECB所描述,该结构由IIS 负责维护,在整个会话过程中其地址是不变的,通过一个指针,可以在任何时候访问它,因而保证了线程无关性。
现在,对于需要接收信息的用 户,如果他的推信道暂时不可用,那么必须有某种机制能够缓存发言内容,当该用户的推信道重新变得可用时,再将缓存的发言内容发送出去。否则该用户将丢失别 人对他的发言,网络连接中断是比较常见的,如果没有这种机制,问题将变得很严重。另外,由于IIS本身的限制,实际上ISAPI扩展程序一次只能有一个异 步操作。而新的信息可能在该异步操作的过程中到达,此时已经不可能再发起另一个异步操作,为了使此时的发言信息不至丢失,也需要缓存机制。
现 在,对于需要接收信息的用户,如果他的推信道暂时不可用,那么必须有某种机制能够缓存发言内容,当该用户的推信道重新变得可用时,再将缓存的发言内容发送 出去。否则该用户将丢失别人对他的发言,网络连接中断是比较常见的,如果没有这种机制,问题将变得很严重。另外,由于IIS本身的限制,实际上ISAPI 扩展程序一次只能有一个异步操作。而新的信息可能在该异步操作的过程中到达,此时已经不可能再发起另一个异步操作,为了使此时的发言信息不至丢失,也需要 缓存机制。
从表面来看,该缓存是“每用户”的,即为每个特定的用户缓存每个发言。但是,从发言内容本身来分析,如果一个发言的多个目的用 户的推信道都不可用,该发言就要在服务器端缓存多份,这显然是不合算的。因而,要求缓存机制既要保证缓存能够区分目的用户,又能避免重复缓存相同的内容。 最终的结果是导致了一种中央缓存机制,所有发言内容的实体都存放在中央缓存中,对每个缓存的内容,都分配一个ID,称之为消息ID。然后,对该发言涉及的 每个目的用户,都发送一个包含该ID的通知,告诉它有信息到达,每个用户只需要一个相对小得多的本地缓冲区来缓存这些通知便可以解决推信道意外中断的问 题,从而节省了服务器资源。当推信道重新可用时,依据本地缓存中的消息ID,便可从中央缓存取出对应的信息并发送给用户。
下面的问题是, 中央缓存应当如何管理。既然只有一个中央缓存,对其的访问必然会相当频繁。因而希望对其的访问需要尽量少的阻塞,这就要求使用尽量少的同步机制,以及尽量 使用轻量级的同步机制。实现中,将整个缓存划分为若干缓冲项,每个缓冲项缓存一条信息,缓冲项的大小是固定的(由于中央缓存主要是缓存用户发言,因而可以 通过限制发言的最大长度来确定缓冲项的大小)。中央缓存的所有缓冲项被循环使用,即,当用完最后一个缓冲项后,下一个即将被使用的缓冲项是第一项。在聊天 室的多线程环境中,为了使多个线程不至同时选择相同的缓冲项,可以使用互锁函数。互锁函数是轻量级的同步机制,相对其它同步机制,互锁函数带来的性能损失 是微小的。
采用这种机制,一个重要的问题就是避免消息的覆盖问题,就是指:新的消息写入到了还在使用的消息所在的缓冲项。在实际应用中,只要合理的设置缓冲项的数目,就可以避免或尽量减少覆盖。覆盖虽然带来风险,但是由此带来的性能提升却更可观。
同 时,中央缓存还必须能够保证其分配给每个进入缓存的消息的ID是不重复的,否则如果一个用户的推信道长时间不可用,其描述符就会长时间缓存某个消息ID, 此时中央缓存可能又将该ID分配给了新的消息,当该用户的推信道重新可用时,用户将收到错误的信息。可见,消息ID不能简单的使用缓冲项序号。
在 最终的中央缓存实现中,消息ID使用一个64位的整数表示,其中高32位为读取指针,表示一个缓冲项索引;低32位为序列号,表示在该缓冲项上已经缓存过 的消息的总数。这样,对不同缓冲项上的消息,肯定具有不同的ID,对同一个缓冲项上不同时间进入的消息,其ID要在该缓冲项的消息内容重复改写232次后 才会重复,那时对该消息的引用应该都已不复存在,因而在可预见的情况下,消息ID是不会重复的。缓冲项序列号也可以使用互锁函数取得,但由于缓冲项的分配 已经在一定程度上隔离了并发,在填写缓冲项的时间不是很长的情况下,在一个线程对缓冲项操作的同时,不应该存在另一个操作同样缓冲项的线程。因而,实际上 不需要使用互锁函数,只需简单的将序列号加一即可。
当从中央缓存中提取信息时,要先检查消息ID中记录的缓冲项序号是否与实际缓冲项中的 序号一致,如果一致才开始提取信息,否则通知信息丢失。还有,缓冲项可能会在提取信息的过程中被重新分配,此时另一个线程将同时向缓冲区中写入,必须设法 检测这种情况,否则也将会发送错误的信息。只要在存入信息之前先增量序号,在提取信息之后再检测一次序号,就可以保证提取的信息是准确的。
中央缓存的大小是一个关键的问题,太大的缓冲区会造成资源的浪费,太小又会造成严重的信息丢失。而且在聊天室的整个运行过程中,不同时期需要的缓存 大小也是 不同的。因而需要能够动态的调整缓冲区的大小,以使其适应不同的使用强度。很不幸,这次Windows 2000没有提供现成的功能,为此,设计了一个自 动调整机制,根据每单位时间内丢失的信息的百分率来确定缓冲区的使用强度。如果缓冲区过小,单位时间内必然有很多覆盖出现,因而造成较多的信息丢失,如果 丢失率高于某个阀值,则应该增加缓冲区;反之,如果缓冲区过大,覆盖几乎不会出现,如果丢失率长时间低于某个阀值,则应该减少缓冲区。可以想象,只要合理 的规定两个阀值,就能使缓冲区的利用率达到最优。
对于总目录、聊天室描述符和用户描述符,在同步需求方面,有一个共同的特征是,都涉及 “单个写、多个读”的同步要求。这是一种很典型的同步需求,在Jaffrey Richter著的《Windows核心编程》中有详细的介绍,本实现使用 了其中的例程。但是,在原例程的源代码中,不支持已经获得写锁的线程再获得读锁,这样做会造成死锁。我修改了代码,以支持这种情况,因为在实际编写代码时 会用到。
使用这种锁定机制,很容易就可以实现用户描述符内的消息ID缓存。消息ID缓存需要的是类似于中央缓存的机制——循环使用、自动 覆盖、能保证信息完整性。但与中央缓存不同的是,消息ID缓存处于用户描述符内部,其本身受到描述符同步机制的保护,完全可以使用已有的同步机制来简化其 实现。对实现的简化主要体现在保证信息的完整性上,其主要思想就是将单写多读同步机制反过来用:获得读锁的线程实际上进行写操作,而获得写锁的线程进行读 操作。由于可以同时有多个线程获得读锁,此时使用类似中央缓存的机制就可以保证ID缓存被循环利用,而旧的缓存项被自动覆盖。当某个线程要读取ID时,只 要获得写锁,就可以保证没有其它线程在向缓冲区中写入信息,因而也就不需要类似中央缓存的缓冲项序号。由上面的分析可知,IIS只允许每个会话每次一个异 步I/O,而只有在会话发生I/O时才需要读取消息,因此,实际上同一时刻只有一个线程需要从ID缓冲区中读取ID,可见使用写锁来读取数据不会降低并发 性能。
接下来的问题是,为用户发送信息的I/O是异步的,那么如何来驱动它开始发送?它在发送完成后又将做什么?当推信道刚被挂接时,就 检查是否有要发送的信息,如果有,则进行异步发送,最后返回PENDING。当异步发送完成时,回调函数被调用,它检测其刚处理的用户描述符中是否还有要 发送的消息,如果有,则立即启动下一个异步发送,如此往复,直到没有消息可发。此时,函数当然不能进入等待,它应该立即返回,以使其所用线程返回线程池。 而当新消息到这之后到达时,异步发送已经停止,消息无法发送出去,此时就需要写入消息ID的线程来启动异步发送。从另一方面来想,如果异步发送还在进行, 则写入ID的线程不应再启动它,否则将有两个异步操作同时发生在同一个会话上,由于之前所说的IIS限制,这样的操作将导致错误。因而必须使写入ID的线 程能够检测异步发送是否已经挂起,可以通过用互锁函数操作一个标志变量来解决这个问题。这时,另一个可预见的并发性问题是,如果两个线程同时向ID缓冲区 中写入时,都发现异步发送已经挂起,因而同时去启动异步发送,同样会造成两个异步操作同时存在,因而还需要一个锁变量来使这些写入消息线程互斥,以竞选出 一个线程来启动异步发送。
最后,在具体实现的过程中,一个很重要的问题就是锁定。必须为每个操作的每个步骤安排合适的锁定强度,更要合理 的安排这些步骤之间的锁定顺序。由于在同一时刻,总会有不同种类的多个操作在进行,如果锁定顺序和强度使用不当,轻则造成性能下降,重则造成死锁。造成死 锁的原因经常就是设计时的逻辑错误,而且死锁一旦发生,其原因将是很难被检测到的。因而,在编写代码之前,必须对其进行仔细的设计。
至此,服务器端的设计已经大体完成,至于详细到有关代码编写的问题,如前言所说,不是本文重点。相关信息可查阅附录中的UML图。
后记在NS浏览器中,支持一种特殊类型的数据流,它的类型描述字段是:
“Content- Type: multipart/x-mixed-replace;boundary=BOUNDARY”,这一非标准数据类型支持多个部分的数据,部分 之间以boundary划分,每个部分为同一内容在不同时间的一个快照。使用该数据类型,也许能够用纯HTML实现。但只有NS才支持它,IE却不支持。
在 我完成基于此理论的聊天室的第一版之后不久,发现首都在线(www.263.net)也使用了类似的Push技术(到本文成文时它还在使用),只是在具体 实现上有所不同。其服务器不是NT,而是Unix系列的。它没有使用类似文中所属的嵌入Web服务的方式,似乎是写了一个另外的服务,监听一个80以外的 端口,然后让用户去连接指定的端口。该端口也执行HTTP协议,如果整个服务都是自主开发的,则HTTP协议的服务器端实现也应该是自己编写的,由此看 来,它们的开发量应该不会小。但我不认为它的效率会比本文中的实现高。
同样,在客户端实现上也存在差异,未完成的页面被放在了UI部分, 就是说,直接接收推信息的页面对用户是可见的,因而,它推到该页面的信息包含了许多HTML标记,实际上,它甚至还包含了脚本。我不知道这样做有什么好 处,但其能够肯定的是,其要传输的信息量较大,而且由于包含了脚本,如果传输中出错,脚本也将执行错误,这通常比HTML标记的错误更严重。
虽然客户端聊天室已经实现,但是很可惜,由于很多方面的原因,到本文成文时我还不能将其部署,但愿我能尽快的部署它。
客户端聊天室建设思路独家分享