netty5 HTTP協(xié)議棧淺析與實(shí)踐
閱讀目錄
1. 寫在前面的話
1.1. 關(guān)于netty example
1.2. 關(guān)于github項(xiàng)目
2. HTTP 協(xié)議知多少
2.1. GET請求
2.2. POST請求
2.3. HTTP POST Content-Type
3. netty HTTP 編解碼
3.1. netty 自帶 HTTP 編解碼器
3.2. HTTP GET 解析實(shí)踐
3.3. HTTP POST 解析實(shí)踐
4. 自定義 HTTP POST 的 message body 解碼器
4.1. HttpJsonDecoder
4.2. HttpProtobufDecoder
5. 聊聊開發(fā)中遇到的問題【推薦】
5.1. 關(guān)于內(nèi)存泄漏
5.1.1. netty 應(yīng)用計數(shù)對象
5.1.2. 如何規(guī)避內(nèi)存泄漏
5.2. 關(guān)于 HTTP 長連接
5.2.1. TCP KeepAlive 和 HTTP KeepAlive
5.2.2. 長連接方式中如何判斷數(shù)據(jù)發(fā)送完成
1. 說在前面的話
前段時間,工作上需要做一個針對視頻質(zhì)量的統(tǒng)計分析系統(tǒng),各端(PC端、移動端和 WEB端)將視頻質(zhì)量數(shù)據(jù)放在一個 HTTP 請求中上報到服務(wù)器,服務(wù)器對數(shù)據(jù)進(jìn)行解析、分揀后從不同的維度做實(shí)時和離線分析。(ps:這種活兒本該由統(tǒng)計部門去做的,但由于各種原因落在了我頭上,具體原因略過不講……)
先用個“概念圖”來描繪下整個系統(tǒng)的架構(gòu):
嗯,這個是真正的“概念圖”,因?yàn)槲乙呀?jīng)把大部分細(xì)節(jié)都屏蔽了,別笑,因?yàn)楸疚牡闹攸c(diǎn)只是整個架構(gòu)中的一小部分,就是上圖中紅框內(nèi)的 http server。
也許你會問,這不就是個 HTTP 服務(wù)器嗎,而且是只處理一個請求的 HTTP 服務(wù)器,搞個java web 項(xiàng)目在 Tomcat 中一啟動不就完事兒了,有啥好講的呀?。莫慌,且聽老夫慢慢道來為啥要用 netty HTTP 協(xié)議棧來實(shí)現(xiàn)這個接收轉(zhuǎn)發(fā)服務(wù)。
- 首先,接入服務(wù)需要支持10W+ tps,而 netty 的多線程模型和異步非阻塞的特性讓人很自然就會將它和高并發(fā)聯(lián)系起來。
- 其次,接入服務(wù)雖然使用 HTTP 協(xié)議,但顯然這并不是個 WEB 應(yīng)用,無需運(yùn)行在相對較重的 Tomcat 這種 WEB 容器上。
- 接著,在提供同等服務(wù)的情況下對比 netty HTTP 協(xié)議棧和 Tomcat HTTP 服務(wù),發(fā)現(xiàn)使用 netty 時在機(jī)器資源占用(如CPU使用率、內(nèi)存占用及上下文切換等)方面要優(yōu)于 Tomcat。
- 最后,netty 一直在說對 HTTP 協(xié)議提供了非常好的支持,因此想乘機(jī)檢驗(yàn)一下是否屬實(shí)。
基于以上幾點(diǎn)原因,老夫就決定使用 netty HTTP 協(xié)議棧開干啦~
本文并非純理論或純技術(shù)類文章,而是結(jié)合理論進(jìn)而實(shí)踐(雖然沒有特別深入的實(shí)踐),淺析 netty 的 HTTP 協(xié)議棧,并著重聊聊實(shí)踐中遇到的問題及解決方案。越往后越精彩哦!
1.1. 關(guān)于netty example
netty 官方提供了關(guān)于 HTTP 的例子,大伙兒可以在 netty 項(xiàng)目中查看。
https://github.com/netty/netty/tree/4.1/example/src/main/java/io/netty/example/http
https://github.com/netty/netty/tree/4.1/example/src/main/java/io/netty/example/http2
1.2. 關(guān)于github項(xiàng)目
本人在網(wǎng)上使用 “netty + HTTP” 的關(guān)鍵字搜索了下,發(fā)現(xiàn)大部分都是原搬照抄 netty 項(xiàng)目中的 example,很少有“原創(chuàng)性”的實(shí)踐,也幾乎沒有看到實(shí)現(xiàn)一個相對完整的 HTTP 服務(wù)器的項(xiàng)目(比如如何解析GET/POST請求、自定義 HTTP decoder、對 HTTP 長短連接的思考等等……),因此就自己整理了一個相對完整一點(diǎn)的項(xiàng)目,項(xiàng)目地址https://github.com/cyfonly/netty-http,該項(xiàng)目實(shí)現(xiàn)了基于 netty5 的 HTTP 服務(wù)端,暫時實(shí)現(xiàn)以下功能:
- HTTP GET 請求解析與響應(yīng)
- HTTP POST 請求解析與響應(yīng),提供 application/json、application/x-www-form-urlencoded、multipart/form-data 三種常見 Content-Type 的 message body 解析示例
- HTTP decoder實(shí)現(xiàn),提供 POST 請求 message body 解碼器的 HttpJsonDecoder 及 HttpProtobufDecoder 實(shí)現(xiàn)示例
- 作為服務(wù)端接收瀏覽器文件上傳及保存
將來可能會繼續(xù)實(shí)現(xiàn)的功能有:
- 命名空間
- uri路由
- chunked 傳輸編碼
如果你也打算使用 netty 來實(shí)現(xiàn) HTTP 服務(wù)器,相信這個項(xiàng)目和本文對你是有較大幫助的!
好了,閑話不多說,下面正式進(jìn)入正題。
2. HTTP 協(xié)議知多少
要通過 netty 實(shí)現(xiàn) HTTP 服務(wù)端(或者客戶端),首先你得了解 HTTP 協(xié)議【1】。
HTTP 協(xié)議是請求/響應(yīng)式的協(xié)議,客戶端需要發(fā)送一個請求,服務(wù)器才會返回響應(yīng)內(nèi)容。例如在瀏覽器上輸入一個網(wǎng)址按下 Enter,或者提交一個 Form 表單,瀏覽器就會發(fā)送一個請求到服務(wù)器,而打開的網(wǎng)頁的內(nèi)容,就是服務(wù)器返回的響應(yīng)。
下面講下 HTTP 請求和響應(yīng)包含的內(nèi)容。
HTTP 請求有很多種 method,最常用的就是 GET 和 POST,每種 method 的請求之間會有細(xì)微的區(qū)別。下面分別分析一下 GET 和 POST 請求。
2.1. GET請求
下面是瀏覽器對 http://localhost:8081/test?name=XXG&age=23 的 GET 請求時發(fā)送給服務(wù)器的數(shù)據(jù):
可以看出請求包含 request line 和 header 兩部分。其中 request line 中包含 method(例如 GET、POST)、request uri 和 protocol version 三部分,三個部分之間以空格分開。request line 和每個 header 各占一行,以換行符 CRLF(即 \r\n)分割。
2.2. POST請求
下面是瀏覽器對 http://localhost:8081/test 的 POST 請求時發(fā)送給服務(wù)器的數(shù)據(jù),同樣帶上參數(shù) name=XXG&age=23:
可以看出,上面的請求包含三個部分:request line、header、message,比之前的 GET 請求多了一個 message body,其中 header 和 message body 之間用一個空行分割。POST 請求的參數(shù)不在 URL 中,而是在 message body 中,header 中多了一項(xiàng) Content-Length 用于表示 message body 的字節(jié)數(shù),這樣服務(wù)器才能知道請求是否發(fā)送結(jié)束。這也就是 GET 請求和 POST 請求的主要區(qū)別。
HTTP 響應(yīng)和 HTTP 請求非常相似,HTTP 響應(yīng)包含三個部分:status line、header、massage body。其中 status line 包含 protocol version、狀態(tài)碼(status code)、reason phrase 三部分。狀態(tài)碼用于描述 HTTP 響應(yīng)的狀態(tài),例如 200 表示成功,404 表示資源未找到,500 表示服務(wù)器出錯。
在上面的 HTTP 響應(yīng)中,Header 中的 Content-Length 同樣用于表示 message body 的字節(jié)數(shù)。Content-Type 表示 message body 的類型,通常瀏覽網(wǎng)頁其類型是HTML,當(dāng)然還會有其他類型,比如圖片、視頻等。
2.3. HTTP POST Content-Type
HTTP/1.1 協(xié)議規(guī)定的 HTTP 請求方法有 OPTIONS、GET、HEAD、POST、PUT、DELETE、TRACE、CONNECT 這幾種。其中 POST 一般用來向服務(wù)端提交數(shù)據(jù),本文討論主要的幾種 POST 提交數(shù)據(jù)方式。
我們知道,HTTP 協(xié)議是以 ASCII 碼傳輸,建立在 TCP/IP 協(xié)議之上的應(yīng)用層規(guī)范。規(guī)范把 HTTP 請求分為三個部分:狀態(tài)行、請求頭、消息主體。類似于下面這樣:
- <method> <request-URL> <version>
- <headers>
- <entity-body>
協(xié)議規(guī)定 POST 提交的數(shù)據(jù)必須放在消息主體(entity-body)中,但協(xié)議并沒有規(guī)定數(shù)據(jù)必須使用什么編碼方式。實(shí)際上,開發(fā)者完全可以自己決定消息主體的格式,只要最后發(fā)送的 HTTP 請求滿足上面的格式就可以。
但是,數(shù)據(jù)發(fā)送出去,還要服務(wù)端解析成功才有意義。一般服務(wù)端語言如 php、python 等,以及它們的 framework,都內(nèi)置了自動解析常見數(shù)據(jù)格式的功能。服務(wù)端通常是根據(jù)請求頭(headers)中的 Content-Type 字段來獲知請求中的消息主體是用何種方式編碼,再對主體進(jìn)行解析。所以說到 POST 提交數(shù)據(jù)方案,包含了 Content-Type 和消息主體編碼方式 Charset 兩部分。下面就正式開始介紹它們。
2.3.1. application/x-www-form-urlencoded
這應(yīng)該是最常見的 POST 提交數(shù)據(jù)的方式了。瀏覽器的原生 Form 表單,如果不設(shè)置 enctype 屬性,那么最終就會以 application/x-www-form-urlencoded 方式提交數(shù)據(jù)。請求類似于下面這樣(無關(guān)的請求頭在本文中都省略掉了):
- POST http://www.example.com HTTP/1.1
- Content-Type: application/x-www-form-urlencoded;charset=utf-8
- title=test&sub%5B%5D=1&sub%5B%5D=2&sub%5B%5D=3
首先,Content-Type 被指定為 application/x-www-form-urlencoded;其次,提交的數(shù)據(jù)按照 key1=val1&key2=val2 的方式進(jìn)行編碼,key 和 val 都進(jìn)行了 URL 轉(zhuǎn)碼。大部分服務(wù)端語言都對這種方式有很好的支持。
很多時候,我們用 Ajax 提交數(shù)據(jù)時,也是使用這種方式。例如 JQuery 的 Ajax,Content-Type 默認(rèn)值都是 application/x-www-form-urlencoded;charset=utf-8 。
2.3.2. multipart/form-data
這又是一個常見的 POST 數(shù)據(jù)提交的方式。我們使用表單上傳文件時,必須讓 Form 的 enctyped 等于這個值。直接來看一個請求示例:
- POST http://www.example.com HTTP/1.1
- Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA
- ------WebKitFormBoundaryrGKCBY7qhFd3TrwA
- Content-Disposition: form-data; name="text"
- title
- ------WebKitFormBoundaryrGKCBY7qhFd3TrwA
- Content-Disposition: form-data; name="file"; filename="chrome.png"
- Content-Type: image/png
- PNG ... content of chrome.png ...
- ------WebKitFormBoundaryrGKCBY7qhFd3TrwA--
這個例子稍微復(fù)雜點(diǎn)。首先生成了一個 boundary 用于分割不同的字段,為了避免與正文內(nèi)容重復(fù),boundary 很長很復(fù)雜。然后 Content-Type 里指明了數(shù)據(jù)是以 mutipart/form-data 來編碼,本次請求的 boundary 是什么內(nèi)容。消息主體里按照字段個數(shù)又分為多個結(jié)構(gòu)類似的部分,每部分都是以 –boundary 開始,緊接著內(nèi)容描述信息,然后是回車,最后是字段具體內(nèi)容(文本或二進(jìn)制)。如果傳輸?shù)氖俏募?,還要包含文件名和文件類型信息。消息主體最后以 –boundary– 標(biāo)示結(jié)束。
這種方式一般用來上傳文件,各大服務(wù)端語言對它也有著良好的支持。
上面提到的這兩種 POST 數(shù)據(jù)的方式,都是瀏覽器原生支持的,而且現(xiàn)階段原生 Form 表單也只支持這兩種方式。但是隨著越來越多的 Web 站點(diǎn),尤其是 WebApp,全部使用 Ajax 進(jìn)行數(shù)據(jù)交互之后,我們完全可以定義新的數(shù)據(jù)提交方式,給開發(fā)帶來更多便利。
2.3.3. application/json
application/json 這個 Content-Type 作為響應(yīng)頭大家肯定不陌生。實(shí)際上,現(xiàn)在越來越多的人把它作為請求頭,用來告訴服務(wù)端消息主體是序列化后的 JSON 字符串。由于 JSON 規(guī)范的流行,除了低版本 IE 之外的各大瀏覽器都原生支持 JSON.stringify,服務(wù)端語言也都有處理 JSON 的函數(shù),使用 JSON 不會遇上什么麻煩。
JSON 格式支持比鍵值對復(fù)雜得多的結(jié)構(gòu)化數(shù)據(jù),這一點(diǎn)也很有用,當(dāng)需要提交的數(shù)據(jù)層次非常深,就可以考慮把數(shù)據(jù) JSON 序列化之后來提交的。
- var data = {'title':'test', 'sub' : [1,2,3]};
- $http.post(url, data).success(function(result) {
- ...
- });
最終發(fā)送的請求是:
- POST http://www.example.com HTTP/1.1
- Content-Type: application/json;charset=utf-8
- {"title":"test","sub":[1,2,3]}
這種方案,可以方便的提交復(fù)雜的結(jié)構(gòu)化數(shù)據(jù),特別適合 RESTful 的接口。各大抓包工具如 Chrome 自帶的開發(fā)者工具、Fiddler,都會以樹形結(jié)構(gòu)展示 JSON 數(shù)據(jù),非常友好。
其他幾種 Content-Type 就不一一詳細(xì)介紹了,感興趣的童鞋請自行了解。下面進(jìn)入 netty 支持 HTTP 協(xié)議的源碼分析階段。
3. netty HTTP 編解碼
要通過 netty 處理 HTTP 請求,需要先進(jìn)行編解碼。
3.1. netty 自帶 HTTP 編解碼器
netty5 提供了對 HTTP 協(xié)議的幾種編解碼器:
3.1.1. HttpRequestDecoder
- Decodes ByteBuf into HttpRequest and HttpContent.
即把 ByteBuf 解碼到 HttpRequest 和 HttpContent。
3.1.2. HttpResponseEncoder
- Encodes an HttpResponse or an HttpContent into a ByteBuf.
即把 HttpResponse 或 HttpContent 編碼到 ByteBuf。
3.1.3. HttpServerCodec
- A combination of HttpRequestDecoder and HttpResponseEncoder which enables easier server side HTTP implementation.
即 HttpRequestDecoder 和 HttpResponseEncoder 的結(jié)合。
因此,基于 netty 實(shí)現(xiàn) HTTP 服務(wù)端時,需要在 ChannelPipeline 中加上以上編解碼器:
- ch.pipeline().addLast("codec",new HttpServerCodec())
或者
- ch.pipeline().addLast("decoder",new HttpRequestDecoder())
- .addLast("encoder",new HttpResponseEncoder())
然而,以上編解碼器只能夠支持部分 HTTP 請求解析,比如 HTTP GET請求所傳遞的參數(shù)是包含在 uri 中的,因此通過 HttpRequest 既能解析出請求參數(shù)。但是,對于 HTTP POST 請求,參數(shù)信息是放在 message body 中的(對應(yīng)于 netty 來說就是 HttpMessage),所以以上編解碼器并不能完全解析 HTTP POST請求。
這種情況該怎么辦呢?別慌,netty 提供了一個 handler 來處理。
3.1.4. HttpObjectAggregator
- A ChannelHandler that aggregates an HttpMessage and its following HttpContent into a single FullHttpRequest or FullHttpResponse
- (depending on if it used to handle requests or responses) with no following HttpContent.
- It is useful when you don't want to take care of HTTP messages whose transfer encoding is 'chunked'.
即通過它可以把 HttpMessage 和 HttpContent 聚合成一個 FullHttpRequest 或者 FullHttpResponse (取決于是處理請求還是響應(yīng)),而且它還可以幫助你在解碼時忽略是否為“塊”傳輸方式。
因此,在解析 HTTP POST 請求時,請務(wù)必在 ChannelPipeline 中加上 HttpObjectAggregator。(具體細(xì)節(jié)請自行查閱代碼)
當(dāng)然,netty 還提供了其他 HTTP 編解碼器,有些涉及到高級應(yīng)用(較復(fù)雜的應(yīng)用),在此就不一一解釋了,以上只是介紹netty HTTP 協(xié)議棧最基本的編解碼器(切合文章主題——淺析)。
3.2. HTTP GET 解析實(shí)踐
上面提到過,HTTP GET 請求的參數(shù)是包含在 uri 中的,可通過以下方式解析出 uri:
- HttpRequest request = (HttpRequest) msg;
- String uri = request.uri();
特別注意的是,用瀏覽器發(fā)起 HTTP 請求時,常常會被 uri = "/favicon.ico" 所干擾,因此最好對其特殊處理:
- if(uri.equals(FAVICON_ICO)){
- return;
- }
接下來就是解析 uri 了。這里需要用到 QueryStringDecoder:
- Splits an HTTP query string into a path string and key-value parameter pairs.
- This decoder is for one time use only. Create a new instance for each URI:
- QueryStringDecoder decoder = new QueryStringDecoder("/hello?recipient=world&x=1;y=2");
- assert decoder.getPath().equals("/hello");
- assert decoder.getParameters().get("recipient").get(0).equals("world");
- assert decoder.getParameters().get("x").get(0).equals("1");
- assert decoder.getParameters().get("y").get(0).equals("2");
- This decoder can also decode the content of an HTTP POST request whose
- content type is application/x-www-form-urlencoded:
- QueryStringDecoder decoder = new QueryStringDecoder("recipient=world&x=1;y=2", false);
- ...
從上面的描述可以看出,QueryStringDecoder 的作用就是把 HTTP uri 分割成 path 和 key-value 參數(shù)對,也可以用來解碼 Content-Type = "application/x-www-form-urlencoded" 的 HTTP POST。特別注意的是,該 decoder 僅能使用一次。
解析代碼如下:
- String uri = request.uri();
- HttpMethod method = request.method();
- if(method.equals(HttpMethod.GET)){
- QueryStringDecoder queryDecoder = new QueryStringDecoder(uri, Charsets.toCharset(CharEncoding.UTF_8));
- Map<String, List<String>> uriAttributes = queryDecoder.parameters();
- //此處僅打印請求參數(shù)(你可以根據(jù)業(yè)務(wù)需求自定義處理)
- for (Map.Entry<String, List<String>> attr : uriAttributes.entrySet()) {
- for (String attrVal : attr.getValue()) {
- System.out.println(attr.getKey() + "=" + attrVal);
- }
- }
- }
3.3. HTTP POST 解析實(shí)踐
如3.1.4小結(jié)所說的那樣,解析 HTTP POST 請求的 message body,一定要使用 HttpObjectAggregator。但是,是否一定要把 msg 轉(zhuǎn)換成 FullHttpRequest 呢?答案是否定的,且往下看。
首先解釋下 FullHttpRequest 是什么:
- Combinate the HttpRequest and FullHttpMessage, so the request is a complete HTTP request.
即 FullHttpRequest 包含了 HttpRequest 和 FullHttpMessage,是一個 HTTP 請求的完全體。
而把 msg 轉(zhuǎn)換成 FullHttpRequest 的方法很簡單:
- FullHttpRequest fullRequest = (FullHttpRequest) msg;
接下來就是分幾種 Content-Type 進(jìn)行解析了。
3.3.1. 解析 application/json
處理 JSON 格式是非常方便的,我們只需要將 msg 轉(zhuǎn)換成 FullHttpRequest,然后將其 content 反序列化成 JSONObject 對象即可,如下:
- FullHttpRequest fullRequest = (FullHttpRequest) msg;
- String jsonStr = fullRequest.content().toString(Charsets.toCharset(CharEncoding.UTF_8));
- JSONObject obj = JSON.parseObject(jsonStr);
- for(Entry<String, Object> item : obj.entrySet()){
- System.out.println(item.getKey()+"="+item.getValue().toString());
- }
3.3.2. 解析 application/x-www-form-urlencoded
解析此類型有兩種方法,一種是使用 QueryStringDecoder,另外一種就是使用 HttpPostRequestDecoder。
方法一:3.2節(jié)中講 QueryStringDecoder 時提到:QueryStringDecoder 可以用來解碼 Content-Type = "application/x-www-form-urlencoded" 的 HTTP POST。因此我們可以用它來解析 message body,剩下的處理就跟 HTTP GET沒什么兩樣了:
- FullHttpRequest fullRequest = (FullHttpRequest) msg;
- String jsonStr = fullRequest.content().toString(Charsets.toCharset(CharEncoding.UTF_8));
- QueryStringDecoder queryDecoder = new QueryStringDecoder(jsonStr, false);
- Map<String, List<String>> uriAttributes = queryDecoder.parameters();
- for (Map.Entry<String, List<String>> attr : uriAttributes.entrySet()) {
- for (String attrVal : attr.getValue()) {
- System.out.println(attr.getKey()+"="+attrVal);
- }
- }
方法二:使用 HttpPostRequestDecoder 解析時,無需先將 msg 轉(zhuǎn)換成 FullHttpRequest。
我們先來了解下 HttpPostRequestDecoder :
- public HttpPostRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
- if (factory == null) {
- throw new NullPointerException("factory");
- }
- if (request == null) {
- throw new NullPointerException("request");
- }
- if (charset == null) {
- throw new NullPointerException("charset");
- }
- // Fill default values
- if (isMultipart(request)) {
- decoder = new HttpPostMultipartRequestDecoder(factory, request, charset);
- } else {
- decoder = new HttpPostStandardRequestDecoder(factory, request, charset);
- }
- }
由它的定義可知,它的內(nèi)部實(shí)現(xiàn)其實(shí)有兩種方式,一種是針對 multipart 類型的解析,一種是普通類型的解析。這兩種方式的具體實(shí)現(xiàn)中,我把它們相同的代碼提取出來,如下:
- if (request instanceof HttpContent) {
- // Offer automatically if the given request is als type of HttpContent
- offer((HttpContent) request);
- } else {
- undecodedChunk = buffer();
- parseBody();
- }
由于我們使用過 HttpObjectAggregator, request 都是 HttpContent 類型,因此會 Offer automatically,我們就不必自己手動去 offer 了,也不用處理 Chunk,所以使用 HttpObjectAggregator 確實(shí)是帶來了很多簡便的。
好了,接下來就是使用 HttpPostRequestDecoder 來解析了,直接上代碼:
- HttpRequest request = (HttpRequest) msg;
- HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(factory, request, Charsets.toCharset(CharEncoding.UTF_8));
- List<InterfaceHttpData> datas = decoder.getBodyHttpDatas();
- for (InterfaceHttpData data : datas) {
- if(data.getHttpDataType() == HttpDataType.Attribute) {
- Attribute attribute = (Attribute) data;
- System.out.println(attribute.getName() + "=" + attribute.getValue());
- }
- }
是不是很簡單?沒錯。但是這里有點(diǎn)我要說明下, InterfaceHttpData 是一個interface,沒有 API 可以直接拿到它的 value。那怎么辦呢?莫方,在它的類內(nèi)部定義了個枚舉類型,如下:
- enum HttpDataType {
- Attribute, FileUpload, InternalAttribute
- }
這種情況下它是 Attribute 類型,因此你轉(zhuǎn)換一下就能拿到值了。好奇的你可能會問,除 Attribute 外,其他兩個是什么時候用呢?沒錯,接下來馬上就講 FileUpload,至于 InternalAttribute 嘛,老夫就不多說啦,有興趣可以自己去研究了哈~
3.3.3. 解析 multipart/form-data (文件上傳)
上面說到了 FileUpload,那在這里就來說說如何使用 netty HTTP 協(xié)議棧實(shí)現(xiàn)文件上傳和保存功能。
這里依然使用 HttpPostRequestDecoder,廢話就不多少了,直接上代碼:
- DiskFileUpload.baseDirectory = "/data/fileupload/";
- HttpRequest request = (HttpRequest) msg;
- HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(factory, request, Charsets.toCharset(CharEncoding.UTF_8));
- List<InterfaceHttpData> datas = decoder.getBodyHttpDatas();
- for (InterfaceHttpData data : datas) {
- if(data.getHttpDataType() == HttpDataType.FileUpload) {
- FileUpload fileUpload = (FileUpload) data;
- String fileName = fileUpload.getFilename();
- if(fileUpload.isCompleted()) {
- //保存到磁盤
- StringBuffer fileNameBuf = new StringBuffer();
- fileNameBuf.append(DiskFileUpload.baseDirectory).append(fileName);
- fileUpload.renameTo(new File(fileNameBuf.toString()));
- }
- }}
至于效果,你可以直接在本地起個服務(wù)搞個簡單的頁面,向服務(wù)器傳個文件就行了。如果你很懶,直接用下面的HTML代碼改改將就著用吧:
- <form action="http://localhost:8080" method="post" enctype ="multipart/form-data">
- <input id="File1" runat="server" name="UpLoadFile" type="file" />
- <input type="submit" name="Button" value="上傳" id="Button" />
- </form>
至于其他類型的 Method、其他類型的 Content-Type,我也不打算細(xì)無巨細(xì)一一給大伙兒詳細(xì)講解了,看看上面羅列的,其實(shí)都很簡單是不是?
上面說的都是 netty 自己實(shí)現(xiàn)的東西,下面就來講講如何實(shí)現(xiàn)一個簡單的 HTTP decoder。
4. 自定義 HTTP POST 的 message body 解碼器
關(guān)于解碼器,我也不打算實(shí)現(xiàn)很復(fù)雜很牛逼的,只是寫了兩個粗糙的 decoder,一個是帶參數(shù)的一個是不帶參數(shù)的。既然是淺析,那就下面就簡單的聊聊。
如果你要實(shí)現(xiàn)一個頂層解碼器,就要繼承 MessageToMessageDecoder 并重寫其 decode 方法。MessageToMessageDecoder 繼承了 ChannelHandlerAdapter,也就是說解碼器其實(shí)就是一個 handler,只不過是專門用來做解碼的事情。下面我們來看看它重寫的 channelRead 方法:
- @Override
- public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
- RecyclableArrayList out = RecyclableArrayList.newInstance();
- try {
- if (acceptInboundMessage(msg)) {
- @SuppressWarnings("unchecked")
- I cast = (I) msg;
- try {
- decode(ctx, cast, out);
- } finally {
- ReferenceCountUtil.release(cast);
- }
- } else {
- out.add(msg);
- }
- } catch (DecoderException e) {
- throw e;
- } catch (Exception e) {
- throw new DecoderException(e);
- } finally {
- int size = out.size();
- for (int i = 0; i < size; i ++) {
- ctx.fireChannelRead(out.get(i));
- }
- out.recycle();
- }
- }
其中 decode 方法是你實(shí)現(xiàn) decoder 時需要重寫的,經(jīng)過解碼之后,會調(diào)用 ctx.fireChannelRead() 將 out 傳遞給給下一個 handler 執(zhí)行相關(guān)邏輯。
4.1. HttpJsonDecoder
從名字可以看出,這是個針對 message body 為 JsonString 的解碼器。處理過程很簡單,只需要把 HTTP 請求的 content (即 ByteBuf)的可讀字節(jié)轉(zhuǎn)換成 JSONObject 對象,如下:
- @Override
- protected void decode(ChannelHandlerContext ctx, HttpRequest msg, List<Object> out) throws Exception {
- FullHttpRequest fullRequest = (FullHttpRequest) msg;
- ByteBuf content = fullRequest.content();
- int length = content.readableBytes();
- byte[] bytes = new byte[length];
- for(int i=0; i<length; i++){
- bytes[i] = content.getByte(i);
- }
- try{
- JSONObject obj = JSON.parseObject(new String(bytes));
- out.add(obj);
- }catch(ClassCastException e){
- throw new CodecException("HTTP message body is not a JSONObject");
- }
- }
使用方法也很簡單,在 Server 的 HttpServerCodec() 和 HttpObjectAggregator() 后面加上:
- .addLast("jsonDecoder", new HttpJsonDecoder())
然后在業(yè)務(wù) handler channelRead方法中使用即可:
- if(msg instanceof JSONObject){
- JSONObject obj = (JSONObject) msg;
- ......
- }
4.2. HttpProtobufDecoder
這是一個帶參數(shù)的 decoder,用來解析使用 protobuf 序列化后的 message body。使用的時候需要傳遞 MessageLite 進(jìn)來,直接上代碼:
- private final MessageLite prototype;
- public HttpProtobufDecoder(MessageLite prototype){
- if (prototype == null) {
- throw new NullPointerException("prototype");
- }
- this.prototype = prototype.getDefaultInstanceForType();
- }
- @Override
- protected void decode(ChannelHandlerContext ctx, HttpRequest msg, List<Object> out) {
- FullHttpRequest fullRequest = (FullHttpRequest) msg;
- ByteBuf content = fullRequest.content();
- int length = content.readableBytes();
- byte[] bytes = new byte[length];
- for(int i=0; i<length; i++){
- bytes[i] = content.getByte(i);
- }
- try {
- out.add(prototype.getParserForType().parseFrom(bytes, 0, length));
- } catch (InvalidProtocolBufferException e) {
- throw new CodecException("HTTP message body is not " + prototype + "type");
- }
- }
使用方法跟 HttpJsonDecoder無異。此處以 protobuf 對象 UserProtobuf.User 為例,在 Server 的 HttpServerCodec() 和 HttpObjectAggregator() 后面加上:
- addLast("protobufDecoder", new HttpProtobufDecoder(UserProbuf.User.getDefaultInstance()))
然后在業(yè)務(wù) handler channelRead方法中使用即可:
- if(msg instanceof UserProbuf.User){
- UserProbuf.User user = (UserProbuf.User) msg;
- ......
- }
5. 聊聊開發(fā)中遇到的問題【推薦】
如果你沒有親自使用過 netty 卻說自己熟悉甚至精通 netty,我勸你千萬別這么做,因?yàn)槟愕哪槙淮蚰[的。netty 作為一個異步非阻塞的 IO 框架,它到底多牛逼在這就不多扯了,而作為一個首次使用 netty HTTP 協(xié)議棧的我來說,踩坑是必不可少的過程。當(dāng)然了,踩了坑就要填上,我還很樂意在這把我踩過的幾個坑給大家分享下,前車之鑒。
5.1. 關(guān)于內(nèi)存泄漏
首先說下經(jīng)歷的情況。在文章開篇提到的接收服務(wù),經(jīng)過多輪的單元測試幾乎沒發(fā)現(xiàn)什么問題,于是對于接下來的壓力測試我是自信滿滿。然而,當(dāng)我第一次跑壓測時就拋出一個異常,如下:
- [ERROR] 2016-07-24 15:25:46 [io.netty.util.internal.logging.Slf4JLogger:176] - LEAK: ByteBuf.release() was not called before it's garbage-collected. Enable advanced leak reporting to find out where the leak occurred. To enable advanced leak reporting, specify the JVM option '-Dio.netty.leakDetectionLevel=advanced' or call ResourceLeakDetector.setLevel() See http://netty.io/wiki/reference-counted-objects.html for more information.
著實(shí)讓我開心了一把,終于出現(xiàn)異常了!異常信息表達(dá)的是 “ByteBuf 在被 JVM GC 之前沒有調(diào)用 ByteBuf.release() ,啟用高級泄漏報告,找出發(fā)生泄漏的地方”,于是馬上google了一把,原來是從 netty4 開始,對象的生命周期由它們的引用計數(shù)(reference counts)管理,而不是由垃圾收集器(garbage collector)管理了。
要解決這個問題,先從源頭了解開始。
5.1.1. netty 引用計數(shù)對象【2】
對于 netty Inbound message,當(dāng) event loop 讀入了數(shù)據(jù)并創(chuàng)建了 ByteBuf,并用這個 ByteBuf 觸發(fā)了一個 channelRead() 事件時,那么管道(pipeline)中相應(yīng)的ChannelHandler 就負(fù)責(zé)釋放這個 buffer 。因此,處理接數(shù)據(jù)的 handler 應(yīng)該在它的 channelRead() 中調(diào)用 buffer 的 release(),如下:
- public void channelRead(ChannelHandlerContext ctx, Object msg) {
- ByteBuf buf = (ByteBuf) msg;
- try {
- ...
- } finally {
- buf.release();
- }
- }
而有時候,ByteBuf 會被一個 buffer holder 持有,它們都擴(kuò)展了一個公共接口 ByteBufHolder。正因如此, ByteBuf 并不是 netty 中唯一一種引用計數(shù)對象。由 decoder 生成的消息對象很可能也是引用計數(shù)對象,比如 HTTP 協(xié)議棧中的 HttpContent,因?yàn)樗矓U(kuò)展了 ByteBufHolder。
- public void channelRead(ChannelHandlerContext ctx, Object msg) {
- if (msg instanceof HttpRequest) {
- HttpRequest req = (HttpRequest) msg;
- ...
- }
- if (msg instanceof HttpContent) {
- HttpContent content = (HttpContent) msg;
- try {
- ...
- } finally {
- content.release();
- }
- }
- }
如果你抱有疑問,或者你想簡化這些釋放消息的工作,你可以使用 ReferenceCountUtil.release():
- public void channelRead(ChannelHandlerContext ctx, Object msg) {
- try {
- ...
- } finally {
- ReferenceCountUtil.release(msg);
- }
- }
或者可以考慮繼承 SimpleChannelHandler,它在所有接收消息的地方都調(diào)用了 ReferenceCountUtil.release(msg)。
對于 netty Outbound message,你的程序所創(chuàng)建的消息對象都由 netty 負(fù)責(zé)釋放,釋放的時機(jī)是在這些消息被發(fā)送到網(wǎng)絡(luò)之后。但是,在發(fā)送消息的過程中,如果有 handler 截獲(intercept)了你的發(fā)送請求并創(chuàng)建了一些中間對象,則這些 handler 要確保正確釋放這些中間對象。比如 encoder,此處不贅述。
通過以上信息,自然就很容易找到 OOM 問題的原因所在了。由于在處理 HTTP 請求過程中沒有釋放 ByteBuf,因此在代碼 finally 塊中加上 ReferenceCountUtil.release(msg) 就解決啦!
5.1.2. 如何規(guī)避內(nèi)存泄漏【3】
netty 提供了內(nèi)存泄漏的監(jiān)測機(jī)制,默認(rèn)就會從分配的 ByteBuf 里抽樣出大約 1% 的來進(jìn)行跟蹤。如果泄漏,就會打印5.1.1節(jié)中的異常信息,并提示你通過指定 JVM 選項(xiàng)
- -Dio.netty.leakDetectionLevel=advanced
來查看泄漏報告。泄漏年監(jiān)測有4個等級:
- 禁用(DISABLED) - 完全禁止泄露檢測,省點(diǎn)消耗。
- 簡單(SIMPLE) - 默認(rèn)等級,告訴我們?nèi)拥?1% 的 ByteBuf 是否發(fā)生了泄露,但總共一次只打印一次,看不到就沒有了。
- 高級(ADVANCED) - 告訴我們?nèi)拥?1% 的 ByteBuf 發(fā)生泄露的地方。每種類型的泄漏(創(chuàng)建的地方與訪問路徑一致)只打印一次。
- 偏執(zhí)(PARANOID) - 跟高級選項(xiàng)類似,但此選項(xiàng)檢測所有 ByteBuf,而不僅僅是取樣的那 1%。在高壓力測試時,對性能有明顯影響。
一般情況下我們采用 SIMPLE 級別即可。
5.2. 關(guān)于 HTTP 長連接
按照慣例,先說下開發(fā)中踩到的坑。
對于接收服務(wù),我采用的是 nginx + netty http,其中 nginx 配置如下(閹割隱藏版):
- upstream xxx.com{
- keepalive 32;
- server xxxx.xx.xx.xx:8080;
- }
- server{
- listen 80;
- server_name xxx.com;
- location / {
- proxy_next_upstream http_502 http_504 error timeout invalid_header;
- proxy_pass xxx.com;
- proxy_http_version 1.1;
- proxy_set_header Connection "";
- #proxy_set_header Host $host;
- #proxy_set_header X-Forwarded-For $remote_addr;
- #proxy_set_header REMOTE_ADDR $remote_addr;
- #proxy_set_header X-Real-IP $remote_addr;
- proxy_read_timeout 60s;
- client_max_body_size 1m;
- }
- error_page 500 502 503 504 /50x.html;
- location = /50x.html{
- root html;
- }
- }
然后編寫了一個簡單的 HttpClient 發(fā)送消息,如下(截取):
- OutputStream outStream = conn.getOutputStream();
- outStream.write(data);
- outStream.flush();
- outStream.close();
- if (conn.getResponseCode() == 200) {
- <span style="color: #ff0000;">BufferedReader in = new BufferedReader(new InputStreamReader((InputStream) conn.getInputStream(), "UTF-8"));</span>
- String msg = in.readLine();
- System.out.println("msg = " + msg);
- in.close();
- }
- conn.disconnect();
接著,正常發(fā)送 HTTP 請求到服務(wù)器,然而,老夫整整等了60多秒才接到響應(yīng)信息!而且每次都這樣!!
我首先懷疑是不是 ngxin 出問題了,有一個配置項(xiàng)立馬引起了我的懷疑,沒錯,就是上面紅色的那行 proxy_read_timeout 60s; 。為了驗(yàn)證,我首先把 60s 改成了 10s,效果很明顯,發(fā)送的請求 10 秒過一點(diǎn)就收到響應(yīng)了!更加徹底證明是 nginx 的鍋,我去掉了 nginx,讓客戶端直接發(fā)送請求給服務(wù)端。然而,蛋疼的事情出現(xiàn)了,客戶端竟然一直阻塞在 BufferedReader in = new BufferedReader(new InputStreamReader((InputStream) conn.getInputStream(), "UTF-8")); 處。這說明根本就不是 nginx 的問題啊!
我冷靜下來,review 了一下代碼同時 search 了相關(guān)資料,發(fā)現(xiàn)了一個小小的區(qū)別,在我的返回代碼中,對 ChannelFuture 少了對 CLOSE 事件的監(jiān)聽器:
- ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
于是,我加上 Listener 再試一下,馬上就得到響應(yīng)了!
就在這一刻明白了這是 HTTP 長連接的問題。首先從上面的 nginx 配置中可以看到,我顯式指定了 nginx 和 HTTP 服務(wù)器是用的 HTTP1.1 版本,HTTP1.1 版本默認(rèn)是長連接方式(也就是 Connection=Keep-Alive),而我在 netty HTTP 服務(wù)器中并沒有對長、短連接方式做區(qū)別處理,并且在 HttpResponse 響應(yīng)中并沒有顯式加上 Content-Length 頭部信息,恰巧 netty Http 協(xié)議棧并沒有在框架上做這件工作,導(dǎo)致服務(wù)端雖然把響應(yīng)消息發(fā)出去了,但是客戶端并不知道你是否發(fā)送完成了(即沒辦法判斷數(shù)據(jù)是否已經(jīng)發(fā)送完)。
于是,把響應(yīng)的處理完善一下即可:
- /**
- * 響應(yīng)報文處理
- * @param channel 當(dāng)前上下文Channel
- * @param status 響應(yīng)碼
- * @param msg 響應(yīng)消息
- * @param forceClose 是否強(qiáng)制關(guān)閉
- */
- private void writeResponse(Channel channel, HttpResponseStatus status, String msg, boolean forceClose){
- ByteBuf byteBuf = Unpooled.wrappedBuffer(msg.getBytes());
- response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, byteBuf);
- boolean close = isClose();
- if(!close && !forceClose){
- response.headers().add(org.apache.http.HttpHeaders.CONTENT_LENGTH, String.valueOf(byteBuf.readableBytes()));
- }
- ChannelFuture future = channel.write(response);
- if(close || forceClose){
- future.addListener(ChannelFutureListener.CLOSE);
- }
- }
- private boolean isClose(){
- if(request.headers().contains(org.apache.http.HttpHeaders.CONNECTION, CONNECTION_CLOSE, true) ||
- (request.protocolVersion().equals(HttpVersion.HTTP_1_0) &&
- !request.headers().contains(org.apache.http.HttpHeaders.CONNECTION, CONNECTION_KEEP_ALIVE, true)))
- return true;
- return false;
- }
好了,問題是解決了,那么你對 HTTP 長連接真的了解嗎?不了解,好,那就來不補(bǔ)課。
5.2.1. TCP KeepAlive 和 HTTP KeepAlive【4】
netty 中有個地方比較讓初學(xué)者迷惑,就是 childOption(ChannelOption.SO_KEEPALIVE, true)和 HttpRequest.Headers.get("Connection").equals("Keep-Alive") (非標(biāo)準(zhǔn)寫法,僅作示例)的異同。有些人可能會問,我在 ServerBootstrap 中指定了 childOption(ChannelOption.SO_KEEPALIVE, true),是不是就意味著客戶端和服務(wù)器是長連接了?
答案當(dāng)然不是。
首先,TCP 的 KeepAlive 是 TCP 連接的探測機(jī)制,用來檢測當(dāng)前 TCP 連接是否活著。它支持三個系統(tǒng)內(nèi)核參數(shù)
- tcp_keepalive_time
- tcp_keepalive_intvl
- tcp_keepalive_probes
當(dāng)網(wǎng)絡(luò)兩端建立了 TCP 連接之后,閑置 idle(雙方?jīng)]有任何數(shù)據(jù)流發(fā)送往來)了 tcp_keepalive_time 后,服務(wù)器內(nèi)核就會嘗試向客戶端發(fā)送偵測包,來判斷 TCP 連接狀況(有可能客戶端崩潰、強(qiáng)制關(guān)閉了應(yīng)用、主機(jī)不可達(dá)等等)。如果沒有收到對方的回答( ACK 包),則會在 tcp_keepalive_intvl 后再次嘗試發(fā)送偵測包,直到收到對對方的 ACK,如果一直沒有收到對方的 ACK,一共會嘗試 tcp_keepalive_probes 次,每次的間隔時間在這里分別是 15s、30s、45s、60s、75s。如果嘗試 tcp_keepalive_probes,依然沒有收到對方的 ACK 包,則會丟棄該 TCP 連接。TCP 連接默認(rèn)閑置時間是2小時。
而對于 HTTP 的 KeepAlive,則是讓 TCP 連接活長一點(diǎn),在一次 TCP 連接中可以持續(xù)發(fā)送多份數(shù)據(jù)而不會斷開連接。通過使用 keep-alive 機(jī)制,可以減少 TCP 連接建立次數(shù),也意味著可以減少 TIME_WAIT 狀態(tài)連接,以此提高性能和提高 TTTP 服務(wù)器的吞吐率(更少的 TCP 連接意味著更少的系統(tǒng)內(nèi)核調(diào)用,socket 的 accept() 和 close() 調(diào)用)。
對于建立 HTTP 長連接的好處,總結(jié)如下【5】:
By opening and closing fewer TCP connections, CPU time is saved in routers and hosts (clients, servers, proxies, gateways, tunnels, or caches), and memory used for TCP protocol control blocks can be saved in hosts.
HTTP requests and responses can be pipelined on a connection. Pipelining allows a client to make multiple requests without waiting for each response, allowing a single TCP connection to be used much more efficiently, with much lower elapsed time.
Network congestion is reduced by reducing the number of packets caused by TCP opens, and by allowing TCP sufficient time to determine the congestion state of the network.
Latency on subsequent requests is reduced since there is no time spent in TCP's connection opening handshake.
HTTP can evolve more gracefully, since errors can be reported without the penalty of closing the TCP connection. Clients using future versions of HTTP might optimistically try a new feature, but if communicating with an older server, retry with old semantics after an error is reported.
5.2.2. 長連接方式中如何判斷數(shù)據(jù)發(fā)送完成【6】
回到本節(jié)最開始提出的問題,KeepAlive 模式下,HTTP 服務(wù)器在發(fā)送完數(shù)據(jù)后并不會主動斷開連接,那客戶端如何判斷數(shù)據(jù)發(fā)送完成了?
對于短連接方式,服務(wù)端在發(fā)送完數(shù)據(jù)后會斷開連接,客戶端過服務(wù)器關(guān)閉連接能確定消息的傳輸長度。(請求端不能通過關(guān)閉連接來指明請求消息體的結(jié)束,因?yàn)檫@樣讓服務(wù)器沒有機(jī)會繼續(xù)給予響應(yīng))。
但對于長連接方式,服務(wù)端只有在 Keep-alive timeout 或者達(dá)到 max 請求次數(shù)時才會斷開連接。這種情況下有兩種判斷方法。
使用消息頭部 Content-Length
Conent-Length 表示實(shí)體內(nèi)容長度,客戶端(或服務(wù)器)可以根據(jù)這個值來判斷數(shù)據(jù)是否接收完成。但是如果消息中沒有 Conent-Length,那該如何來判斷呢?又在什么情況下會沒有 Conent-Length 呢?
使用消息首部字段 Transfer-Encoding
當(dāng)請求或響應(yīng)的內(nèi)容是動態(tài)的,客戶端或服務(wù)器無法預(yù)先知道要傳輸?shù)臄?shù)據(jù)大小時,就要使用 Transfer-Encoding(即 chunked 編碼傳輸)。chunked 編碼將數(shù)據(jù)分成一塊一塊的發(fā)送。chunked 編碼將使用若干個chunk 串連而成,由一個標(biāo)明長度為 0 的 chunk 標(biāo)示結(jié)束。每個 chunk 分為頭部和正文兩部分,頭部內(nèi)容指定正文的字符總數(shù)(十六進(jìn)制的數(shù)字)和數(shù)量單位(一般不寫),正文部分就是指定長度的實(shí)際內(nèi)容,兩部分之間用回車換行(CRLF)隔開。在最后一個長度為 0 的 chunk 中的內(nèi)容是稱為footer的內(nèi)容,是一些附加的Header信息(通??梢灾苯雍雎?。
如果一個請求包含一個消息主體并且沒有給出 Content-Length,那么服務(wù)器如果不能判斷消息長度的話應(yīng)該以400響應(yīng)(Bad Request),或者以411響應(yīng)(Length Required)如果它堅(jiān)持想要收到一個有效的 Content-length。所有的能接收實(shí)體的 HTTP/1.1 應(yīng)用程序必須能接受 chunked 的傳輸編碼,因此當(dāng)消息的長度不能被提前確定時,可以利用這種機(jī)制來處理消息。消息不能同時都包括 Content-Length 頭域和 非identity (Transfer-Encoding)傳輸編碼。如果消息包括了一個 非identity 的傳輸編碼,Content-Length頭域必須被忽略。當(dāng) Content-Length 頭域出現(xiàn)在一個具有消息主體(message-body)的消息里,它的域值必須精確匹配消息主體里字節(jié)數(shù)量。
好了,本章較長,雖然不是很深奧難懂的知識,也不是很牛逼的技術(shù)實(shí)現(xiàn),但是耐心看完之后相信你終究是有所收獲的。在此本文就要完結(jié)了,后續(xù)會對 netty HTTP 協(xié)議棧做更深入的研究,至于這個 github 上的項(xiàng)目,后面也會繼續(xù)完善 TODO LIST。大家可以通過多種方式與我交流,并歡迎大家提出寶貴意見。