三周,用長輪詢實現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最悲痛最悲劇最悲哀的嘆息。

(通宵腦子已經很不清醒了,寫得怎樣就怎樣吧,回頭再補救...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM