HTTP(超文本傳輸協議)協議是建立在TCP傳輸協議之上的應用層協議。HTTP是一個屬於應用層的面向對象的協議,由於其簡捷、快速的方式,適用於分布式超媒體信息系統。
本文將重點介紹如何基於Netty的HTTP協議棧進行HTTP服務端和客戶端開發。由於Netty的HTTP協議棧是基於Netty的NIO通信框架開發的,因此,Netty的HTTP協議也是異步非阻塞的。
HTTP協議介紹
HTTP協議的主要特點如下:
- 支持Client/Server模式;
- 簡單——客戶向服務器請求服務時,只需指定服務URL,攜帶必要的請求參數或者消息體;
- 靈活——HTTP允許傳輸任意類型的數據對象,傳輸的內容類型由HTTP消息頭中的Content-Type加以標記;
- 無狀態——HTTP協議是無狀態協議,無狀態是指協議對於事務處理沒有記憶能力。缺少狀態意味着如果后續處理需要之前的信息,則它必須重傳,這樣可能導致每次連接傳送的數據量增大。另一方面,在服務器不需要先前信息時它的應答就較快,負載較輕。
HTTP協議的URL
HTTP URL(URL是一種特殊類型的URI,包含了用於查找某個資源的足夠的信息)的格式如下。
http://host[":"port][abs_path]
其中,http表示要通過HTTP協議來定位網絡資源;host表示合法的Internet主機域名或者IP地址;port指定一個端口號,為空則使用默認端口80;abs_path指定請求資源的URI,如果URL中沒有給出abs_path,那么當它作為請求URI時,必須以“/”的形式給出,通常這點工作瀏覽器會自動幫我們完成。
HTTP請求消息(HttpRequest)
HTTP請求由三部分組成,具體如下:
- HTTP請求行;
- HTTP消息頭;
- HTTP請求正文;
請求行以一個方法符開頭,以空格分開,后面跟着請求的URI和協議的版本。
格式為:Method Request-URI HTTP-Version CRLF。
例如:GET /netty5.0 HTTP/1.1
其中Method表示請求方法,Request-URI是一個統一資源標識符,HTTP-Version表示請求的HTTP協議版本,CRLF表示回車和換行(除了作為結尾的CRLF外,不允許出現單獨的CR或LF字符)。
請求方法有多種,各方法的作用如下:
- GET:請求獲取Request-URI所標識的資源;
- POST:在Request-URI所標識的資源后附加新的提交數據;
- HEAD:請求獲取由Request-URI所標識的資源的響應消息報頭;
- PUT:請求服務器存儲一個資源,並用Request-URI作為其標識;
- DELETE:請求服務器刪除Request-URI所標識的資源;
- TRACE:請求服務器回送收到的請求信息,主要用於測試或診斷;
- CONNECT:保留將來使用;
- OPTIONS:請求查詢服務器的性能,或者查詢與資源相關的選項和需求。
例如:GET方法:以在瀏覽器的地址欄中輸入網址的方式訪問網頁時,瀏覽器采用GET方法向服務器獲取資源。例如,我們直接在瀏覽器中輸入http://localhost:8080/netty-5.0.0
通過服務端抓包,打印HTTP請求消息頭,內容如下。
GET /netty5.0 HTTP/1.1
Host: localhost:8080
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip,deflate,sdch
Accept-Language: zh-CN,zh;q=0.8
Accept-Charset: GBK,utf-8;q=0.7,*;q=0.3
Content-Length: 0
請求報頭允許客戶端向服務器端傳遞請求的附加信息以及客戶端自身的信息。常用的請求報頭如表:
HTTP請求消息體是可選的,比較常用的HTTP+XML協議就是通過HTTP請求和響應消息體來承載XML信息的。
HTTP響應消息(HttpResponse)
處理完HTTP客戶端的請求之后,HTTP服務端返回響應消息給客戶端,HTTP響應也是由三個部分組成,分別是:狀態行、消息報頭、響應正文。
狀態行的格式為:HTTP-Version Status-Code Reason-Phrase CRLF,其中HTTP-Version表示服務器HTTP協議的版本,Status-Code表示服務器返回的響應狀態代碼,Status-Code表示服務器返回的響應狀態代碼。
狀態代碼由三位數字組成,第一個數字定義了響應的類別,它有5種可能取值。
(1)1xx:指示信息。表示請求已接收,繼續處理;
(2)2xx:成功。表示請求已被成功接收、理解、接受;
(3)3xx:重定向。要完成請求必須進行更進一步的操作;
(4)4xx:客戶端錯誤。請求有語法錯誤或請求無法實現;
(5)5xx:服務器端錯誤。服務器未能處理請求。
常見的狀態代碼、狀態描述如表:
響應報頭允許服務器傳遞不能放在狀態行中的附加響應信息,以及關於服務器的信息和對Request-URI所標識的資源進行下一步訪問的信息。
常用的響應報頭如表:
Netty HTTP服務端入門開發
由於Netty天生是異步事件驅動的架構,因此基於NIO TCP協議棧開發的HTTP協議棧也是異步非阻塞的。
Netty的HTTP協議棧無論在性能還是可靠性上,都表現優異,非常適合在非Web容器的場景下應用,相比於傳統的Tomcat、Jetty等Web容器,它更加輕量和小巧,靈活性和定制性也更好。
場景設計
以文件服務器為例學習Netty的HTTP服務端入門開發,例程場景如下:文件服務器使用HTTP協議對外提供服務,當客戶端通過瀏覽器訪問文件服務器時,對訪問路徑進行檢查,檢查失敗時返回HTTP 403錯誤,該頁無法訪問;如果校驗通過,以鏈接的方式打開當前文件目錄,每個目錄或者文件都是個超鏈接,可以遞歸訪問。如果是目錄,可以繼續遞歸訪問它下面的子目錄或者文件,如果是文件且可讀,則可以在瀏覽器端直接打開,或者通過【目標另存為】下載該文件。
HTTP服務端開發
import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.HttpRequestDecoder; import io.netty.handler.codec.http.HttpResponseEncoder; import io.netty.handler.stream.ChunkedWriteHandler; public class HttpFileServer { public void run(final int port, final String url) throws Exception { EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) throws Exception { //首先向ChannelPipeline中添加HTTP請求消息解碼器, ch.pipeline().addLast("http-decoder",new HttpRequestDecoder()); //添加了HttpObjectAggregator解碼器, //它的作用是將多個消息轉換為單一的FullHttpRequest或者FullHttpResponse, //原因是HTTP解碼器在每個HTTP消息中會生成多個消息對象。 //(1)HttpRequest / HttpResponse; //(2)HttpContent; //(3)LastHttpContent。 ch.pipeline().addLast("http-aggregator",new HttpObjectAggregator(65536)); //新增HTTP響應編碼器,對HTTP響應消息進行編碼; ch.pipeline().addLast("http-encoder",new HttpResponseEncoder()); //新增Chunked handler,它的主要作用是支持異步發送大的碼流(例如大的文件傳輸), //但不占用過多的內存,防止發生Java內存溢出錯誤。 ch.pipeline().addLast("http-chunked",new ChunkedWriteHandler()); //添加HttpFileServerHandler,用於文件服務器的業務邏輯處理。 ch.pipeline().addLast("fileServerHandler",new HttpFileServerHandler(url)); } }); ChannelFuture future = b.bind("127.0.0.1", port).sync(); System.out.println("HTTP文件目錄服務器啟動,網址是 : " + "http://127.0.0.1:" + port + url); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } public static void main(String[] args) throws Exception { int port = 8080; if (args.length > 0) { try { port = Integer.parseInt(args[0]); } catch (NumberFormatException e) { e.printStackTrace(); } } String url = "/Users"; if (args.length > 1) url = args[1]; //它有兩個參數:第一個是端口,第二個是HTTP服務端的URL路徑。 new HttpFileServer().run(port, url); } } import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.*; import io.netty.handler.codec.http.*; import io.netty.handler.stream.ChunkedFile; import io.netty.util.CharsetUtil; import javax.activation.MimetypesFileTypeMap; import java.io.File; import java.io.FileNotFoundException; import java.io.RandomAccessFile; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.regex.Pattern; import static io.netty.handler.codec.http.HttpResponseStatus.*; import static io.netty.handler.codec.http.HttpMethod.*; import static io.netty.handler.codec.http.HttpHeaders.*; import static io.netty.handler.codec.http.HttpHeaders.Names.*; import static io.netty.handler.codec.http.HttpVersion.*; public class HttpFileServerHandler extends SimpleChannelInboundHandler { private final String url; public HttpFileServerHandler(String url) { this.url = url; } @Override public void messageReceived(ChannelHandlerContext ctx, Object o) throws Exception { FullHttpRequest request = (FullHttpRequest) o; //首先對HTTP請求消息的解碼結果進行判斷,如果解碼失敗,直接構造HTTP 400錯誤返回。 if (!request.getDecoderResult().isSuccess()) { sendError(ctx, BAD_REQUEST); return; } //對請求行中的方法進行判斷,如果不是從瀏覽器或者表單設置為GET發起的請求(例如POST),則構造HTTP 405錯誤返回。 if (request.getMethod() != GET) { sendError(ctx, METHOD_NOT_ALLOWED); return; } //對請求URL進行包裝,然后對sanitizeUri方法展開分析。 final String uri = request.getUri(); final String path = sanitizeUri(uri); //如果構造的URI不合法,則返回HTTP 403錯誤。 if (path == null) { sendError(ctx, FORBIDDEN); return; } //使用新組裝的URI路徑構造File對象。 File file = new File(path); //如果文件不存在或者是系統隱藏文件,則構造HTTP 404異常返回。 if (file.isHidden() || !file.exists()) { sendError(ctx, NOT_FOUND); return; } //如果文件是目錄,則發送目錄的鏈接給客戶端瀏覽器。 if (file.isDirectory()) { if (uri.endsWith("/")) { sendListing(ctx, file); } else { sendRedirect(ctx, uri + '/'); } return; } //如果用戶在瀏覽器上點擊超鏈接直接打開或者下載文件 //對超鏈接的文件進行合法性判斷,如果不是合法文件,則返回HTTP 403錯誤。 if (!file.isFile()) { sendError(ctx, FORBIDDEN); return; } //使用隨機文件讀寫類以只讀的方式打開文件,如果文件打開失敗,則返回HTTP 404錯誤。 RandomAccessFile randomAccessFile; try { randomAccessFile = new RandomAccessFile(file, "r");// 以只讀的方式打開文件 } catch (FileNotFoundException fnfe) { sendError(ctx, NOT_FOUND); return; } //獲取文件的長度,構造成功的HTTP應答消息 long fileLength = randomAccessFile.length(); //在消息頭中設置content length和content type HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK); setContentLength(response, fileLength); setContentTypeHeader(response, file); //判斷是否是Keep-Alive,如果是,則在應答消息頭中設置Connection為Keep-Alive。 if (isKeepAlive(request)) { response.headers().set(CONNECTION, HttpHeaders.Values.KEEP_ALIVE); } //發送響應消息。 ctx.write(response); ChannelFuture sendFileFuture; //通過Netty的ChunkedFile對象直接將文件寫入到發送緩沖區中。 sendFileFuture = ctx.write( new ChunkedFile(randomAccessFile, 0, fileLength, 8192), ctx.newProgressivePromise()); //最后為sendFileFuture增加GenericFutureListener,如果發送完成,打印“Transfer complete.”。 sendFileFuture.addListener(new ChannelProgressiveFutureListener() { @Override public void operationProgressed(ChannelProgressiveFuture future, long progress, long total) { if (total < 0) { // total unknown System.err.println("Transfer progress: " + progress); } else { System.err.println("Transfer progress:" + progress + "/" + total); } } @Override public void operationComplete(ChannelProgressiveFuture future) throws Exception { System.out.println("Transfer complete."); } }); //如果使用chunked編碼,最后需要發送一個編碼結束的空消息體, //將LastHttpContent的EMPTY_LAST_CONTENT發送到緩沖區中,標識所有的消息體已經發送完成, //同時調用flush方法將之前在發送緩沖區的消息刷新到SocketChannel中發送給對方。 ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); //如果是非Keep-Alive的,最后一包消息發送完成之后,服務端要主動關閉連接。 if (!isKeepAlive(request)) { lastContentFuture.addListener(ChannelFutureListener.CLOSE); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); if (ctx.channel().isActive()) { sendError(ctx, INTERNAL_SERVER_ERROR); } } private static final Pattern INSECURE_URI = Pattern.compile(".*[<>&\"].*"); private String sanitizeUri(String uri) { //首先使用JDK的java.net.URLDecoder對URL進行解碼,使用UTF-8字符集, try { uri = URLDecoder.decode(uri, "UTF-8"); } catch (UnsupportedEncodingException e) { try { uri = URLDecoder.decode(uri, "ISO-8859-1"); } catch (UnsupportedEncodingException e1) { throw new Error(); } } //解碼成功之后對URI進行合法性判斷,如果URI與允許訪問的URI一致或者是其子目錄(文件),則校驗通過,否則返回空。 if (!uri.startsWith(url)) { return null; } if (!uri.startsWith("/")) { return null; } //將硬編碼的文件路徑分隔符替換為本地操作系統的文件路徑分隔符。 uri = uri.replace('/', File.separatorChar); //對新的URI做二次合法性校驗,如果校驗失敗則直接返回空。 if (uri.contains(File.separator + '.') || uri.contains('.' + File.separator) || uri.startsWith(".") || uri.endsWith(".") || INSECURE_URI.matcher(uri).matches()) { return null; } //最后對文件進行拼接,使用當前運行程序所在的工程目錄 + URI構造絕對路徑返回。 // return System.getProperty("user.dir") + File.separator + uri; return uri; } private static final Pattern ALLOWED_FILE_NAME = Pattern.compile("[A-Za-z0-9][-_A-Za-z0-9\\.]*"); private static void sendListing(ChannelHandlerContext ctx, File dir) { //首先創建成功的HTTP響應消息 FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, OK); //隨后設置消息頭的類型為“text/html; charset=UTF-8”。 response.headers().set(CONTENT_TYPE, "text/html;charset=UTF-8"); //用於構造響應消息體,由於需要將響應結果顯示在瀏覽器上,所以采用了HTML的格式。 StringBuilder buf = new StringBuilder(); String dirPath = dir.getPath(); buf.append("<!DOCTYPE HTML>\r\n"); buf.append("<HTML><HEAD><TITLE>"); buf.append(dirPath); buf.append(" 目錄:"); buf.append("</TITLE></HEAD><BODY>"); buf.append("<H3>"); buf.append(dirPath); buf.append(" 目錄:"); buf.append("</H3>\r\n"); buf.append("<UL>"); //打印了一個 ..的鏈接。 buf.append("<LI>鏈接:<a href=\"../\">..</a></LI>\r\n"); //用於展示根目錄下的所有文件和文件夾,同時使用超鏈接來標識。 for (File f : dir.listFiles()) { if (f.isHidden() || !f.canRead()) { continue; } String name = f.getName(); if (!ALLOWED_FILE_NAME.matcher(name).matches()) { continue; } buf.append("<LI>鏈接:<a href=\""); buf.append(name); buf.append("\">"); buf.append(name); buf.append("</a></LI>\r\n"); } buf.append("</UL></BODY></HTML>\r\n"); //分配對應消息的緩沖對象 ByteBuf buffer = Unpooled.copiedBuffer(buf, CharsetUtil.UTF_8); //將緩沖區中的響應消息存放到HTTP應答消息中,然后釋放緩沖區 response.content().writeBytes(buffer); buffer.release(); //最后調用writeAndFlush將響應消息發送到緩沖區並刷新到SocketChannel中。 ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } private static void sendRedirect(ChannelHandlerContext ctx, String newUri) { FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, FOUND); response.headers().set(LOCATION, newUri); ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) { FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, status,
Unpooled.copiedBuffer("Failure: " + status.toString() + "\r\n", CharsetUtil.UTF_8)); response.headers().set(CONTENT_TYPE, "text/plain; charset= UTF-8"); ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } private static void setContentTypeHeader(HttpResponse response, File file) { MimetypesFileTypeMap mimeTypesMap = new MimetypesFileTypeMap(); response.headers().set(CONTENT_TYPE, mimeTypesMap.getContentType(file.getPath())); } }
運行結果: