用.NET MVC實現長輪詢,與jQuery.AJAX即時雙向通訊

兩週前用長輪詢作了一個Chat,並移植到了Azure,還寫了篇博客http://www.cnblogs.com/indream/p/3187540.html,讓你們幫忙測試。html

首先感謝300位註冊用戶,讓我有充足的數據進行重構和優化。因此這兩週都在進行大重構。ajax

其中最大的一個問題就是數據流量過大,原先已有更新,還會有Web傳統「刷新」的形式把數據從新拿一次,而後再替換掉本地數據。json

但這一拿問題就來了,在10個Chat*300個用戶的狀況下,這一拿產生了一次8M多的流量,這是十分嚴重的事情,特別是其中絕大部分數據都是浪費掉了的。跨域

那麼解決方案就很簡單了,把「全量」改爲「增量」,只傳輸修改的部分,同時大量增長往返次數,把每次往返量壓縮。緩存

 

固然,這篇文章主要講長輪詢,也是以後被問得比較多的方面,因此就單獨寫篇文章出來了。服務器

此次比單純的輪詢多了一個緩存行爲,以解決每次「心跳」中所產生的斷線間隔數據丟失的問題。網絡

 

首先列舉一下所使用到的技術點:併發

  • jQuery.Ajax
  • .NET同步(lock)與異步(async await Task)
  • MVC異步頁面

 

長輪詢的簡介

長輪詢是一種相似於JSONP同樣畸形的Web通訊技術,用以實現Web與服務端之間的實時雙向通訊。dom

在有人實現JSONP以前,單純的JS或者說Web是沒法實現原生地有效地實現跨域通訊的;而在有了JSONP以後,這項工做就變得簡單了,雖然實現方法很「畸形(或者說有創意吧)」。異步

一樣,在有長輪詢以前,還沒出現HTML5 Web Socket的時代,單純的Web沒法與服務器進行實時通訊,HTTP限制了通訊行爲只能是有客戶端發起請求,而後服務端針對該請求進行迴應。

長輪詢所作的就是把原有的協議「漏洞」利用起來,使得客戶端和服務端之間在HTML 4.1(部分更低版本應該也能夠兼容)下能夠實時通訊。

 

長輪詢的原理

HTTP協議自己有兩個「漏洞」,也是如今網絡通訊中沒法避免的。

一個是請求(Request)和答覆(Response)之間沒法確認其鏈接情況,可就沒法肯定其所用的時限了。

判斷客戶端與服務端是否相連的一個標準就是客戶端的請求是否能收到服務端的答覆,若是收穫得,就說明鏈接上了,即時收到的是服務端錯誤的通知(好比404 not found)。

第二漏洞就是在獲取到答覆(Response)前,都沒法知道所須要的數據內容是怎麼樣的(若是有還跟人家要啥)。

長輪詢就是利用了這兩個「漏洞」:服務端收到請求(Request)後,將該請求Hold住不立刻答覆,而是一直等,等到服務端有信息須要發送給客戶端的時候,經過將剛纔Hold住的那條請求(Request)的答覆(Response)發回給客戶端,讓客戶端做出反應。而返回的內容,呵呵呵呵呵,那就隨便服務端了。

而後,客戶端收到答覆(Response)後,立刻再從新發送一次請求(Request)給服務端,讓服務端再Hold住這條鏈接。周而復始,就實現了從服務端向客戶端發送消息的實時通訊,客戶端向服務端發送消息則依舊利用傳統的Post和Get進行。

受Web通訊現實狀況限制,若是服務端長時間沒有消息須要推送到客戶端的時候,也不能一直Hold住那條連接,由於頗有可能被斷定爲網關超時等超時狀況。因此即便沒有消息,每間隔一段時間,服務端也要返回一個答覆(Response),讓客戶端從新請求一個連接。

見過一些人喜歡把每次輪詢的斷開到下次輪詢開始客戶端的接收->再請求的行爲稱之爲一次「心跳(Beat)」,也挺貼切的。

要實現真正的實時通訊,長輪詢的實現並不那麼簡單,由於每次「心跳」時會產生一個小間隙,這個間隙的時候服務端已經將上一個答覆(Response)返回,但尚未接收到客戶端的下一次請求(Request)。那麼這時候,服務端若是有最新消息,就沒法推送給客戶端了,因此須要將這些消息緩存起來,等到下一次機會到來的時候再XXOO。

 

jQuery.AJAX

若是是AJAX的話,通常都是用jQuery進行實現。何況,畢竟還用了JSONP,手動寫起來在工做中實在不划算。

到了Web端的代碼,就變得很容易了,如下內容直接從項目中節選,只是做了一些山間

 1     getJsonp: function (url, data, callback, errorCallback) {
 2         $.ajax({
 3             url: url,
 4             data: data,
 5             type: "POST",
 6             dataType: "jsonp",
 7             jsonpCallback: "callback" + Math.random().toString().replace('.', ''),
 8             success: callback,
 9             error: errorCallback
10         });
11     },
12     //輪詢的鎖,保證每一個輪詢有且僅有一個
13     pollingLocks: {
14     },
15     //輪詢的重試時間
16     pollingRetries: {
17     },
18     //輪詢錯誤的callBack緩存
19     pollingCallbacks: [],
20     //輪詢
21     //listeningCode: 監聽編碼,與服務器的一個契約,單個監聽編碼在服務器中有對應的一個緩衝池,以保留該監聽相關信息
22     //url: 目標地址
23     //data: 請求時的參數
24     //lockName: 鎖名,一樣的鎖名在同一時間只會出現一個輪詢
25     //callbakc: 接收到服務端數據後的回調
26     polling: function (listeningCode, url, data, lockName, callback) {
27         var comet = chatConnectionProvider.connections.comet;
28 
29         //判斷是否有鎖,排他,不容許重複監聽,保持單一連接
30         if (!comet.pollingLocks[lockName]) {
31             //鎖住監聽
32             comet.pollingLocks[lockName] = true;
33             comet.getJsonp(url, data, function (cometCallbackData) {
34                 var listeningCode = cometCallbackData.ListeningCode;
35                 //將消息發回
36                 for (var i in cometCallbackData.Callbacks) {
37                     callback(cometCallbackData.Callbacks[i]);
38                 }
39                 //將監聽編碼添加到請求數據中,以和服務器的監聽編碼保持一致
40                 data = data || {};
41                 data.listeningCode = cometCallbackData.ListeningCode;
42                 //解鎖後繼續監聽
43                 comet.pollingLocks[lockName] = false;
44                 comet.polling(listeningCode, url, data, lockName, callback);
45             }, function (jqXHR, textStatus, errorThrown) {
46                 //若是發生錯誤,則重試,而且逐步加大重試時間,以減低服務器壓力,以100毫秒開始,每次加倍
47                 comet.pollingRetries[lockName] = comet.pollingRetries[lockName] * 2 || 100;
48                 //將回調函數暫存
49                 chatConnectionProvider.connections.comet.pollingCallbacks[lockName] = callback;
50                 var rePollingMethors = 'chatConnectionProvider.connections.comet.pollingLocks["' + lockName + '"] = false;'//先解鎖,在解鎖以前排他,不容許重複輪詢
51                     + 'chatConnectionProvider.connections.comet.polling("' + listeningCode + '", "' + url + '", "' + data + '", "' + lockName + '", chatConnectionProvider.connections.comet.pollingCallbacks["' + lockName + '"]);';
52                 setTimeout(rePollingMethors, comet.pollingRetries[lockName]);
53             });
54         }
55     },
JS部分代碼


 

.NET MVC中的異步

一開始我花了比較長時間尋找服務端Hold住請求的方法。

普通狀況下,一個Web的請求是同步執行的,若是須要轉成異步的話,須要對線程進行操做。好比一開始我最白癡的想法是用自旋鎖,或者用Thread相關的方法,而後在須要的時候採用一些Interup方法進行中斷等等,都不容易寫。

後來發現MVC中提供了比較合理的一種原生的異步頁面方式,能夠簡單地實現同步轉異步。

首先是Controller要由默認的Controller改成繼承自AsyncController。該基類有一個私有成員AsyncManager,利用該對象能夠簡單地將同步轉換成異步。

而本來有的方法,要拆分紅兩個方法來寫,分別在兩個方法用原名加上Async和Completed。

好比個人ListenController,裏面有一個User方法,用以監聽用戶的數據。通過實現以後,就變成了ListenController : AsyncController,同時擁有一對User方法:UserAsync和UserCompleted。

那麼,在頁面請求Listen/User的時候,就會自動調用名稱匹配的UserAsync方法。

在這以後,咱們就須要利用AsyncManager執行如下語句,將線程「掛起」(Hold住,這樣懂了吧):

asyncManager.OutstandingOperations.Increment();

直到咱們有消息須要發送給用戶的時候,經過如下方式對UserCompleted進行傳參:

asyncManager.Parameters["listeningCode"] = Code;

而後再觸發UserCompleted:

asyncManager.OutstandingOperations.Decrement();

再總體地看一次,ListenController就是長這個樣子的:

 1     public class ListenController : AsyncController
 2     {
 3         //
 4         // GET: /Listen/
 5 
 6         ICometManager cometManager;
 7 
 8         public ListenController()
 9         {
10             cometManager = StructureMap.ObjectFactory.GetInstance<ICometManager>();
11         }
12 
13         /// <summary>
14         /// 監聽用戶的信息
15         /// </summary>
16         /// <param name="listeningCode">監聽編碼,若是爲空則視爲一次全新的監聽,容許同以客戶端開啓多個網頁進行多個監聽</param>
17         public void UserAsync(int? listeningCode)
18         {
19             //開始監聽用戶
20             cometManager.ListenUser(listeningCode, AsyncManager);
21         }
22 
23         /// <summary>
24         /// 返回用戶的信息
25         /// </summary>
26         /// <param name="listeningCode">監聽編碼</param>
27         /// <returns></returns>
28         public JsonpResult UserCompleted(int listeningCode)
29         {
30             //獲取用戶全部的消息
31             var callbacks = cometManager.TakeAllUserCallbacks(listeningCode);
32 
33             //將該消息返回
34             return Json(new
35             {
36                 ListeningCode = listeningCode,
37                 Callbacks = callbacks.Select(item => new CallbackModel(item))
38             })
39             .ToJsonp();
40         }
41     }
ListenController

CometManager就是我用來處理輪詢的對象。

注意到在UserCompleted是經過了一個ICometManager.TakeAllUserCallbacks來獲取用戶的全部回調數據,而不是直接經過AsyncManager.Parameters發送。緣由是實現過程當中我發現沒法經過AsyncManager.Parameters將自定義對象傳參,因此採起了這種方式。或許,實現序列化後或者引用相關序列化方法,能實現如此傳參。

在CometManager : ICometManager中,相關實現如此:

 1         /// <summary>
 2         /// 監聽用戶的方法
 3         /// </summary>
 4         /// <param name="listeningCode">指定監聽編碼,若是爲空則爲全新的監聽</param>
 5         /// <param name="asyncManager">監聽來源頁面的AsyncManager,用以處理異步與回調</param>
 6         public void ListenUser(int? listeningCode, System.Web.Mvc.Async.AsyncManager asyncManager)
 7         {
 8             //監聽新消息
 9             userListenerQuery.Add(chatUserProvider.Current.Id, listeningCode, userListenManager, asyncManager);
10         }
11 
12         /// <summary>
13         /// 取走用戶全部回調結果
14         /// </summary>
15         /// <param name="listeningCode">監聽者的Id</param>
16         /// <returns></returns>
17         public IEnumerable<CallbackModel> TakeAllUserCallbacks(int listeningCode)
18         {
19             return userListenerQuery.TakeAllCallback(listeningCode);
20         }
CometManager節選

userListenerQuery是一個單例(Singleton)的監聽隊列;而UserListenManager是往上一層的監聽管理對象,畢竟Chat自己不單止支持輪詢,還須要支持其餘通訊方式,因此往上有一個公共層管理着全部消息。

 

.NET中的異步

除了MVC自己提供的特有方法外,還須要一些傳統的行爲才能實現完整的長輪詢。

接着上面,參照ListenQuery的實現:

 1         Dictionary<int, CometListener> listenersDic;
 2         Dictionary<int, DateTime> lastAddTimeDic;
 3 
 4         public ListenerQuery()
 5         {
 6             listenersDic = new Dictionary<int, CometListener>();
 7             lastAddTimeDic = new Dictionary<int, DateTime>();
 8         }
 9 
10         /// <summary>
11         /// 添加一個監聽
12         /// </summary>
13         /// <param name="listenToId">監聽對象的Id</param>
14         /// <param name="listeningCode">原有監聽者的編碼</param>
15         /// <param name="listenManager">監聽的相關業務管理對象</param>
16         /// <param name="asyncManager">頁面的異步管理對象</param>
17         /// <returns>監聽編碼</returns>
18         public int Add(int listenToId, int? listeningCode, IListenManager<int> listenManager, AsyncManager asyncManager)
19         {
20             lock (listenersDic)
21             {
22                 lock (lastAddTimeDic)
23                 {
24                     CometListener listener;
25                     //若是監聽者不存在,則生成,不然用原有的監聽者
26                     if (listeningCode == null || !listenersDic.ContainsKey(listeningCode.Value))
27                     {
28                         ////生成其隨機編碼
29                         //var seed = 10000;
30                         //var random = new Random(seed);
31                         //listeningCode = random.Next(seed);
32                         //while (listenersDic.ContainsKey(listeningCode.Value))
33                         //{
34                         //    listeningCode = random.Next(seed);
35                         //}
36                         //改成採用原有編碼
37 
38                         //生成監聽者並開始監聽
39                         Action<int> setListenerCode;
40                         listener = new CometListener(out setListenerCode);
41                         listenManager.ListenAsnyc(listenToId, listener, setListenerCode);
42 
43                         listeningCode = listener.Code;
44                         //添加入本列表字典
45                         listenersDic.Add(listeningCode.Value, listener);
46                         //添加監聽時間
47                         lastAddTimeDic.Add(listeningCode.Value, DateTime.Now);
48                     }
49                     else
50                     {
51                         listener = listenersDic[listeningCode.Value];
52                         lastAddTimeDic[listeningCode.Value] = DateTime.Now;
53                     }
54 
55                     //開始監聽
56                     listener.Begin(asyncManager);
57 
58                     //定時一次檢查,若是監聽超時,則清除監聽
59                     //設計倒計時,按期從新監聽,以避免超時
60                     var timeLimitInMilliSecond = 60000;
61                     System.Timers.Timer timer = new System.Timers.Timer(timeLimitInMilliSecond);
62 
63                     //設置計時終結方法
64                     timer.Elapsed += (sender, e) =>
65                     {
66                         if (lastAddTimeDic[listeningCode.Value].AddSeconds(45) < DateTime.Now)
67                         {
68                             listenManager.StopListenAsnyc(listener);
69                         }
70                     };
71 
72                     //啓動倒計時
73                     timer.Start();
74                 }
75             }
76 
77             return listeningCode.Value;
78         }
79 
80         /// <summary>
81         /// 取走全部回調結果
82         /// </summary>
83         /// <param name="listeningCode">監聽者的Id</param>
84         /// <returns></returns>
85         public IEnumerable<CallbackModel> TakeAllCallback(int listeningCode)
86         {
87             return listenersDic[listeningCode].ShiftAllCallbacks();
88         }
89     }
ListenerQuery

這裏用了一個字典來記錄每一個ListeningCode以及相關的Listener。

注意Add方法內有一個Timer。就像註釋上所說的,按期檢查用戶是否在監聽。我在這裏設置了每30秒有一次「心跳」(Beat),而每次監聽後的第60秒會來檢查45秒內(暫時這麼設置的,有待時間考驗是否是個合適值)用戶是否再來監聽,若是沒有則中止監聽。

這麼作的緣由是防止客戶端單方面離婚毀約,而後服務端的Comet傻傻地在這裏癡情地幫客戶端繼續保留緩存消息。這種狀況時有出現,好比客戶端還沒等到答覆(Response)就私奔關掉了頁面,留下服務單在那邊Hold住鏈接傻傻地等待。

注意凡是處理隊列類的地方都有鎖,以防止併發問題。

那麼最後,CometListener的實現就以下:

  1     public class CometListener : Listen.IListener
  2     {
  3         AsyncManager asyncManager;
  4 
  5         List<CallbackModel> callbacks;
  6 
  7         /// <summary>
  8         /// 構造函數
  9         /// </summary>
 10         public CometListener(out Action<int> setListenerCode)
 11         {
 12             setListenerCode = setCode;
 13 
 14             callbacks = new List<CallbackModel>();
 15         }
 16 
 17         internal void setCode(int code)
 18         {
 19             this.Code = code;
 20         }
 21 
 22         /// <summary>
 23         /// 開始監聽的方法
 24         /// </summary>
 25         /// <param name="asyncManager">頁面的異步處理對象</param>
 26         public void Begin(AsyncManager asyncManager)
 27         {
 28             //先把原有數據返回
 29             Return();
 30 
 31             lock (asyncManager)
 32             {
 33                 this.asyncManager = asyncManager;
 34                 lock (this.asyncManager)
 35                 {
 36                     //啓動異步
 37                     asyncManager.OutstandingOperations.Increment();
 38 
 39                     //設計倒計時,按期斷開監聽,以避免網關超時
 40                     var timeLimitInMilliSecond = 30000;
 41                     System.Timers.Timer timer = new System.Timers.Timer(timeLimitInMilliSecond);
 42 
 43                     //設置計時終結方法
 44                     timer.Elapsed += (sender, e) =>
 45                     {
 46                         if (this.asyncManager == asyncManager)
 47                         {
 48                             Return();
 49                         }
 50                     };
 51 
 52                     //啓動倒計時
 53                     timer.Start();
 54                 }
 55             }
 56         }
 57 
 58         /// <summary>
 59         /// 將現有的值返回給客戶端
 60         /// </summary>
 61         public void Return()
 62         {
 63             if (asyncManager != null)
 64             {
 65                 lock (asyncManager)
 66                 {
 67                     //返回最新值
 68                     asyncManager.Parameters["listeningCode"] = Code;
 69 
 70                     //返回最新值
 71                     asyncManager.OutstandingOperations.Decrement();
 72 
 73                     //清空當前頁面異步對象,以等待下一個輪詢請求
 74                     asyncManager = null;
 75                 }
 76             }
 77         }
 78 
 79         /// <summary>
 80         /// 拿走並清除callbacks
 81         /// </summary>
 82         public IEnumerable<CallbackModel> ShiftAllCallbacks()
 83         {
 84             lock (callbacks)
 85             {
 86                 var result = callbacks.ToList();
 87                 callbacks.Clear();
 88                 return result;
 89             }
 90         }
 91 
 92 
 93         #region IListener members
 94 
 95         /// <summary>
 96         /// 惟一的監聽編碼,用以隔開並區分監聽
 97         /// </summary>
 98         public int Code
 99         {
100             get;
101             private set;
102         }
103 
104         /// <summary>
105         /// 回調方法,經過該方法將新的數據發送回給監聽者
106         /// </summary>
107         /// <param name="typeCode">數據的類型</param>
108         /// <param name="data">數據內容</param>
109         /// <returns></returns>
110         public async Task CallAsync(int typeCode, object args)
111         {
112             lock (callbacks)
113             {
114                 callbacks.Add(new CallbackModel(typeCode, args));
115             }
116             Return();
117         }
118 
119         #endregion
120     }
CometListener


 

 

 

總結

兩週前單次通訊的往返大約在200ms~300ms之間,此次重構後,將Chat內核中大量同步行爲改爲了異步併發,已經將單次通訊往返壓縮在了30ms~50ms之間。固然最但願是能壓縮在10ms~20ms,那樣就能夠用長輪詢進行高同步性的遊戲應用了,好比射擊、即時戰略。可是,到時候就沒那麼簡單了吧,畢竟心跳(Beat)的時候是會有兩次往返,也就是必須將單次往返壓縮在10ms之內纔有可能實現,頁面的數據支撐也是個問題,須要大量套用字頁面來存放數據,Balabalabalabala.......

和JSONP同樣,長輪詢是一個畸形的技術,也更加是開發人員在備受顯示狀況限制下智慧的結晶。固然,從通訊上來說,它不是一項「優秀」的技術或者協議,它浪費了太多「沒必要要」的資源在沒必要要的事情上了。就像期待IE6今早從市場上消失同樣,我也期待你們廣泛早日統一用上諸如Web Socket通常更好的通訊技術。但現時來講,咱們不得不以相似於長輪訓、Hack的一些方式向底端的用戶妥協,畢竟用戶纔是產品的最終使用者。

最後,再次感謝各位當時在Chat貢獻的測試數據,特別感謝諸位在上面約架(pao)、求關(zhong)注(子)和發#ffd800網地址的幾位同胞。Azure帳號已經到期,因此已經上不去了。你們對數據感興趣嗎?(呵呵呵呵呵呵呵呵呵呵~)

相關文章
相關標籤/搜索