兩周前用長輪詢做了一個Chat,並移植到了Azure,還寫了篇博客http://www.cnblogs.com/indream/p/3187540.html,讓大家幫忙測試。
首先感謝300位注冊用戶,讓我有充足的數據進行重構和優化。所以這兩周都在進行大重構。
其中最大的一個問題就是數據流量過大,原先已有更新,還會有Web傳統“刷新”的形式把數據重新拿一次,然后再替換掉本地數據。
但這一拿問題就來了,在10個Chat*300個用戶的情況下,這一拿產生了一次8M多的流量,這是十分嚴重的事情,特別是其中絕大部分數據都是浪費掉了的。
那么解決方案就很簡單了,把“全量”改成“增量”,只傳輸修改的部分,同時大量增加往返次數,把每次往返量壓縮。
當然,這篇文章主要講長輪詢,也是之后被問得比較多的方面,所以就單獨寫篇文章出來了。
這次比單純的輪詢多了一個緩存行為,以解決每次“心跳”中所產生的斷線間隔數據丟失的問題。
首先列舉一下所使用到的技術點:
- jQuery.Ajax
- .NET同步(lock)與異步(async await Task)
- MVC異步頁面
長輪詢的簡介
長輪詢是一種類似於JSONP一樣畸形的Web通信技術,用以實現Web與服務端之間的實時雙向通信。
在有人實現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 },
.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 }
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 }
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 }
這里用了一個字典來記錄每個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 }
總結
兩周前單次通信的往返大約在200ms~300ms之間,這次重構后,將Chat內核中大量同步行為改成了異步並發,已經將單次通信往返壓縮在了30ms~50ms之間。當然最希望是能壓縮在10ms~20ms,那樣就可以用長輪詢進行高同步性的游戲應用了,比如射擊、即時戰略。但是,到時候就沒那么簡單了吧,畢竟心跳(Beat)的時候是會有兩次往返,也就是必須將單次往返壓縮在10ms以內才有可能實現,頁面的數據支撐也是個問題,需要大量套用字頁面來存放數據,Balabalabalabala.......
和JSONP一樣,長輪詢是一個畸形的技術,也更加是開發人員在備受顯示情況限制下智慧的結晶。當然,從通信上來講,它不是一項“優秀”的技術或者協議,它浪費了太多“不必要”的資源在不必要的事情上了。就像期待IE6今早從市場上消失一樣,我也期待大家普遍早日統一用上諸如Web Socket一般更好的通信技術。但現時來說,我們不得不以類似於長輪訓、Hack的一些方式向底端的用戶妥協,畢竟用戶才是產品的最終使用者。
最后,再次感謝各位當時在Chat貢獻的測試數據,特別感謝諸位在上面約架(pao)、求關(zhong)注(子)和發#ffd800網地址的幾位同胞。Azure賬號已經到期,所以已經上不去了。大家對數據感興趣嗎?(呵呵呵呵呵呵呵呵呵呵~)