公司的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最悲痛最悲劇最悲哀的嘆息。
(通宵腦子已經很不清醒了,寫得怎樣就怎樣吧,回頭再補救...)
