三周,用长轮询实现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 });
这些是在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>
剩下的就是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最悲痛最悲剧最悲哀的叹息。
(通宵脑子已经很不清醒了,写得怎样就怎样吧,回头再补救...)