開放、平等、協(xié)作、快速、分享
自從即時(shí)Web的概念提出后,RealTime便成為了web開發(fā)者們津津樂道的話題。實(shí)時(shí)化的web應(yīng)用,憑借其響應(yīng)迅速、無需刷新、節(jié)省網(wǎng)絡(luò)流量的特性,不僅讓開發(fā)者們眼前一亮,更是為用戶帶來絕佳的網(wǎng)絡(luò)體驗(yàn)。
近年來關(guān)于RealTime的實(shí)現(xiàn),主要還是基于Ajax的拉取和Comet的推送。大家都知道Ajax,這是一種借助瀏覽器端JavaScript實(shí)現(xiàn)的異步無刷新請(qǐng)求功能:要客戶端按需向服務(wù)器發(fā)出請(qǐng)求,并異步獲取來自服務(wù)器的響應(yīng),然后按照邏輯更新當(dāng)前頁(yè)面的相應(yīng)內(nèi)容。但是這僅僅是拉取啊,這并不是真正的RealTime:缺少服務(wù)器端的自動(dòng)推送!因此,我們不得不使用另一種略復(fù)雜的技術(shù)Comet,只有當(dāng)這兩者配合起來,這個(gè)web應(yīng)用才勉強(qiáng)算是個(gè)RealTime應(yīng)用!
不過隨著HTML5草案的不斷完善,越來越多的現(xiàn)代瀏覽器開始全面支持WebSocket技術(shù)了。至于WebSocket,我想大家或多或少都聽說過。
這個(gè)WebSocket是一種全新的協(xié)議。它將TCP的Socket(套接字)應(yīng)用在了web page上,從而使通信雙方建立起一個(gè)保持在活動(dòng)狀態(tài)連接通道,并且屬于全雙工(雙方同時(shí)進(jìn)行雙向通信)。
其實(shí)是這樣的,WebSocket協(xié)議是借用HTTP協(xié)議的101 switch protocol
來達(dá)到協(xié)議轉(zhuǎn)換的,從HTTP協(xié)議切換成WebSocket通信協(xié)議。
再簡(jiǎn)單點(diǎn)來說,它就好像將Ajax和Comet技術(shù)的特點(diǎn)結(jié)合到了一起,只不過性能要高并且使用起來要方便的多(當(dāng)然是之指在客戶端方面。。)
RFC草案中已經(jīng)說明,WebSocket的目的就是為了在基礎(chǔ)上保證傳輸?shù)臄?shù)據(jù)量最少。
這個(gè)協(xié)議是基于Frame而非Stream的,也就是說,數(shù)據(jù)的傳輸不是像傳統(tǒng)的流式讀寫一樣按字節(jié)發(fā)送,而是采用一幀一幀的Frame,并且每個(gè)Frame都定義了嚴(yán)格的數(shù)據(jù)結(jié)構(gòu),因此所有的信息就在這個(gè)Frame載體中。(后面會(huì)詳細(xì)介紹這個(gè)Frame)
基于TCP協(xié)議
具有命名空間
可以和HTTP Server共享同一port
下面我先用自然語言描述一下WebSocket的工作原理:
若要實(shí)現(xiàn)WebSocket協(xié)議,首先需要瀏覽器主動(dòng)發(fā)起一個(gè)HTTP請(qǐng)求。
這個(gè)請(qǐng)求頭包含“Upgrade”字段,內(nèi)容為“websocket”(注:upgrade字段用于改變HTTP協(xié)議版本或換用其他協(xié)議,這里顯然是換用了websocket協(xié)議),還有一個(gè)最重要的字段“Sec-WebSocket-Key”,這是一個(gè)隨機(jī)的經(jīng)過base64
編碼的字符串,像密鑰一樣用于服務(wù)器和客戶端的握手過程。一旦服務(wù)器君接收到來自客戶端的upgrade請(qǐng)求,便會(huì)將請(qǐng)求頭中的“Sec-WebSocket-Key”字段提取出來,追加一個(gè)固定的“魔串”:258EAFA5-E914-47DA-95CA-C5AB0DC85B11
,并進(jìn)行SHA-1
加密,然后再次經(jīng)過base64
編碼生成一個(gè)新的key,作為響應(yīng)頭中的“Sec-WebSocket-Accept”字段的內(nèi)容返回給瀏覽器。一旦瀏覽器接收到來自服務(wù)器的響應(yīng),便會(huì)解析響應(yīng)中的“Sec-WebSocket-Accept”字段,與自己加密編碼后的串進(jìn)行匹配,一旦匹配成功,便有建立連接的可能了(因?yàn)檫€依賴許多其他因素)。
這是一個(gè)基本的Client請(qǐng)求頭:(我只寫了關(guān)鍵的幾個(gè)字段)
Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: ************== Sec-WebSocket-Version: **
Server正確接收后,會(huì)返回一個(gè)響應(yīng)頭:(同樣只有關(guān)鍵的)
Upgrade:websocket Connnection: Upgrade Sec-WebSocket-Accept: ******************
這表示雙方握手成功了,之后就是全雙工的通信。
當(dāng)你看完上面一節(jié)后一定會(huì)質(zhì)疑該協(xié)議的保密性和安全性,看上去任何客戶端都能夠很容易的向WS服務(wù)器發(fā)起請(qǐng)求或偽裝截獲數(shù)據(jù)。WebSocket協(xié)議規(guī)定在連接建立時(shí)檢查Upgrade請(qǐng)求中的某些字段(如Origin
),對(duì)于不符合要求的請(qǐng)求立即截?cái)?;在通信過程中,也對(duì)Frame中的控制位做了很多限制,以便禁止異常連接。
對(duì)于握手階段的檢查,這種限制僅僅是在瀏覽器中,對(duì)于特殊的客戶端(non-browser,如編碼構(gòu)造正確的請(qǐng)求頭發(fā)送連接請(qǐng)求),這種源模型就失效了。
(后面會(huì)介紹通信過程中的連接關(guān)閉種類與流程。)
除此之外,WebSocket也規(guī)定了加密數(shù)據(jù)傳輸方法,允許使用TLS/SSL對(duì)通信進(jìn)行加密,類似HTTPS。默認(rèn)情況下,ws協(xié)議使用80端口進(jìn)行普通連接,加密的TLS連接默認(rèn)使用443端口。
WebSocket是基于TCP的獨(dú)立的協(xié)議。
和HTTP的唯一關(guān)聯(lián)就是HTTP服務(wù)器需要發(fā)送一個(gè)“Upgrade”請(qǐng)求,即101 Switching Protocol
到HTTP服務(wù)器,然后由服務(wù)器進(jìn)行協(xié)議轉(zhuǎn)換。
客戶端向服務(wù)器發(fā)起握手請(qǐng)求的header中可能帶有“Sec-WebSocket-Protocol”字段,用來指定一個(gè)特定的子協(xié)議,一旦這個(gè)字段有設(shè)置,那么服務(wù)器需要在建立連接的響應(yīng)頭中包含同樣的字段,內(nèi)容就是選擇的子協(xié)議之一。
子協(xié)議的命名應(yīng)該是注冊(cè)過的(有一套規(guī)范)。
為了避免潛在的沖突,建議子協(xié)議的源(發(fā)起者)使用ASCII編碼的域名。
例子:
一個(gè)注冊(cè)過的子協(xié)議叫“chat.xxx.com”,另一個(gè)叫“chat.xxx.org”。這兩個(gè)子協(xié)議都會(huì)被server同時(shí)實(shí)現(xiàn),server會(huì)動(dòng)態(tài)的選擇使用哪個(gè)子協(xié)議(取決于客戶端發(fā)送過來的值)。
擴(kuò)展是用來增加ws協(xié)議一些新特性的,這里就不詳細(xì)說了。
上面說的僅僅是個(gè)概述,重要的是該如何在我們的web應(yīng)用中使用或者說該如何建立一個(gè)基于WebSocket的應(yīng)用呢?
我直說了,客戶端使用WebSocket簡(jiǎn)直易如反掌,服務(wù)端實(shí)現(xiàn)WebSocket真是難的一B??!尤其是我們現(xiàn)在還沒有學(xué)過計(jì)算機(jī)網(wǎng)絡(luò),對(duì)一些網(wǎng)絡(luò)底層的(如TCP/IP協(xié)議)知識(shí)了解的太少,理解并實(shí)現(xiàn)WebSocket確實(shí)不太容易。所以這次我先把WebSocket用提供一部分接口的高級(jí)語言來實(shí)現(xiàn)。
Node.js的異步I/O模型實(shí)在是太適合這種類型的應(yīng)用了,因此我選擇它作為I/O編程的首選。來看下面的JavaScript代碼~:
Note:以下代碼僅用于闡明原理,不可用于生產(chǎn)環(huán)境!
var http = require('http'); var crypto = require('crypto'); var MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // HTTP服務(wù)器部分
var server = http.createServer(function (req, res) { res.end('websocket test\r\n');
}); // Upgrade請(qǐng)求處理
server.on('upgrade', callback); function callback(req, socket) { // 計(jì)算返回的key
var resKey = crypto.createHash('sha1')
.update(req.headers['sec-websocket-key'] + MAGIC_STRING)
.digest('base64'); // 構(gòu)造響應(yīng)頭
resHeaders = ([ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: ' + resKey
]).concat('', '').join('\r\n'); // 添加通信數(shù)據(jù)處理
socket.on('data', function (data) { // ...
}); // 響應(yīng)給客戶端
socket.write(resHeaders);
} server.listen(3000);
上面的代碼是等待客戶端與之握手,當(dāng)有客戶端發(fā)出請(qǐng)求時(shí),會(huì)按照“加密-編碼-返回”的流程與之建立通信通道。既然連接已建立,接下來就是雙方的通信了。為了讓大家明白WebSocket的全程使用,在此之前有必要提一下支持WebSocket的底層協(xié)議的實(shí)現(xiàn)。
協(xié)議這種東西就像某種魔法,賦予了計(jì)算機(jī)之間各種神奇的通信能力,但對(duì)用戶來說卻是透明的。
不過對(duì)于WebSocket協(xié)議,我們可以透過IETF的RFC規(guī)范,看到關(guān)于實(shí)現(xiàn)WebSocket細(xì)節(jié)的每次變更與修正。
前面已經(jīng)說過了WebSocket在客戶端與服務(wù)端的“Hand-Shaking”實(shí)現(xiàn),所以這里講數(shù)據(jù)傳輸。
WebSocket傳輸?shù)臄?shù)據(jù)都是以Frame
(幀)的形式實(shí)現(xiàn)的,就像TCP/UDP協(xié)議中的報(bào)文段Segment
。下面就是一個(gè)Frame:(以bit為單位表示)
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
按照RFC中的描述:
FIN: 1 bit
表示這是一個(gè)消息的最后的一幀。第一個(gè)幀也可能是最后一個(gè)。 %x0 : 還有后續(xù)幀 %x1 : 最后一幀
RSV1、2、3: 1 bit each
除非一個(gè)擴(kuò)展經(jīng)過協(xié)商賦予了非零值以某種含義,否則必須為0 如果沒有定義非零值,并且收到了非零的RSV,則websocket鏈接會(huì)失敗
Opcode: 4 bit
解釋說明 “Payload data” 的用途/功能 如果收到了未知的opcode,最后會(huì)斷開鏈接 定義了以下幾個(gè)opcode值: %x0 : 代表連續(xù)的幀 %x1 : text幀 %x2 : binary幀 %x3-7 : 為非控制幀而預(yù)留的 %x8 : 關(guān)閉握手幀 %x9 : ping幀 %xA : pong幀 %xB-F : 為非控制幀而預(yù)留的
Mask: 1 bit
定義“payload data”是否被添加掩碼 如果置1, “Masking-key”就會(huì)被賦值 所有從客戶端發(fā)往服務(wù)器的幀都會(huì)被置1
Payload length: 7 bit | 7+16 bit | 7+64 bit
“payload data” 的長(zhǎng)度如果在0~125 bytes范圍內(nèi),它就是“payload length”, 如果是126 bytes, 緊隨其后的被表示為16 bits的2 bytes無符號(hào)整型就是“payload length”, 如果是127 bytes, 緊隨其后的被表示為64 bits的8 bytes無符號(hào)整型就是“payload length”
Masking-key: 0 or 4 bytes
所有從客戶端發(fā)送到服務(wù)器的幀都包含一個(gè)32 bits的掩碼(如果“mask bit”被設(shè)置成1),否則為0 bit。一旦掩碼被設(shè)置,所有接收到的payload data都必須與該值以一種算法做異或運(yùn)算來獲取真實(shí)值。(見下文)
Payload data: (x+y) bytes
它是"Extension data"和"Application data"的總和,一般擴(kuò)展數(shù)據(jù)為空。
Extension data: x bytes
除非擴(kuò)展被定義,否則就是0 任何擴(kuò)展必須指定其Extension data的長(zhǎng)度
Application data: y bytes
占據(jù)"Extension data"之后的剩余幀的空間
注意:這些數(shù)據(jù)都是以二進(jìn)制形式表示的,而非ascii編碼字符串
Frame的結(jié)構(gòu)已經(jīng)清楚了,我們就構(gòu)造一個(gè)Frame。
在構(gòu)造時(shí),我們可以把Frame分成兩段:控制位和數(shù)據(jù)位。其中控制位就是Frame的前兩字節(jié),包含F(xiàn)IN、Opcode等與該Frame的元信息。
Note:網(wǎng)絡(luò)中使用大端次序(Big endian)表示大于一字節(jié)的數(shù)據(jù),稱之為網(wǎng)絡(luò)字節(jié)序。
Node.js中提供了Buffer對(duì)象,專門用來彌補(bǔ)JavaScript在處理字節(jié)數(shù)據(jù)上的不足,這里正好可以用它來完成這個(gè)任務(wù):
// 控制位: FIN, Opcode, MASK, Payload_len
var preBytes = [],
payBytes = new Buffer('test websocket'),
mask = 0;
masking_key = Buffer.randomByte(4); var dataLength = payBytes.length; // 構(gòu)建Frame的第一字節(jié)
preBytes.push((frame['FIN'] << 7) + frame['Opcode']); // 處理不同長(zhǎng)度的dataLength,構(gòu)建Frame的第二字節(jié)(或第2~第8字節(jié))
// 注意這里以大端字節(jié)序構(gòu)建dataLength > 126的dataLenght
if (dataLength < 126) { preBytes.push((frame['MASK'] << 7) + dataLength);
} else if (dataLength < 65536) { preBytes.push(
(frame['MASK'] << 7) + 126,
(dataLength & 0xFF00) >> 8,
dataLength & 0xFF
);
} else { preBytes.push(
(frame['MASK'] << 7) + 127, 0, 0, 0, 0,
(dataLength & 0xFF000000) >> 24,
(dataLength & 0xFF0000) >> 16,
(dataLength & 0xFF00) >> 8,
dataLength & 0xFF
);
}
preBytes = new Buffer(preBytes); // 如果有掩碼,就對(duì)數(shù)據(jù)進(jìn)行加密,并構(gòu)建之后的控制位
if (mask) {
preBytes = Buffer.concat([preBytes, masking_key]); for (var i = 0; i < dataLength; i++)
payBytes[i] ^= masking_key[i % 4];
} // 生成一個(gè)Frame
var frame = Buffer.concat([preBytes, payBytes]);
按照這種格式,就定義好了一個(gè)幀,客戶端或者服務(wù)器就可以用這個(gè)幀來互傳數(shù)據(jù)了。既然數(shù)據(jù)已經(jīng)接收,接下來看看如何處理這些數(shù)據(jù)。
規(guī)范里解釋了Masking-key
掩碼的作用了:就是當(dāng)mask
字段的值為1時(shí),payload-data
字段的數(shù)據(jù)需要經(jīng)這個(gè)掩碼進(jìn)行解密。
在處理數(shù)據(jù)之前,我們要清楚一件事:服務(wù)器推送到客戶端的消息中,mask
字段是0,也就是說Masking-key
為空。這樣的話,數(shù)據(jù)的解析就不涉及到掩碼,直接使用就行。
但是我們前面提到過,如果消息是從客戶端發(fā)送到服務(wù)器,那么mask
一定是1,Masking-key
一定是一個(gè)32bit的值。下面我們來看看數(shù)據(jù)是如何解析的:
當(dāng)消息到達(dá)服務(wù)器后,服務(wù)器程序就開始以字節(jié)為單位逐步讀取這個(gè)幀,當(dāng)讀取到payload-data
時(shí),首先將數(shù)據(jù)按byte依次與Masking-key
中的4個(gè)byte按照如下算法做異或:
//假設(shè)我們發(fā)送的"Payload data"以變量`data`表示,字節(jié)(byte)數(shù)為len;
//masking_key為4byte的mask掩碼組成的數(shù)組
//offset:跳過的字節(jié)數(shù)
for (var i = 0; i < len; i++) { var j = i % 4;
data[offset + i] ^= masking_key[j];
}
上面的JavaScript代碼給出了掩碼Masking-key
是如何解密Payload-data
的:先對(duì)i取模來獲得要使用的masking-key的索引,然后用data[offset + i]
與masking_key[j]
做異或,從而得到真實(shí)的byte數(shù)據(jù)。
控制幀用來說明WebSocket的狀態(tài)信息,用來控制分片、連接的關(guān)閉等等。所有的控制幀必須有一個(gè)小于等于125字節(jié)的payload,并且control Frames不允許被分片。Opcode
為0x0
(持續(xù)的幀),0x8
(關(guān)閉連接),0x9
(Ping幀)和0xA
(Pong幀)代表控制幀。
一般Ping Frame用來對(duì)一個(gè)有超時(shí)機(jī)制的套接字keepalive或者驗(yàn)證對(duì)方是否有響應(yīng)。Pong Frame就是對(duì)Ping的回應(yīng)。
前面我們總是談到“控制幀”和“非控制幀”,想必大家已經(jīng)看出來一些門路。其實(shí)數(shù)據(jù)幀就是非控制幀。因?yàn)檫@個(gè)幀并不是用來提供協(xié)議連接狀態(tài)信息的。數(shù)據(jù)幀由最高符號(hào)位是0的Opcode
確定,現(xiàn)在可用的幾個(gè)數(shù)據(jù)幀的Opcode是0x1
(utf-8文本)、0x2
(二進(jìn)制數(shù)據(jù))。
理論上來說,每個(gè)幀(Frame)的大小是沒有限制的,因?yàn)閜ayload-data在整個(gè)幀的最后。但是發(fā)送的數(shù)據(jù)有不能太大,否則 WebSocket 很可能無法高效的利用網(wǎng)絡(luò)帶寬。那如果我們想傳點(diǎn)大數(shù)據(jù)該怎么辦呢?WebSocket協(xié)議給我們提供了一個(gè)方法:分片,將原本一個(gè)大的幀拆分成數(shù)個(gè)小的幀。下面是把一個(gè)大的Frame分片的圖示:
編號(hào): 0 1 .... n-2 n-1 分片: |——|——|......|——|——| FIN: 0 0 .... 0 1 Opcode: !0 0 .... 0 0
由圖可知,第一個(gè)分片的FIN
為0,Opcode
為非0值(0x1或0x2),最后一個(gè)分片的FIN
為1,Opcode
為0。中間分片的FIN
和Opcode
二者均為0。
Note1:消息的分片必須由發(fā)送者按給定的順序發(fā)送給接收者。
Note2:控制幀禁止分片
Note3:接受者不必按順序緩存整個(gè)frame來處理
發(fā)送關(guān)閉連接請(qǐng)求(Close Handshake)
即發(fā)送Close Frame
(Opcode為0x8)。一旦一端發(fā)送/接收了一個(gè)Close Frame,就開始了Close Handshake,并且連接狀態(tài)變?yōu)?code style="box-sizing: border-box; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; color: rgb(232, 62, 140); word-break: break-word; padding: 0.2em 0.4em; margin: 0px; background-color: rgba(27, 31, 35, 0.0470588); border-radius: 3px;">Closing。
Close Frame中如果包含Payload data,則data的前2字節(jié)必須為兩字節(jié)的無符號(hào)整形,(同樣遵循網(wǎng)絡(luò)字節(jié)序:BE)用于表示狀態(tài)碼,如果2byte之后仍有內(nèi)容,則應(yīng)包含utf-8編碼的關(guān)閉理由。
如果一端在之前未發(fā)送過Close Frame,則當(dāng)他收到一個(gè)Close Frame時(shí),必須回復(fù)一個(gè)Close Frame。但如果它正在發(fā)送數(shù)據(jù),則可以推遲到當(dāng)前數(shù)據(jù)發(fā)送完,再發(fā)送Close Frame。比如Close Frame在分片發(fā)送時(shí)到達(dá),則要等到所有剩余分片發(fā)送完之后,才可以作出回復(fù)。
關(guān)閉WebSocket連接
當(dāng)一端已經(jīng)收到Close Frame,并已發(fā)送了Close Frame時(shí),就可以關(guān)閉連接了,close handshake過程結(jié)束。這時(shí)丟棄所有已經(jīng)接收到的末尾字節(jié)。
關(guān)閉TCP連接
當(dāng)?shù)讓覶CP連接關(guān)閉時(shí),連接狀態(tài)變?yōu)?code style="box-sizing: border-box; font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; color: rgb(232, 62, 140); word-break: break-word; padding: 0.2em 0.4em; margin: 0px; background-color: rgba(27, 31, 35, 0.0470588); border-radius: 3px;">Closed。
如果TCP連接在Close handshake完成之后關(guān)閉,就表示W(wǎng)ebSocket連接已經(jīng)clean closed(徹底關(guān)閉)了。
如果WebSocket連接并未成功建立,狀態(tài)也為連接已關(guān)閉,但并不是clean closed
。
正常關(guān)閉過程屬于clean close
,應(yīng)當(dāng)包含close handshake
。
通常來講,應(yīng)該由服務(wù)器關(guān)閉底層TCP連接,而客戶端應(yīng)該等待服務(wù)器關(guān)閉連接,除非等待超時(shí)的話,那么自己關(guān)閉底層TCP連接。
服務(wù)器可以隨時(shí)關(guān)閉WebSocket連接,而客戶端不可以主動(dòng)斷開連接。
由于某種算法或規(guī)定,一端直接關(guān)閉連接。(特指在open handshake(打開連接)階段)
底層連接丟失導(dǎo)致的連接中斷。
由于某種算法或規(guī)范要求指定連接失敗。這時(shí),客戶端和服務(wù)器必須關(guān)閉WebSocket連接。當(dāng)一端得知連接失敗時(shí),不準(zhǔn)再處理數(shù)據(jù),包括響應(yīng)close frame。
為了防止海量客戶端同時(shí)發(fā)起重連請(qǐng)求(reconnect),客戶端應(yīng)該推遲一個(gè)隨機(jī)時(shí)間后重新連接,可以選擇回退算法來實(shí)現(xiàn),比如截?cái)喽M(jìn)制指數(shù)退避算法。
這兩篇blog里主要用自然語言講了WebSocket的實(shí)現(xiàn)。代碼的細(xì)節(jié)操作(例如:處理數(shù)據(jù)、安全處理等)并沒有給出,因?yàn)楹诵膶?shí)現(xiàn)原理已經(jīng)闡明。
因?yàn)榻趯懥艘粋€(gè)比較完整的WebSocket庫(kù)RocketEngine,在編碼過程中發(fā)現(xiàn)了好多需要注意的問題,特此加以補(bǔ)充和修正,增加了部分章節(jié),改正了一些不精確的說法,同時(shí)將兩篇日志合并。
如需詳細(xì)學(xué)習(xí),請(qǐng)戳=> RocketEngine(附詳細(xì)注釋與wiki)
24小時(shí)免費(fèi)咨詢
請(qǐng)輸入您的聯(lián)系電話,座機(jī)請(qǐng)加區(qū)號(hào)