本文版權歸 OSChina jsongo0 所有,轉載請標明出處,以示尊重!
原文:https://my.oschina.net/jsongo/blog/757871
為什么需要websocket?
傳統的實時交互的游戲,或服務器主動發送消息的行為(如推送服務),如果想做在微信上,可能你會使用輪詢的方式進行,不過這太消耗資源,大量的請求也加重了服務器的負擔,而且延遲問題比較嚴重。如果是自己開發的app,為了解決這些問題,很多團隊會自建socket,使用tcp長鏈接、自定協議的方式與服務器進行相對實時的數據交互。有能力的團隊,采用這種方式自然沒什么大問題。不過小團隊可能就要花費很多時間去調試,要解決很多難題,這個在成本上就划不來。
H5引入了webSocket來解決網頁端的長鏈接問題,而微信小程序也支持websocket。這是一個非常重要的特性,所以本系列的文章會專門拿出一篇來討論websocket。
webSocket本質上也是TCP連接,它提供全雙工的數據傳輸。一方面可以避免輪詢帶來的連接頻繁建立與斷開的性能損耗,另一方面數據可以是比較實時的進行雙向傳輸(因為是長鏈接),而且WebSocket允許跨域通信(這里有個潛在的跨域安全的問題,得靠服務端來解決)。目前除IE外的瀏覽器已經對webSocket支持得很好了,微信小程序再推一把之后,它會變得更加流行。
我們來設計一個新的demo,一個比較有趣的小游戲,多人版掃雷,准確地講,多人版挖黃金。
后台代碼:https://github.com/jsongo/mime-server
前端代碼:https://github.com/jsongo/wx-mime
游戲規則是這樣的:把雷換成金子,挖到金子加一分,每人輪流一次(A挖完輪到B,B挖完A才能再點擊),點中金子就算你的,也不會炸,游戲繼續,直到把場上所有的金子都挖完游戲才結束。跟掃雷一樣,數字也是表示周邊有幾個金子,然后用戶根據場上已經翻出來的數字來猜哪一格可能有金子。
這種交互的游戲難點在於,用戶的點擊操作都要傳到服務器上,而且服務器要實時的推送到其它玩家的應用上。另外用戶自己也要接收對方操作時實時傳過來的數據,這樣才不至於重復點中同一個格子。簡單講,就是你要上報操作給服務器,而服務器也要實時給你推消息。為了簡化整個模型,我們規定玩家必須輪流來點擊,玩家A點完后,才能輪到玩家B,玩家B操作完,玩家A才能點。
我們分幾步來實現這個功能。
一、實現思路
1、第一步,我們要先生成掃雷的地圖場景
這個算法比較簡單,簡述一下。隨機取某行某列就可以定位一個格子,標記成金子(-1表示金子)。mimeCnt表示要生成的金子的數量,用同樣的方式循環標記mimeCnt個隨機格子。生成完后,再用一個循環去掃描這些-1的格子,把它周邊的格子都加1,當然必須是非金子的格子才加1。代碼放在這里。
其中increaseArround用來把這格金子周邊的格子都加1,實現也比較簡單:
執行genMimeArr(),隨機生成結果如下:
-1表示金子。看了下貌似沒什么問題。接下去,我們就要接入webSocket了。
(這個是js版本的,其實生成地圖場景的工作是在后台生成,這個js版本只是一個演示,不過算法是一樣的。)
2、我們需要一個支持webSocket的服務端
本例子中,我們使用python的tornado框架來實現(tornado提供了tornado.websocket模塊)。當然讀者也可以使用socket.io,專為webSocket設計的js語言的服務端,用起來非常簡單,它也對不支持webSocket的瀏覽器提供了兼容(flash或comet實現)。
筆者本人比較喜歡使用tornado,做了幾年后台開發,使用最多的框架之一的就是它,NIO模型,而且非常輕量級,同樣的rps,java可能需要700-800M的內存,tornado只要30-40M,所以在一台4G內存的機子上可以跑上百個tornado服務,而java,對不起,只能跑3個虛擬機。微服務的時代,這一點對小公司很重要。當然如果讀者本人對java比較熟悉的話,也可以選擇netty框架嘗試一下。
webSocket用tornado的另一個好處是,它可以在同一個服務(端口)上同時支持webSocket及http兩種協議。tornado的官方demo代碼中展示了怎么實現同時使用兩種協議。在本游戲中,可以這么用:用戶進入首頁,用http協議去拉取當前的房間號及數據。因為首頁是打開最多的,進了首頁的用戶不一定會玩游戲。所以首頁還沒必要建立webSocket鏈接,webSocket鏈接主要用來解決頻繁請求及推送的操作。首頁只有一個請求操作。選了房間號后,進去下一個游戲頁面再開始建立webSocket鏈接。
3、客戶端
使用微信小程序開發工具,直接連接是會報域名安全錯誤的,因為工具內部做了限制,對安全域名才會允許連接。所以同樣的,這里我們也繼續改下工具的源碼,把相關的行改掉就行修改方式如下:
找到asdebug.js的這一行,把它改成: if(false)即可。
if (!i(r, "webscoket"))
注:筆者目前使用IDE版本為 0.11.112301 修改代碼部分為 if(!(0,l.checkUrl)(o,"webscoket")) ===》 if(false&&!(0,l.checkUrl)(o,"webscoket")) 即可
懶得修改的讀者可以直接使用我破解過的IDE。 發起一個websocket鏈接的代碼也比較簡單:
wx.connectSocket({ url: webSocketUrl, });
在調用這個請求代碼之前,先添加下事件監聽,這樣才知道有沒有連接成功:
wx.onSocketOpen(function(res){ console.log('websocket opened.'); });
連接失敗的事件:
wx.onSocketError(function(res){ console.log('websocket fail'); })
收到服務器的消息時觸發的事件:
wx.onSocketMessage(function(res){ console.log('received msg: ' + res.data); })
當鏈接建立之后,發送消息的方法如下:
wx.sendSocketMessage({ data:msg })
消息發送
由於建立鏈接是需要幾次握手,需要一定的時間,所以在wx.connectSocket成功之前,如果直接wx.sendSocketMessage發送消息會報錯,這里做一個兼容,如果連接還沒建立成功,則用一個數組來保存要發送的信息;而鏈接第一次建立時,把數據遍歷一遍,把消息拿出來一個個補發。這個邏輯我們封裝成一個send方法,如下:
function sendSocketMessage(msg) { if (typeof(msg) === 'object') { // 只能發送string msg = JSON.stringify(msg); } if (socketOpened) { // socketOpened變量在wx.onSocketOpen時設置為true wx.sendSocketMessage({ data:msg }); } else { // 發送的時候,鏈接還沒建立 socketMsgQueue.push(msg); } }
二、demo功能解析
1、首頁entry
為了簡化模型,把重點放在webSocket上,我們把首頁做成自己填寫房間號的形式。讀者如果自己有時間和能力的話,可以把首頁做成一個房間列表,並顯示每個房間有多少人在玩,只有一人的可以進去跟他玩。甚至后面還可以加上觀看模式,點擊別人的房間進去觀看別人怎么玩。
填寫房間號的input組件,添加一個事件,取得它的值event.detail.value后setData到本page里面。
點擊“開始游戲”,再把房間號存入app的globalData里面,然后wx.navigateTo到主游戲頁面index。
這個頁面比較簡單。
2、主游戲頁面
我們封裝一個websocket/connect.js模塊,專門用來處理websocket鏈接。主要有兩個方法,connect發起webSocket鏈接,send用來發送數據。
index主頁面:
初始化狀態,9x9的格子,每一格子其實都是一個button按鈕。我們生成的地圖場景數據,分別對應着每一格。比如1表示周邊的1個金子,0表示周邊沒有金子,-1表示這格是個金子,我們的目標就是找到這些-1。找得越多得分越高。
這里討論一個安全性問題。相信一句話:在前端做的安全措施大都是不靠譜的。上圖中的矩陣,每個格子背后的數據,不應該放在前端,因為js代碼是可以調試的,可以下斷點在相應的變量上,就可以看到整個矩陣數據,然后就知道哪些格子是金子,就可以作弊,這是非常不公平的。所以最好的方法是把這些矩陣數據存在后端,每次用戶操作的時候,把用戶點擊的坐標發到后台,后台再判斷相應的坐標是什么數據,再返回給前端。這個看似有很多數據傳輸的交互方式,其實是不會浪費資源,因為用戶的每個點擊操作,本來就要上報到后台,這樣游戲的另一玩家才知道你點了哪個格子。反正都是要傳數據的,所以肯定要傳坐標,這樣前端就完全沒有必要知道哪個格子是什么數據,因為后台的推送消息會告訴你。
這樣我們就繞過了前端存矩陣數據的問題。但是我們還是需要一個數組來存儲當前矩陣狀態的,比如哪個格子已經被翻開,里面是什么數據,也就是說要存儲場上已經被打開的格子。所以在后台,我們要存儲兩個數據,一個是所有的矩陣數據,也就是地圖場景數據;另一個是當前狀態的數據,這個要用來同步雙方的界面。
3、結束頁面
游戲結束的判斷條件,就是場上所有的金子都被挖完了。這個條件也是在后台判斷的。
在每次用戶挖到金子的時候,后台都會多一個判斷邏輯,就是看這個金子是否是最后一個。如果是的話,就發送一個over類型的消息給游戲的所有玩家。
玩家終端接收到這個消息時,就會結束當前的游戲,並跳到結束頁面。
沒有專門的設計師,隨便網上偷了張圖片貼上去,界面比較丑。下方顯示自己的得分和當前的房間號。
三、實現細節
1、代碼結構
前端代碼,分了幾個模塊:pages放所有的頁面,common放通用的模塊,mime放挖金子的主邏輯(暫時沒用到),res放資源文件,websocket放webSocket相關的處理邏輯。
后台代碼,讀者稍微了解一下就行了,不討論太多。里面我放了docker文件,熟悉docker的讀者可以直接一個命令跑起整個服務端。筆者在自己的服務器上跑了這個webSocket服務,ip和端口已經寫在前端代碼里,讀者輕虐。可能放不久,讀者可以自己把這個服務跑起來。
2、消息收發
(1)消息協議
我們簡單地定義下,消息的格式如下。 發送消息:
{type: 'dig', …}
type是必帶字段。
服務器返回的消息:
{errCode: 0, data: {type: 'dig', …} }
errCode為0的時候,表示請求處理成功,后面帶上data字段表示返回的數據,里面的type也是必帶字段,表示的是什么類型的消息。
因為webSocket類型的消息跟傳統的http請求不太一樣,http請求沒有狀態,一個請求過去,一會兒就返回,返回的數據肯定是針對這個請求的。而webSocket的模型是這樣的:客戶端發過去很多請求,然后也不知道服務器返回的數據哪個是對應哪個請求,所以需要一個字段來把所有的返回分成多種類型,並進行相應的處理。
(2)發送消息
發送消息就比較容易了,上面我們定義了一個send方法及未連接成功時的簡單的消息列表。
(3)接收消息
讀者在閱讀代碼的時候,可能會有一個疑惑,websocket/connect.js里只有send發送方法,而沒有接收推送消息的處理,那接收消息的處理在哪?怎么關聯起來的?
websocket/目錄里面還有另一個文件,msgHandler.js,它就是用來處理接收消息的主要處理模塊:
從服務器推送過來的消息,主要有這三種類型:1挖金子操作,可能是自己的操作,也可能是對方的操作,里面有一個字段isMe來表示是否是自己的操作。接收到這類消息時,會翻轉地圖上相應的格子,並顯示出挖的結果。2創建或進入房間的操作,一個房間有兩個用戶玩,創建者先開始。3游戲結束的消息,當應用接收到這類消息時,會直接跳轉到結束頁面。
這個處理邏輯,是在websocket/connect.js的wx.onSocketMessage回調里關聯上的。
在消息的收發過程中,每個消息交互,調試工具都會記錄下來。可以在調試工具里看到,在NetWork->WS里就可以看到:
3、前端挖金子
代碼如下:
var websocket = require('../../websocket/connect.js'); var msgReceived = require('../../websocket/msgHandler.js'); Page({ data: { mimeMap: null, leftGolds: 0, // 總共有多少金子 score: 0, // 我的得分 roomNo: 0 // 房間號 }, x: 0, // 用戶點中的列 y: 0, // 用戶點中的行 onLoad: function () { var roomNo = app.getRoomNo(); this.setData({ roomNo: roomNo }); // test // websocket.send('before connection'); if (!websocket.socketOpened) { // setMsgReceiveCallback websocket.setReceiveCallback(msgReceived, this); // connect to the websocket websocket.connect(); websocket.send({ type: 'create' }); } else { websocket.send({ type: 'create', no: roomNo }); } }, digGold: function(event) { // 不直接判斷,而把坐標傳給后台判斷 // 被開過的就不管了 if (event.target.dataset.value < 9) { return; } // 取到這格的坐標 this.x = parseInt(event.target.dataset.x); this.y = parseInt(event.target.dataset.y); console.log(this.x, this.y); // 上報坐標 this.reportMyChoice(); }, reportMyChoice: function() { roomNo = app.getRoomNo(); websocket.send({ type: 'dig', x: this.x, y: this.y, no: roomNo }); }, });
在page的onLoad事件里,先更新界面上的房間號信息。然后開始我們的重點,websocket.connect發起webSocket鏈接,websocket是我們封裝的模塊。然后把我們msgHandler.js處理邏輯設置到服務端推送消息回調里面。接着,發送一個create消息來創建或加入房間。服務端會對這個消息做出響應,返回本房間的當前狀態數據。
digGold是每個格子的點擊事件處理函數。這兒有一個邏輯,一個格子周邊最多有8個格子,所以每個格子的數據最大不可能大於8,上面代碼中可以看到有一個9,這其實是為了跟0區分,用來表示場上目前的還沒被翻開的格子的數據,用9來表示,當然你也可以用10,100都行。
wxml的矩陣數據綁定代碼如下:
<view wx:for="{{mimeMap}}" wx:for-item="row" wx:for-index="i" class="flex-container"> <button wx:for="{{row}}" wx:for-item="cell" wx:for-index="j" class="flex-item {{cell<0?'gold':''}} {{cell<9?'open':''}}" bindtap="digGold" data-x="{{j}}" data-y="{{i}}" data-value="{{cell}}"> {{cell<9?(cell<0?'*':cell):"-"}} </button> </view>
這個比較復雜些。兩層for,第一層遍歷行,第二層遍歷行里的每個格子的數據。wx:for-item指定里面用到的item的名字,wx:for-index指定里面用到的key的名字。每個格子是一個button,class值做了兩次判斷,如果這個格子的數據小於0,表示它是金子,加上gold這個class,主要是為了給它加樣式。而當數據是非9的時候,表示這個格子被翻開了,這時就加上open樣式,把顏色設置成橙色。data-x和data-y用來記錄格子的坐標,這樣的話,用戶點擊的時候,就可以直接從dataset里取出坐標值,再把這個值發到服務端進行判斷。
4、服務端實現
簡單的提一下就好,因為后台不是本系列文章的重點,雖然這個demo的開發也花了大半的時候在寫后台。前后端的消息交互,借助了webSocket通道,傳輸我們自己定義格式的內容。上面有個截圖顯示了后台代碼目錄的結構,划分得比較隨意,handlers里存放了的是主要的處理邏輯。webSocketHandler是入口,在它的on_message里,對收到的客戶端的消息,根據類型進行分發,dig類型,分發到answerHandler去處理,create類型,分發到roomHandler里去處理。
還有一點稍微提一下,本例子中的后台webSocket消息處理也跟傳統的http處理流程有一點不一樣。就是在最后返回的時候,不是直接返回的,而是廣播的形式,把消息發送給所有的人。比如用戶A點擊了格子,后台收到坐標后,會把這個坐標及坐標里的數據一起發送給房間里的所有人,而不是單獨返回給上報坐標的人。只是會有一個isMe字段來告訴客戶端是否是自己的操作。
總之,在做webSocket開發的時候,上面提到的,前后端都可能會有一些地方跟傳統的http接口開發不太一樣。讀者嘗試在做webSocket項目的時候,轉換一下思維。
最后提下一個注意點:微信小程序的websocket鏈接是全局只能有一個,官方提示:“一個微信小程序同時只能有一個 WebSocket 連接,如果當前已存在一個 WebSocket 連接,會自動關閉該連接,並重新創建一個 WebSocket 連接。”
本文首發地址:http://www.jsongo.com/post/js/2016/weapp-7/
oschina 上同步發布。