三周,用长轮询实现Chat并迁移到Azure测试

公司的OA从零开始进行开发,继简单的单点登陆、角色与权限、消息中间件之后,轮到在线即时通信的模块需要我独立去完成。这三周除了逛网店见爱*看动漫接兼职,基本上都花在这上面了。简单地说就是用MVC4基于长轮询实现(伪)即时通信,利用BootMetro搭建即时聊天系统,同时跨域组件化之后今晚移植到了Azure上方便周末进行第一次迭代的公网测试,地址在http://indreamchat.cloudapp.net/。有兴趣的朋友可以上去送测试数据,剥离了认证登陆,简单地伪装了一个...一个...怎么说,反正能用就好了...

一大早要坐客车回家,所以现在睡也不是不睡也不是,就分享一下实现方式。由于已经回到租房了,所以代码不在手上,写得如何,有待指正。

最后,它长这个样子:

 

首先介绍下用户情况。

系统的用户除了1/4的内网用户外,基本上都是全国各地办事处的外网用户,而且出差尤多,特别是海外,这是网络状况。

也就是我们的用户遍布世界各地,有不同的网络状况,而且世界上大部分可以想得到的设备都可能会是接入端,所以一开始就有较高兼容需求。

 

回到即时通信,其实只是其难点而已,总体来说是一个Chat和消息推送模块,允许各个子系统按照需求用群组会话组织管理用户,并推送系统消息,同时允许用户间的通信交流,并且满足移植性,使得不同子系统能直接引用。

下文先交代架构,然后最后交流下关键技术吧。

那么直接用文字说下架构吧,从上到下是从底层(数据库)到顶层(UI):

  • SQL Server 2012
  • Data Access / Entity Framework 5——数据链路以及数据缓存,利用EF实现
  • ChatManager + ListenQueue——会话的管理对象,监听列表提供监听服务和持有监听对象,在新消息和相关监听存在的时候通过callback推送消息
  • CometManager——长轮询的管理对象,负责向MVC提供轮询服务,同时向上层监听消息,可以视为一个服务的Adapter
  • Service / MVC 4——提供UI和JSONP API的(工程意义上的)UI/Service层
  • ChatDataManager.js——与Service进行数据通信并维护本地数据对象的管理对象
  • chatUiEngine.js——利用ChatDataManager.js向UI提供动态UI服务的引擎
  • UI——提供了配置和界面容器后,只执行了一个chatUiEngine.Start()方法启动的页面

以下逐层详细说明:

SQL Server 2012

Data Access / Entity Framework 5

即时通信有一个很特别的地方,就是对集中的数据进行频繁读写。你可以站在数据的角度来看,基本上所有用户都在访问并且添加最近最新的那群数据,所以作为数据链路层的数据对象,只需要将尽量新的数据缓存起来即可。另外可以保证的是先写后读(发了消息其他人才可以看见),写完马上要(发完了消息其他人马上要读取),基本上就是个对写方法加了信号量(同步锁)的数据栈。

那么,是用带有缓存的ORM就十分合适,比如Entity Framework。

从业务方面考虑,这是个频繁修改需求的项目,有Model First的Entity Framework是个不错的选择。

至于为什么用那么新,其实只是用Nuget更新的,不过还是很喜欢它的Convert To Enum功能。只是用EF的话要十分小心数据库的结构和Model并不完全同步,比如1 to 1/0在生成数据库再另外生成Data Model的时候会变成1 to *,因为只有一个外键约束。

ChatManager + ListenQueue

这是会话管理与消息管理的核心,会话管理其实也就是增删查改的问题,主要功能实现在于消息部分,也就是ListenQueue。

ListenQueue是一个监听队列,可以添加监听,由ChatManager作为其Fascade,对外提供监听和停止监听服务。

业务实现的方法就是,向ChatManager提出监听某用户/会话的最新信息,在ChatManager有最新信息的时候通过Callback将有最新消息的消息返回给监听者(怎么说着那么别扭呢),由监听者决定是获取新消息还是执行什么业务。

所以,这一层实现了消息的发送获取管理、会话的管理、监听列表的管理,而它们各自有业务相关。

在这里,得感慨一下delegate闭包的强大和便利。

CometManager

一个监听实现者和一个长轮询服务者,通过长轮询实现监听到最新消息后即时推送回客户端。长轮询是怎么回事呢?应该可以搜到不少资源,放在后面讲吧。

在这里不是用ChatManager直接提供轮询服务是因为需要扩充性,将来必然有其他形式的客户端和其连接方式需要获取最新消息,比如Web Socket、WCF、Hessian。到时候这些方式的接收者只要实现符合delegate约束的监听方法,即可将消息以自己的通信方式发送回自己所服务的客户端了。

Service / MVC 4

这就是为浏览器提供最终页面和数据的项目层面上的UI层。选用MVC 4是因为其可以同时提供轻量级的跨域Web API的JSONP服务,如何实现JSONP后面简述吧。

这一层主要的任务就是界面和数据服务,并没有什么特别的。当然,依赖注入由这里启动,我用的是StructureMap2.6.4。

这里使用JSONP服务的一个目的就是为了能让其他系统跨域调用Chat。

ChatDataManager.js

这个在站点可以直接看到,没有做编码,所以可以从页面源代码处看到源代码。

这是利用jQuery.Ajax与上一层进行数据交流以及本地数据管理的管理对象。它主要的功能就是获取数据、将数据格式化并持久化、同步更新数据,在数据更新时用回调通知监听对象,让其对数据更新作出反应。

用大写字母开头而不用JS常用的命名法就是不希望一般用户直接使用。

chatUiEngine.js

利用ChatDataManager所持久化的数据和数据更变让界面持续工作的“引擎”,从一个Start(settings)方法开始启动。

它启动后的第一步就是启动ChatDataManager.js,然后用获取到的数据构建Chat的整个页面界面,然后一直维持界面运转。比如在有新内容的时候刷新或者更改界面,用户操作时控制界面作出反应,用户发送消息时将消息通过ChatDataManager.js推送回服务器,等等。

将所有UI操作的方法封装成API的目的就是让其他系统可以通过调用两个JS而在自己系统打开Chat,并且使用;而将数据与UI的持久化控制分成两层,是为了让客户端在有需要的时候获取部分数据,而不需交互。

UI

UI所作的就是提供容器(显示Chat以及相关内容的地方)和配置(告诉chatUiEngine.js有什么具体UI需求)。

这里尝试展示下打开chatUiEngine.js的方法(不大懂插入代码...):

 1         $(document).ready(function () {
 2             chatUiEngine.start({
 3                 user: {
 4                     id: UserId
 5                 },
 6                 application: {
 7                     onStart: null,
 8                     onStarted: function () {
 9                     },
10                     onRefleshed: function () {
11                         $('.maintable .icon-equals, .maintable .icon-plus')
12                             .find('span')
13                             .remove();
14 
15                         $('.ctrlbutton').click(function () {
16                             (chatUiEngine.chat.input.ctrl.isHold ? chatUiEngine.chat.input.ctrl.release : chatUiEngine.chat.input.ctrl.hold)();
17                         });
18                     }
19                 },
20                 listen: {
21                     user: true
22                 },
23                 chatGroup: {
24                     container: '.chatgroups',
25                     chatList: {
26                         container: '.chatgroupchatlists'
27                     }
28                 },
29                 chat: {
30                     container: '.chats',
31                     messages: {
32                         message: {
33                             container: '.messages .messagescontainer',
34                             myPostClass: 'mypost'
35                         }
36                     },
37                     input: {
38                         inputArea: {
39                             container: '.poster .inputarea'
40                         },
41                         postButton: {
42                             container: '.poster .postbutton'
43                         },
44                         ctrl: {
45                             //按住Ctrl时的显示效果
46                             onDown: function () {
47                                 $('.ctrlbutton')
48                                     .removeClass('btn-info')
49                                     .addClass('btn-danger');
50                                 $('.enterbutton')
51                                     .addClass('btn-info');
52                             },
53                             //松开Ctrl时的现实效果
54                             onUp: function () {
55                                 $('.ctrlbutton')
56                                     .addClass('btn-info')
57                                     .removeClass('btn-danger');
58                                 $('.enterbutton')
59                                     .removeClass('btn-info');
60                             }
61                         }
62                     }
63                 },
64                 newMessage: {
65                     onAlertAdded: function (alertHtml) {
66                         
67                     },
68                     onAdd: function (message) {
69                         $('.maintable .chatgroupchatlists .chat' + message.ParentChatId + ' .chat-unreadmessages-count').show();
70                     },
71                     onClear: function (chatId) {
72                         $('.maintable .chatgroupchatlists .chat' + chatId + ' .chat-unreadmessages-count').hide(0);
73                     }
74                 },
75                 alert: {
76                     container: '.alerts-container'
77                 }
78             });
79         });
启动chatUiEngine.js

这些是在Html中提供给chatUiEngine.js的容器,chatUiEngine.js利用它们生成合适的界面元素,将数据渲染上去后展示到容器中,而容器在上面的配置中进行描述。

 1 <body>
 2     <!--会话的容器模型-->
 3     <div class="chat-model">
 4         <ul>
 5             <li class="chatgroup">
 6                 <a href="#" class="chatgroup-name"></a>
 7             </li>
 8         </ul>
 9         <ul class="chat-model-chatlist nav nav-pills nav-stacked">
10         </ul>
11         <ul>
12             <li class="chat-model-chatlistitem">
13                 <a href="#">
14                     <span class="chat-name"></span>
15                     <span class="chat-unreadmessages-count label label-important"></span>
16                 </a>
17             </li>
18         </ul>
19         <div class="chat-model-chat chat">
20             <div class="title">
21                 <h1 class="chat-model-chat-name text-info"></h1>
22             </div>
23             <div class="messages">
24                 <div class="messagescontainer">
25                 </div>
26             </div>
27             <div class="poster">
28                 <div class="inputarea">
29                 </div>
30                 <span class="postbutton">
31                 </span>
32                 <span class="icon-equals"></span>
33                 <div class="ctrlbutton btn btn-large btn-info">Ctrl</div>
34                 <span class="icon-plus"></span>
35                 <div class="enterbutton btn btn-large">Enter</div>
36             </div>
37         </div>
38         <div class="chat-model-chat-nomorehistory nomorehistory">
39             没有更多历史记录
40         </div>
41         <div class="chat-model-chat-loadmorehistory loadmorehistory">
42             加载更多历史记录
43         </div>
44         <div class="chat-model-chat-message message">
45             <div class="messagecontainer">
46                 <div class="auther">
47                     <div class="btn-link message-member-name"></div>
48                 </div>
49                 <div class="messagecontent">
50                     <div class="message-content"></div>
51                     <div class="posttime message-posttime"></div>
52                 </div>
53             </div>
54         </div>
55         <textarea class="chat-model-chat-inputarea"></textarea>
56         <div class="chat-model-chat-postbutton btn btn-large btn-info">Post</div>
57         <div class="chat-model-alert-newmessage alert alert-info">
58             <button type="button" class="alert-close close" data-dismiss="alert"></button>
59             <div class="chat-unreadmessagescount pull-left badge badge-important">
60                 
61             </div>
62             <div class="toast-body">
63                 <h3 class="chat-name"></h3>
64                 <div class="auther">
65                     <span class="author-name"></span>(<span class="posttime"></span>):
66                 </div>
67                 <div class="content"></div>
68             </div>
69         </div>
70         <div class="chat-model-alert-posterror alert alert-error">
71             <button type="button" class="alert-close close" data-dismiss="alert"></button>
72             <div class="toast-body">
73                 <h3>发送失败!</h3>
74                 <div class="auther">
75                     <span class="chat-name"></span>(<span class="posttime"></span>):
76                 </div>
77                 <div class="content"></div>
78             </div>
79         </div>
80         <div class="chat-model-alert-postsuccess alert alert-success">
81             <button type="button" class="alert-close close" data-dismiss="alert"></button>
82             <div class="toast-body">
83                 <h3>发送成功!</h3>
84                 <div class="auther">
85                     <span class="chat-name"></span>(<span class="posttime"></span>):
86                 </div>
87                 <div class="content"></div>
88             </div>
89         </div>
90         <div class="chat-model-alert-newmember alert alert-success">
91             <button type="button" class="alert-close close" data-dismiss="alert"></button>
92             <div class="toast-body">
93                 <h3>新成员!</h3>
94                 <div class="member-name"></div>
95                 加入了[<span class="chat-name"></span>]
96             </div>
97         </div>
98     </div>
Html模板

剩下的就是UI中大致的容器了,用一个简单的table搭建出来,然后chatUiEngine就会将界面元素动态导入。

 1     <table class="maintable" style="width: 100%;">
 2         <tr class="tr1">
 3             <td class="td1">
 4                 <ul class="nav nav-tabs nav-stacked chatgroups">
 5                     
 6                 </ul>
 7             </td>
 8             <td class="td2">
 9                 <div class="chatgroupchatlists">
10                     
11                 </div>
12             </td>
13             <td class="td3">
14                 <div class="chats">
15 
16                 </div>         
17             </td>
18             <td class="td4">
19                 <div class="alerts-container">
20                 </div>
21             </td>
22         </tr>
23     </table>    
贴了也没什么意义的代码

UI部分已经尽量简化了,目的就是希望对原有的系统可以实现无痛人流植入,尽量少造成更改,同时可以让它们实现自己特殊的界面需求。

当然,至此只是我打了半个星期酱油,敲了两个星期多一点代码的第一次迭代的发布,所以必定很不完善。另外代码只在上周末重构过一次,这周测试和需求频繁发生也造成了新的代码乱搞基冗余,近期需要再次重构。

 

 

 

 

下面就分享下一些技术理解吧:

关于长轮询Long-Polling

详细的许多内容应该挺容易搜索到得,我也是从找到@dudu的谋篇博文开始知道MVC是具体怎么实现的,就用我的方式和实现方法笼统地分享下吧。

首先是原理。

原理很简单,HTTP是个异步转同步的协议,客户端发送了一个请求后,保持了与服务端的一条TCP连接,然后服务端通过这条连接将网页以及相关内容发送回客户端。而原来的Web只允许这一种通信方式,也是出于安全方面考虑(现在有Web Socket了)。

那服务端有消息要马上推送给客户端要怎么办呢?所以有了长轮询。

服务端将那条连接Hold住,直到有消息了再将数据通过那条连接返回给用户,然后用户再继续请求新的连接,然后服务端继续Hold住......

体现在我的开发过程里面就是,一开始我想用自旋锁锁住那条连接的线程的(没那么神秘就是一直While(true) {sleep();}而已),后来发现了MVC可以通过实现AsyncController,然后用AsyncManager来实现异步返回,从而节约了CPU资源。

然后效果就来了:

用户请求连接,然后等啊等,等啊等,等到我有新消息了,然后就断线了(返回了结果),然后发现,唉妈呀(粤语Diu,英语Oh, f**k),断了,有新消息了。然后主动去请求了新消息,这时EF就把刚刚存进去新鲜滚热辣的最新消息返回给该用户。用户拿到后在继续请求连接,然后等啊等,等啊等,等啊等,等啊等,等啊等,按后就断线了,唉妈呀,......

然后,实时通信就这么实现了,虽然我觉得是很聪明,但是却很恶心的技术...

JSONP

一般的Web不许跨域请求消息,但是有一个例外,就是引用文件,比如图片、JS文件、CSS,所以就可以把所需要跨域请求的东西通过文件,动态地引用进本页面。

而JSONP就是用这种方式实现用JSON通信的。

实现起来并不神奇,就是客户度先新建一个function,比如叫做callback1234(),并且把方法名同时和请求一起发送回服务端,然后服务端把数据准备好后,包装成callback1234([数据内容]);,并打包进一个.js文件,发送回客户端。客户端收到那个文件后将其添加进引用,然后因为callback1234是本地已有的一个function,所以就执行了callback1234(data),以此将数据推进了你已经定义好了的代码的深渊......

移植到Windows Azure

就是Microsoft的公有云,一开始并没有这个打算。不过为了方便回家能测试,同时上个月正好去了Azure广州的Live to Code(吃喝玩乐,还发了篇博客,就懒得翻出来了),拿了一个还有两天到期的试用账号,所以今晚...呃...好吧,刚才就挂上去了。

挂上去还算比较简单,首先在Azure建立自己的数据库,然后用SQL Server Management Studio连接上,并执行了EF Model First生成的SQL代码就把数据库在上面生成了。

在这里,我做错的就是用DB First生成了Azure用的那个Container,导致1 to 1/0的约束变成了1 to *,烦了我半天。原来直接吧connetionString改了就可以了,不用新建的...

其他代码都是从原有项目复制黏贴上去的,唯一修改的地方就是Web层的Global文件已经失效了,因为不是用IIS启动的,不会被执行。所以添加了一个WebRole(其实是自动添加的),用上面的OnStart()等方法代替了Application_Start()等方法,仅此而已。

在Visual Studio 2012里,右键创建的那个Windows Azure项目,点击publish,然后第一次下一步下一步下一步地设置好,比如用多少个CPU、多少个实例等等,然后就会推送到Azure了。推送完成后马上可以通过自己设置的二级域名打开网站。

我第一次用,不大熟悉,用了cloudapp.com的一级域名,另外建了个windowsazure.com的站点。不过,暂时来说,能挂上去能跑就是好事了,我也太累赶着下班了(其实还不是通宵没睡)。

最后再说一下http://indreamchat.cloudapp.net/进入这个站点哦,账号快过期,时间有限!时间有限哦!!!

最最后,关于360浏览器和IE6

最最后,作为一个要涉足前端,并且涉足兼容的开发人员,允许我再一次表达对360垃圾浏览器最深刻最深沉最深入的鄙视,以及对IE6最悲痛最悲剧最悲哀的叹息。

(通宵脑子已经很不清醒了,写得怎样就怎样吧,回头再补救...)

posted @ 2013-07-13 06:53  Indream Luo  阅读(4064)  评论(40编辑  收藏  举报