0x00 前言
當(dāng)前已經(jīng)成為和空氣水食物并列的生存必需品的互聯(lián)網(wǎng),其典型的應(yīng)用大多采用基于HTTP協(xié)議的B/S這一基礎(chǔ)架構(gòu)。作為自1994網(wǎng)景發(fā)布第一款瀏覽器以來就存在的這一技術(shù)體系,盡管20多年來不斷發(fā)展,已經(jīng)非常成熟,卻依然有一個(gè)尷尬之處隨著應(yīng)用場(chǎng)景的不斷豐富而越發(fā)明顯。那就是作為客戶端的瀏覽器,無法實(shí)時(shí)的接收來自服務(wù)端的信息推送,以至于后來大家想到用js腳本周期性調(diào)用ajax輪詢數(shù)據(jù)的方法曲線救國(guó),直到HTML5的廣泛應(yīng)用。
HTML5加入了一個(gè)非常重要的特性叫websocket,它的作用是讓瀏覽器開啟一個(gè)和服務(wù)器之間的雙向長(zhǎng)連接,客戶端既可以快速的向服務(wù)器發(fā)送信息,也可以實(shí)時(shí)接收來自服務(wù)器方向的推送,特別適用與即時(shí)通信,股票期貨交易,網(wǎng)絡(luò)游戲這類互性比較強(qiáng),實(shí)時(shí)要求比較高的應(yīng)用場(chǎng)景。
像其他HTML5的新特性一樣,當(dāng)前主流的瀏覽器均已支持websocket,比如Chrome及其魔改,F(xiàn)irefox,Safari,IE9及以上(包括Edge)。
下面用一個(gè)簡(jiǎn)單的范例來演示如何基于Tomcat實(shí)現(xiàn)一個(gè)最基本的websocket服務(wù),以便后續(xù)的學(xué)習(xí)和研究。
0x01 一個(gè)簡(jiǎn)單的范例
該范例參考tomcat自帶的websocket example,這里做進(jìn)一步的簡(jiǎn)化。
創(chuàng)建一個(gè)普通的java工程,該工程依賴tomcat的lib目錄下的兩個(gè)websocket相關(guān)的jar包,tomcat-websoket.jar,websocket-api.jar。
然后創(chuàng)建以下兩個(gè)類:
newWebsocket.SocketConfig
public class SocketConfig implements ServerApplicationConfig {
@Override
public Set<ServerEndpointConfig> getEndpointConfigs(Set<Class<? extends Endpoint>> scanned) {
Set<ServerEndpointConfig> result = new HashSet<>();
if (scanned.contains(EchoEndpoint.class)) {
result.add(ServerEndpointConfig.Builder.create(EchoEndpoint.class,
"/websocket/echo").build());
}
return result;
}
@Override
public Set<Class<?>> getAnnotatedEndpointClasses(Set<Class<?>> scanned) {
Set<Class<?>> results = new HashSet<>();
for (Class<?> clazz : scanned) {
if (clazz.getPackage().getName().startsWith("newWebsocket.")) {
results.add(clazz);
}
}
return results;
}
}
newWebsocket.EchoEndpoint
public class EchoEndpoint extends Endpoint {
@Override
public void onOpen(Session session, EndpointConfig endpointConfig) {
RemoteEndpoint.Basic remoteEndpointBasic = session.getBasicRemote();
session.addMessageHandler(new EchoMessageHandlerText(remoteEndpointBasic));
}
private static class EchoMessageHandlerText implements MessageHandler.Partial<String> {
private final RemoteEndpoint.Basic remoteEndpointBasic;
private EchoMessageHandlerText(RemoteEndpoint.Basic remoteEndpointBasic) {
this.remoteEndpointBasic = remoteEndpointBasic;
}
@Override
public void onMessage(String message, boolean last) {
try {
if (remoteEndpointBasic != null) {
remoteEndpointBasic.sendText(message, last);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
其中,SocketConfig負(fù)責(zé)將EchoEndpoint注冊(cè)到容器中,并且和/websocket/echo這個(gè)路徑綁定。
EchoEndpoint則是業(yè)務(wù)邏輯。在onOpen方法中注冊(cè)了EchoMessageHandlerText這個(gè)Handler的實(shí)例,EchoMessageHandlerText的onMessage方法用于處理客戶端發(fā)送過來的信息。
build這個(gè)工程,最終會(huì)生成這樣一些.class文件。
newWebsocket
├── EchoEndpoint$1.class
├── EchoEndpoint.class
├── EchoEndpoint$EchoMessageHandlerText.class
└── SocketConfig.class
將newWebsocket目錄復(fù)制到tomcat的webapps/examples/WEB-INF/classes,SocketConfig類在tomcat啟動(dòng)時(shí)會(huì)被掃描到,隨后由容器調(diào)用SocketConfig實(shí)現(xiàn)的方法完成將EchoEndpoint注冊(cè)至容器的工作。
啟動(dòng)tomcat后,用上述提到的支持websocket特性的瀏覽器打開http://127.0.0.1:8080/,如果tomcat啟動(dòng)成功,會(huì)看到tomcat的歡迎頁面。不用管這個(gè),打開開發(fā)者調(diào)試工具,依次輸入以下javascript代碼
var ws = new WebSocket("ws://127.0.0.1:8080/examples/websocket/echo");
ws.onmessage = function(event) {console.log(event.data)};
ws.send("111");
請(qǐng)注意跨域限制,當(dāng)前瀏覽器必須打開127.0.0.1:8080域下的任意一個(gè)頁面,否則以上javascript代碼可能無法成功執(zhí)行
這里以firefox為例,如果看到類似下圖的調(diào)試頁面打印出"111",則表示websocket部署成功,且前端調(diào)用也正常。

0x02 抓包和協(xié)議規(guī)范對(duì)照分析
依靠純粹的http協(xié)議是無法實(shí)現(xiàn)websocket的,所以其背后必然有一套不同于http的應(yīng)用層協(xié)議作為支撐,該協(xié)議的標(biāo)準(zhǔn)文檔是RFC6455-The WebSocket Protocol。
接下來就根據(jù)實(shí)際抓包和標(biāo)準(zhǔn)文檔進(jìn)行比對(duì)來研究一下websocket在網(wǎng)絡(luò)應(yīng)用層的實(shí)現(xiàn)。由于我們的目的是研究websocket在服務(wù)端的底層實(shí)現(xiàn),為了方便,我們直接使用tomcat自帶的example來發(fā)起websocket請(qǐng)求。
首先是一條由客戶端發(fā)往/examples/websocket/echoProgrammatic的http GET請(qǐng)求。規(guī)范中提到,websocket通信由http請(qǐng)求發(fā)起握手。注意該請(qǐng)求中的Connection和Upgrade頭域的值,Upgrade頭域的值為websocket,表示客戶端希望將該次連接升級(jí)為一個(gè)websocket連接。
GET /examples/websocket/echoProgrammatic HTTP/1.1\r\n
Host: 192.168.0.101:8080\r\n
Connection: Upgrade\r\n
Pragma: no-cache\r\n
Cache-Control: no-cache\r\n
Upgrade: websocket\r\n
Origin: http://192.168.0.101:8080\r\n
Sec-WebSocket-Version: 13\r\n
User-Agent: Mozilla/5.0 (Linux; Android 6.0; H60-L01 Build/HDH60-L01) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.124 Mobile Safari/537.36\r\n
Accept-Encoding: gzip, deflate, sdch\r\n
Accept-Language: zh-CN,zh;q=0.8\r\n
Sec-WebSocket-Key: S4iljLdlI5qk3jpx2fHU4A==\r\n
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n
\r\n
[Full request URI: http://192.168.0.101:8080/examples/websocket/echoProgrammatic]
[HTTP request 1/1]
[Response in frame: 10]
HTTP/1.1 101 \r\n
Server: Apache-Coyote/1.1\r\n
Upgrade: websocket\r\n
Connection: upgrade\r\n
Sec-WebSocket-Accept: xwLDQrb5kzxpZDdeTcUd+7diXXU=\r\n
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15\r\n
Date: Sun, 09 Oct 2016 23:07:39 GMT\r\n
\r\n
[HTTP response 1/1]
[Time since request: 0.042990000 seconds]
[Request in frame: 9]
服務(wù)端隨即返回了101響應(yīng),并且沒有釋放該次tcp連接,這表示握手成功,websocket連接已經(jīng)建立完成。
接下來是數(shù)據(jù)幀(Data Framing)的抓包
WebSocket
1... .... = Fin: True
.100 .... = Reserved: 0x4
.... 0001 = Opcode: Text (1)
0... .... = Mask: False
.001 0100 = Payload length: 20
Payload
對(duì)照文檔中對(duì)數(shù)據(jù)幀的格式定義如下:
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 ... |
+---------------------------------------------------------------+
我們觀察一下這個(gè)幀,wireshark的提示已經(jīng)很清楚了,和文檔中的定義基本都能對(duì)應(yīng)上,唯一值得留意的是rsv1被置為了1,很奇怪,按照RFC6455,除非了其他約定,rsv1~3通常應(yīng)該置為0,那么這里的其他約定指什么,RFC6455沒提,那我們先放著,看一下payload
0000 f2 48 2d 4a 55 c8 2c 56 48 54 c8 4d 2d 2e 4e 4c .H-JU.,VHT.M-.NL
0010 4f 55 04 00 OU..
這里的問題就比較大了,按照文檔說的,這里應(yīng)該是我發(fā)送的信息的ascii字節(jié)碼,比如我發(fā)的是"hello",這里就應(yīng)該是"0x48 0x65 0x6c 0x6c 0x6f",但是眼下這個(gè)東西,連ascii都不是。
然而無論是作為服務(wù)端的tomcat,還是作為客戶端的chrome,明顯接受了這種奇怪的編碼,并且確實(shí)無誤的得到了我發(fā)送的信息。這里一定還是遵循其他的一些我尚未了解到的著某種公共的標(biāo)準(zhǔn)協(xié)議。
那么接下來我應(yīng)該怎么做?像這種奇怪的碼流拿去問google肯定也問不出什么名堂,還剩兩條路:
繼續(xù)研究RFC6455的剩余部分尋找蛛絲馬跡;- READ THE FUCKING CODE!!!。
于是毫不猶豫選擇后者。
0x03 對(duì)照源碼尋找解答(雖然已經(jīng)被標(biāo)題劇透了,為了便于搜索的無奈選擇)
這里省略下載apache tomcat的源碼以及編輯構(gòu)建的過程,這是個(gè)老牌開源項(xiàng)目了。
為了找到以上奇怪碼流的成因,跟蹤返回消息是個(gè)比較容易的切入點(diǎn),所以將斷點(diǎn)定在EchoEndpoint.EchoMessageHandlerText.onMessage方法的入口是個(gè)比較好的選擇。
于是用debug模式啟動(dòng)tomcat并啟動(dòng)遠(yuǎn)程調(diào)試,使用websocket的example頁面發(fā)送一條websocket請(qǐng)求,采用單步跟蹤,很快定位到WsRemoteEndpointImplBase.sendMessageBlock(byte opCode, ByteBuffer payload, boolean last, long timeoutExpiry)這個(gè)方法里的messageParts = transformation.sendMessagePart(messageParts);這行語句,對(duì)我們的消息體做了手腳。而transformation這個(gè)變量也確實(shí)起了一個(gè)一看就知道是干這種事情的名字。
transformation他的類型Transformation實(shí)際上是一個(gè)java接口,單步跟蹤后發(fā)現(xiàn)實(shí)際上進(jìn)入到PerMessageDeflate這個(gè)類的sendMessagePart(List<MessagePart> uncompressedParts)方法中。這個(gè)方法實(shí)際上做的事情是調(diào)用jdk里的Deflater的deflate方法對(duì)payload數(shù)據(jù)做壓縮處理。到這里我們就明白了,之所以抓包看到的數(shù)據(jù)和我們實(shí)際發(fā)送的不一樣,是因?yàn)樽隽薲eflate壓縮。而因?yàn)槭腔诠驳乃惴ǎ栽诳蛻舳四沁?,也可以通過同樣的算法還原出原信息。
那么下面要解決的問題就是,客戶端和服務(wù)端之間是如何協(xié)商出使用deflate算法對(duì)數(shù)據(jù)進(jìn)行壓縮的。還是從源碼中找答案,切入點(diǎn)是WsRemoteEndpointImplBase中transformation這個(gè)成員變量什么時(shí)候被實(shí)例化。經(jīng)過一番順藤摸瓜我們發(fā)現(xiàn)這個(gè)transformation的出生地位于org.apache.tomcat.websocket.server.UpgradeUtil的List<Transformation> createTransformations( List<Extension> negotiatedExtensions)這個(gè)方法。仔細(xì)觀察邏輯發(fā)現(xiàn)這個(gè)方法構(gòu)造transformation實(shí)例的過程和唯一的入?yún)?code>negotiatedExtensions中的一個(gè)叫做permessage-deflate的所謂的name有密切關(guān)系。
那么這個(gè)negotiatedExtensions是什么東西,他來自哪里?繼續(xù)往上翻代碼實(shí)在是有點(diǎn)暈了,猜一下吧,UpgradeUtil這個(gè)類名字以及唯一調(diào)用這個(gè)方法的public static void doUpgrade(WsServerContainer sc, HttpServletRequest req, HttpServletResponse resp, ServerEndpointConfig sec, Map<String,String> pathParams)這個(gè)方法名,我猜測(cè)這里應(yīng)該是由第一個(gè)http請(qǐng)求發(fā)起websocket通信的地方,打個(gè)斷點(diǎn)驗(yàn)證了一下確實(shí)如此,第一個(gè)http請(qǐng)求過來的時(shí)候即命中了這個(gè)方法?!皀egotiatedExtensions”從名字看是協(xié)商擴(kuò)展的意思,按照以往研究其他協(xié)議的經(jīng)驗(yàn)看,所謂協(xié)商通常是在握手的請(qǐng)求和響應(yīng)過程中完成的,那么permessage-deflate應(yīng)該是握手請(qǐng)求中的某個(gè)參數(shù),掃了一眼抓包信息果然如此,Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits這個(gè)頭域里面帶的不就是嗎?所以我們目前可以這么猜測(cè),客戶端和服務(wù)端之間就是根據(jù)首次http請(qǐng)求中的Sec-WebSocket-Extensions這個(gè)頭域中的permessage-deflate這個(gè)參數(shù)來協(xié)商是否對(duì)傳輸數(shù)據(jù)進(jìn)行deflate壓縮的。
下面的問題就是怎么驗(yàn)證我的這猜測(cè)了??蛻舳诉@邊比較頭疼,javascript里的WebSocket類并沒有更多可供配置的參數(shù),是否支持deflate擴(kuò)展似乎完全是各家瀏覽器內(nèi)部實(shí)現(xiàn)自己說了算。嘗試了chrome和firefox發(fā)現(xiàn)都是默認(rèn)開啟該permessage-deflate,并且沒有找到辦法關(guān)閉,Safari和Edge一個(gè)手頭沒有,另一個(gè)被我玩壞了處于罷工狀態(tài),最后發(fā)現(xiàn)能用上手的只有IE不支持permessage-deflate擴(kuò)展。被吐槽嫌棄了一萬年想不到也有發(fā)揮余熱的一天————以不支持某一特性這種方式。

于是用IE發(fā)起websocket請(qǐng)求以后我們抓包看的是這樣的結(jié)果:
GET /examples/websocket/echoProgrammatic HTTP/1.1\r\n
Origin: http://192.168.0.103:18080\r\n
Sec-WebSocket-Key: qd6f1YwxnAfGrkqFIy5kFw==\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Version: 13\r\n
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko\r\n
Host: 192.168.0.103:18080\r\n
Cache-Control: no-cache\r\n
\r\n
[Full request URI: http://192.168.0.103:18080/examples/websocket/echoProgrammatic]
[HTTP request 1/1]
[Response in frame: 7]
HTTP/1.1 101 \r\n
Upgrade: websocket\r\n
Connection: upgrade\r\n
Sec-WebSocket-Accept: 13FxZb9VlaaWo+9kYEgPZDKfwGg=\r\n
Date: Sat, 14 Jan 2017 15:52:17 GMT\r\n
\r\n
[HTTP response 1/1]
[Time since request: 0.029400000 seconds]
[Request in frame: 5]
可以看到在第一個(gè)http請(qǐng)求中沒有Sec-WebSocket-Extensions頭域,返回的101響應(yīng)也沒有,說明沒有對(duì)permessage-deflate特性進(jìn)行協(xié)商。
接下來是數(shù)據(jù)幀抓包:
WebSocket
1... .... = Fin: True
.000 .... = Reserved: 0x0
.... 0001 = Opcode: Text (1)
0... .... = Mask: False
.001 0010 = Payload length: 18
Payload
PayLoad:
0000 48 65 72 65 20 69 73 20 61 20 6d 65 73 73 61 67 Here is a messag
0010 65 21 e!
果然以ascii碼流的形式傳輸了,并且注意到rsv1標(biāo)志位也被置0了。
單步跟蹤代碼的執(zhí)行過程可以發(fā)現(xiàn),在這樣的情況下List<Transformation> createTransformations( List<Extension> negotiatedExtensions)入?yún)⑹且粋€(gè)空的列表,并且返回的也是一個(gè)空的列表,這會(huì)導(dǎo)致在后續(xù)的一系列的初始化過程當(dāng)中,transformation被初始化為UnmaskTransformation這類的實(shí)例。我們來看看這個(gè)類的List<MessagePart> sendMessagePart(List<MessagePart> messageParts)方法的實(shí)現(xiàn):
@Override
public List<MessagePart> sendMessagePart(List<MessagePart> messageParts) {
// NO-OP send so simply return the message unchanged.
return messageParts;
}
呵呵。。。
接下來驗(yàn)證服務(wù)端,假設(shè)服務(wù)端不支持permessage-deflate,即使客戶端的http請(qǐng)求里面帶了Sec-WebSocket-Extensions頭域,擴(kuò)展協(xié)商也會(huì)失敗。既然有源碼,很容易就可以將tomcat改造為我們需要的樣子,比如在List<Transformation> createTransformations(List<Extension> negotiatedExtensions)這個(gè)方法的開頭加入這樣一段代碼:
for(Extension extension: negotiatedExtensions) {
if (PerMessageDeflate.NAME.equals(extension.getName())) {
negotiatedExtensions.remove(extension);
break;
}
}
即將請(qǐng)求中的permessage-deflate擴(kuò)展參數(shù)移除掉。重新編譯tomcat后重啟服務(wù),用chrome發(fā)起websocket通信,抓包如下:
GET /examples/websocket/echoProgrammatic HTTP/1.1\r\n
Host: 192.168.163.128:18080\r\n
Connection: Upgrade\r\n
Pragma: no-cache\r\n
Cache-Control: no-cache\r\n
Upgrade: websocket\r\n
Origin: http://192.168.163.128:18080\r\n
Sec-WebSocket-Version: 13\r\n
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36\r\n
Accept-Encoding: gzip, deflate, sdch\r\n
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6\r\n
Sec-WebSocket-Key: N+GWswsViw18TfSpryLcVw==\r\n
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n
\r\n
[Full request URI: http://192.168.163.128:18080/examples/websocket/echoProgrammatic]
[HTTP request 1/1]
[Response in frame: 52]
HTTP/1.1 101 \r\n
Upgrade: websocket\r\n
Connection: upgrade\r\n
Sec-WebSocket-Accept: 4tRMuDpE6WErH7Gc0XqTBmfN/7U=\r\n
Date: Mon, 16 Jan 2017 16:10:14 GMT\r\n
\r\n
[HTTP response 1/1]
[Time since request: 0.380323000 seconds]
[Request in frame: 49]
我們看到服務(wù)器無視了請(qǐng)求中的Sec-WebSocket-Extensions頭域,假裝不支持permessage-deflate特性一樣返回了和ie瀏覽器類似的101響應(yīng),這意味著permessage-deflate特性在協(xié)議層面協(xié)商失敗。于是即使客戶端是chrome,大家也還是用ascii碼流的形式傳輸數(shù)據(jù)
1... .... = Fin: True
.000 .... = Reserved: 0x0
.... 0001 = Opcode: Text (1)
0... .... = Mask: False
.001 0010 = Payload length: 18
Payload
0000 48 65 72 65 20 69 73 20 61 20 6d 65 73 73 61 67 Here is a messag
0010 65 21 e!
在源碼實(shí)現(xiàn)層面上,我們了解了上文中最初的WebSocket的payload沒有采用ascii編碼的原因:是因?yàn)閔ttp握手過程中客戶端和服務(wù)端對(duì)permessage-deflate擴(kuò)展特性協(xié)商采用了deflate對(duì)payload做了壓縮編碼導(dǎo)致的。
0x04 相關(guān)標(biāo)準(zhǔn)
剖析完了代碼,我們最后還需要再找到相應(yīng)的標(biāo)準(zhǔn)才能完成閉環(huán)。RFC6455當(dāng)中并沒有提及這個(gè)permessage-deflate,搜了一下發(fā)現(xiàn)相關(guān)標(biāo)準(zhǔn)位于RFC7692,一份對(duì)websocket的擴(kuò)展協(xié)議。在該協(xié)議的第7節(jié)專門對(duì)permessage-deflate擴(kuò)展做了規(guī)定,包括握手請(qǐng)求和響應(yīng)中應(yīng)用Sec-WebSocket-Extensions頭域?qū)ermessage-deflate相關(guān)參數(shù)的協(xié)商,以及規(guī)定,一旦采用permessage-deflate擴(kuò)展,則rsv1標(biāo)志位必須置為1。
The "Per-Message Compressed" bit, which indicates whether or not
the message is compressed. RSV1 is set for compressed messages
and unset for uncompressed messages.
至此,tomcat源碼實(shí)現(xiàn),實(shí)際抓包結(jié)果,和標(biāo)準(zhǔn)規(guī)范已經(jīng)完全能夠?qū)?yīng)上了。
0x05 后記
目前為止,我們了解到了websocket相關(guān)標(biāo)準(zhǔn)中的一些擴(kuò)展特性,以及tomcat對(duì)這些特性的實(shí)現(xiàn)方面的一些細(xì)節(jié),學(xué)習(xí)到了一些很有趣的課外知識(shí)。那么這些知識(shí)有什么實(shí)際用處呢?當(dāng)然有,比如某一天如果我們的實(shí)際業(yè)務(wù)涉及到websocket的應(yīng)用,在調(diào)試的過程中我們?nèi)绾斡^察websocket接口的數(shù)據(jù)流?由于permessage-deflate擴(kuò)展的影響,從抓包上幾乎無法觀察數(shù)據(jù)流,Chrome的調(diào)試工具能夠展現(xiàn)解碼后的ascii編碼的字符串

但是遇上二進(jìn)制的碼流也是無能為力,用IE系列倒是可以規(guī)避permessage-deflate的影響,但用IE不覺得跌份嗎?
現(xiàn)在有了新的選擇,我們可以定制自己的tomcat,在服務(wù)端屏蔽掉permessage-deflate擴(kuò)展,任意一個(gè)瀏覽器都可以進(jìn)行調(diào)試,用抓包工具就可以觀察碼流??梢裕@很GEEK!