手寫一個類SpringBoot的HTTP框架:幾十行程式碼基於Netty搭建一個 HTTP Server
本文已經收錄進 :https://github.com/Snailclimb/netty-practical-tutorial(Netty 從入門到實戰:手寫 HTTP Server+RPC 框架)。
相關專案:https://github.com/Snailclimb/jsoncat(仿 Spring Boot 但不同於 Spring Boot 的一個輕量級的 HTTP 框架)
目前正在寫的一個叫做jsoncat的輕量級 HTTP 框架內建的 HTTP 伺服器是我自己基於 Netty 寫的,所有的核心程式碼加起來不過就幾十行。這得益於 Netty 提供的各種開箱即用的元件,為我們節省了太多事情。
這篇文章我會手把手帶著小夥伴們實現一個簡易的 HTTP Server。
如果文章有任何需要改善和完善的地方,歡迎在評論區指出,共同進步!
開始之前為了避免有小夥伴不瞭解 Netty ,還是先來簡單介紹它!
什麼是 Netty?
簡單用 3 點來概括一下 Netty 吧!
- Netty 是一個基於NIO的 client-server(客戶端伺服器)框架,使用它可以快速簡單地開發網路應用程式。
- Netty 極大地簡化並優化了 TCP 和 UDP 套接字伺服器等網路程式設計,並且效能以及安全性等很多方面都要更好。
- Netty支援多種協議如 FTP,SMTP,HTTP 以及各種二進位制和基於文字的傳統協議。本文所要寫的 HTTP Server 就得益於 Netty 對 HTTP 協議(超文字傳輸協議)的支援。
Netty 應用場景有哪些?
憑藉自己的瞭解,簡單說一下吧!理論上來說,NIO 可以做的事情 ,使用 Netty 都可以做並且更好。
不過,我們還是首先要明確的是 Netty 主要用來做網路通訊。
- 實現框架的網路通訊模組: Netty 幾乎滿足任何場景的網路通訊需求,因此,框架的網路通訊模組可以基於 Netty 來做。拿 RPC 框架來說! 我們在分散式系統中,不同服務節點之間經常需要相互呼叫,這個時候就需要 RPC 框架了。不同服務指點的通訊是如何做的呢?那就可以使用 Netty 來做了!比如我呼叫另外一個節點的方法的話,至少是要讓對方知道我呼叫的是哪個類中的哪個方法以及相關引數吧!
- 實現一個自己的 HTTP 伺服器
- 實現一個即時通訊系統: 使用 Netty 我們可以實現一個可以聊天類似微信的即時通訊系統,這方面的開源專案還蠻多的,可以自行去 Github 找一找。
- 實現訊息推送系統:市面上有很多訊息推送系統都是基於 Netty 來做的。
- ......
那些開源專案用到了 Netty?
我們平常經常接觸的 Dubbo、RocketMQ、Elasticsearch、gRPC 、Spring Cloud Gateway 等等都用到了 Netty。
可以說大量的開源專案都用到了 Netty,所以掌握 Netty 有助於你更好的使用這些開源專案並且讓你有能力對其進行二次開發。
實際上還有很多很多優秀的專案用到了 Netty,Netty 官方也做了統計,統計結果在這裡:https://netty.io/wiki/related-projects.html。
實現 HTTP Server 必知的前置知識
既然,我們要實現 HTTP Server 那必然先要回顧一下 HTTP 協議相關的基礎知識。
HTTP 協議
超文字傳輸協議(HTTP,HyperText Transfer Protocol)主要是為 Web 瀏覽器與 Web 伺服器之間的通訊而設計的。
當我們使用瀏覽器瀏覽網頁的時候,我們網頁就是通過 HTTP 請求進行載入的,整個過程如下圖所示。
https://www.seobility.net/en/wiki/HTTP_headers
HTTP 協議是基於 TCP 協議的,因此,傳送 HTTP 請求之前首先要建立 TCP 連線也就是要經歷 3 次握手。目前使用的 HTTP 協議大部分都是 1.1。在 1.1 的協議裡面,預設是開啟了 Keep-Alive 的,這樣的話建立的連線就可以在多次請求中被複用了。
瞭解了 HTTP 協議之後,我們再來看一下 HTTP 報文的內容,這部分內容很重要!(參考圖片來自:https://iamgopikrishna.wordpress.com/2014/06/13/4/)
HTTP 請求報文:
HTTP 響應報文:
我們的 HTTP 伺服器會在後臺解析 HTTP 請求報文內容,然後根據報文內容進行處理之後返回 HTTP 響應報文給客戶端。
Netty 編解碼器
如果我們要通過 Netty 處理 HTTP 請求,需要先進行編解碼。所謂編解碼說白了就是在 Netty 傳輸資料所用的ByteBuf
和 Netty 中針對 HTTP 請求和響應所提供的物件比如HttpRequest
和HttpContent
之間互相轉換。
Netty 自帶了 4 個常用的編解碼器:
HttpRequestEncoder
(HTTP 請求編碼器):將HttpRequest
和HttpContent
編碼為ByteBuf
。HttpRequestDecoder
(HTTP 請求解碼器):將ByteBuf
解碼為HttpRequest
和HttpContent
HttpResponsetEncoder
(HTTP 響應編碼器):將HttpResponse
和HttpContent
編碼為ByteBuf
。HttpResponseDecoder
(HTTP 響應解碼器):將ByteBuf
解碼為HttpResponst
和HttpContent
網路通訊最終都是通過位元組流進行傳輸的。ByteBuf
是 Netty 提供的一個位元組容器,其內部是一個位元組陣列。 當我們通過 Netty 傳輸資料的時候,就是通過ByteBuf
進行的。
HTTP Server 端用於接收 HTTP Request,然後傳送 HTTP Response。因此我們只需要HttpRequestDecoder
和HttpResponseEncoder
即可。
我手繪了一張圖,這樣看著應該更容易理解了。
Netty 對 HTTP 訊息的抽象
為了能夠表示 HTTP 中的各種訊息,Netty 設計了抽象了一套完整的 HTTP 訊息結構圖,核心繼承關係如下圖所示。
HttpObject
: 整個 HTTP 訊息體系結構的最上層介面。HttpObject
介面下又有HttpMessage
和HttpContent
兩大核心介面。HttpMessage
: 定義 HTTP 訊息,為HttpRequest
和HttpResponse
提供通用屬性HttpRequest
:HttpRequest
對應 HTTP request。通過HttpRequest
我們可以訪問查詢引數(Query Parameters)和 Cookie。和 Servlet API 不同的是,查詢引數是通過QueryStringEncoder
和QueryStringDecoder
來構造和解析查詢查詢引數。HttpResponse
:HttpResponse
對應 HTTP response。和HttpMessage
相比,HttpResponse
增加了 status(相應狀態碼) 屬性及其對應的方法。HttpContent
:分塊傳輸編碼(Chunked transfer encoding)是超文字傳輸協議(HTTP)中的一種資料傳輸機制(HTTP/1.1 才有),允許 HTTP 由應用伺服器傳送給客戶端應用( 通常是網頁瀏覽器)的資料可以分成多“塊”(資料量比較大的情況)。我們可以把HttpContent
看作是這一塊一塊的資料。LastHttpContent
: 標識 HTTP 請求結束,同時包含HttpHeaders
物件。FullHttpRequest
和FullHttpResponse
:HttpMessage
和HttpContent
聚合後得到的物件。
HTTP 訊息聚合器
HttpObjectAggregator
是 Netty 提供的 HTTP 訊息聚合器,通過它可以把HttpMessage
和HttpContent
聚合成一個FullHttpRequest
或者FullHttpResponse
(取決於是處理請求還是響應),方便我們使用。
另外,訊息體比較大的話,可能還會分成好幾個訊息體來處理,HttpObjectAggregator
可以將這些訊息聚合成一個完整的,方便我們處理。
使用方法:將HttpObjectAggregator
新增到ChannelPipeline
中,如果是用於處理 HTTP Request 就將其放在HttpResponseEncoder
之後,反之,如果用於處理 HTTP Response 就將其放在HttpResponseDecoder
之後。
因為,HTTP Server 端用於接收 HTTP Request,對應的使用方式如下。
ChannelPipeline p = ...; p.addLast("decoder", new HttpRequestDecoder()) .addLast("encoder", new HttpResponseEncoder()) .addLast("aggregator", new HttpObjectAggregator(512 * 1024)) .addLast("handler", new HttpServerHandler());
基於 Netty 實現一個 HTTP Server
通過 Netty,我們可以很方便地使用少量程式碼構建一個可以正確處理 GET 請求和 POST 請求的輕量級 HTTP Server。
原始碼地址:https://github.com/Snailclimb/netty-practical-tutorial/tree/master/example/http-server。
新增所需依賴到 pom.xml
第一步,我們需要將實現 HTTP Server 所必需的第三方依賴的座標新增到pom.xml
中。
<!--netty--> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.42.Final</version> </dependency> <!-- log --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.25</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>1.7.25</version> </dependency> <!-- lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.8</version> <scope>provided</scope> </dependency> <!--commons-codec--> <dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.14</version> </dependency>
建立服務端
@Slf4j public class HttpServer { private static final int PORT = 8080; public void start() { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) // TCP預設開啟了 Nagle 演算法,該演算法的作用是儘可能的傳送大資料快,減少網路傳輸。TCP_NODELAY 引數的作用就是控制是否啟用 Nagle 演算法。 .childOption(ChannelOption.TCP_NODELAY, true) // 是否開啟 TCP 底層心跳機制 .childOption(ChannelOption.SO_KEEPALIVE, true) //表示系統用於臨時存放已完成三次握手的請求的佇列的最大長度,如果連線建立頻繁,伺服器處理建立新連線較慢,可以適當調大這個引數 .option(ChannelOption.SO_BACKLOG, 128) .handler(new LoggingHandler(LogLevel.INFO)) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast("decoder", new HttpRequestDecoder()) .addLast("encoder", new HttpResponseEncoder()) .addLast("aggregator", new HttpObjectAggregator(512 * 1024)) .addLast("handler", new HttpServerHandler()); } }); Channel ch = b.bind(PORT).sync().channel(); log.info("Netty Http Server started on port {}.", PORT); ch.closeFuture().sync(); } catch (InterruptedException e) { log.error("occur exception when start server:", e); } finally { log.error("shutdown bossGroup and workerGroup"); bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }
簡單解析一下服務端的建立過程具體是怎樣的!
1.建立了兩個NioEventLoopGroup
物件例項:bossGroup
和workerGroup
。
bossGroup workerGroup
舉個例子:我們把公司的老闆當做 bossGroup,員工當做 workerGroup,bossGroup 在外面接完活之後,扔給 workerGroup 去處理。一般情況下我們會指定 bossGroup 的 執行緒數為 1(併發連線量不大的時候) ,workGroup 的執行緒數量為CPU 核心數 *2。另外,根據原始碼來看,使用NioEventLoopGroup
類的無參建構函式設定執行緒數量的預設值就是CPU 核心數 *2。
2.建立一個服務端啟動引導/輔助類:ServerBootstrap
,這個類將引導我們進行服務端的啟動工作。
3.通過.group()
方法給引導類ServerBootstrap
配置兩大執行緒組,確定了執行緒模型。
4.通過channel()
方法給引導類ServerBootstrap
指定了 IO 模型為NIO
NioServerSocketChannel
:指定服務端的 IO 模型為 NIO,與 BIO 程式設計模型中的ServerSocket
對應NioSocketChannel
: 指定客戶端的 IO 模型為 NIO, 與 BIO 程式設計模型中的Socket
對應
5.通過.childHandler()
給引導類建立一個ChannelInitializer
,然後指定了服務端訊息的業務處理邏輯也就是自定義的ChannelHandler
物件
6.呼叫 ServerBootstrap 類的 bind()方法繫結埠 。
//bind()是非同步的,但是,你可以通過 sync()方法將其變為同步。 ChannelFuture f = b.bind(port).sync();
自定義服務端 ChannelHandler 處理 HTTP 請求
我們繼承SimpleChannelInboundHandler
,並重寫下面 3 個方法:
channelRead() exceptionCaught() channelReadComplete()
另外,客戶端 HTTP 請求引數型別為FullHttpRequest
。我們可以把FullHttpRequest
物件看作是 HTTP 請求報文的 Java 物件的表現形式。
@Slf4j public class HttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> { private static final String FAVICON_ICO = "/favicon.ico"; private static final AsciiString CONNECTION = AsciiString.cached("Connection"); private static final AsciiString KEEP_ALIVE = AsciiString.cached("keep-alive"); private static final AsciiString CONTENT_TYPE = AsciiString.cached("Content-Type"); private static final AsciiString CONTENT_LENGTH = AsciiString.cached("Content-Length"); @Override protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest fullHttpRequest) { log.info("Handle http request:{}", fullHttpRequest); String uri = fullHttpRequest.uri(); if (uri.equals(FAVICON_ICO)) { return; } RequestHandler requestHandler = RequestHandlerFactory.create(fullHttpRequest.method()); Object result; FullHttpResponse response; try { result = requestHandler.handle(fullHttpRequest); String responseHtml = "<html><body>" + result + "</body></html>"; byte[] responseBytes = responseHtml.getBytes(StandardCharsets.UTF_8); response = new DefaultFullHttpResponse(HTTP_1_1, OK, Unpooled.wrappedBuffer(responseBytes)); response.headers().set(CONTENT_TYPE, "text/html; charset=utf-8"); response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes()); } catch (IllegalArgumentException e) { e.printStackTrace(); String responseHtml = "<html><body>" + e.toString() + "</body></html>"; byte[] responseBytes = responseHtml.getBytes(StandardCharsets.UTF_8); response = new DefaultFullHttpResponse(HTTP_1_1, INTERNAL_SERVER_ERROR, Unpooled.wrappedBuffer(responseBytes)); response.headers().set(CONTENT_TYPE, "text/html; charset=utf-8"); } boolean keepAlive = HttpUtil.isKeepAlive(fullHttpRequest); if (!keepAlive) { ctx.write(response).addListener(ChannelFutureListener.CLOSE); } else { response.headers().set(CONNECTION, KEEP_ALIVE); ctx.write(response); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } @Override public void channelReadComplete(ChannelHandlerContext ctx) { ctx.flush(); } }
我們返回給客戶端的訊息體是FullHttpResponse
物件。通過FullHttpResponse
物件,我們可以設定 HTTP 響應報文的 HTTP 協議版本、響應的具體內容 等內容。
我們可以把FullHttpResponse
物件看作是 HTTP 響應報文的 Java 物件的表現形式。
FullHttpResponse response; String responseHtml = "<html><body>" + result + "</body></html>"; byte[] responseBytes = responseHtml.getBytes(StandardCharsets.UTF_8); // 初始化 FullHttpResponse ,並設定 HTTP 協議 、響應狀態碼、響應的具體內容 response = new DefaultFullHttpResponse(HTTP_1_1, OK, Unpooled.wrappedBuffer(responseBytes));
我們通過FullHttpResponse
的headers()
方法獲取到HttpHeaders
,這裡的HttpHeaders
對應於 HTTP 響應報文的頭部。通過HttpHeaders
物件,我們就可以對 HTTP 響應報文的頭部的內容比如 Content-Typ 進行設定。
response.headers().set(CONTENT_TYPE, "text/html; charset=utf-8"); response.headers().setInt(CONTENT_LENGTH, response.content().readableBytes());
本案例中,為了掩飾我們設定的 Content-Type 為text/html
,也就是返回 html 格式的資料給客戶端。
常見的 Content-Type
Content-Type | 解釋 |
---|---|
text/html | html 格式 |
text/plain | 純文字格式 |
text/css | css 格式 |
text/javascript | js 格式 |
application/json | json 格式(前後端分離專案常用) |
image/gif | gif 圖片格式 |
image/jpeg | jpg 圖片格式 |
image/png | png 圖片格式 |
請求的具體處理邏輯實現
因為有這裡有 POST 請求和 GET 請求。因此我們需要首先定義一個處理 HTTP Request 的介面。
public interface RequestHandler { Object handle(FullHttpRequest fullHttpRequest); }
HTTP Method 不只是有 GET 和 POST,其他常見的還有 PUT、DELETE、PATCH。只是本案例中實現的 HTTP Server 只考慮了 GET 和 POST。
GET /classes POST /classes PUT /classes/12 DELETE /classes/12
GET 請求的處理
@Slf4j public class GetRequestHandler implements RequestHandler { @Override public Object handle(FullHttpRequest fullHttpRequest) { String requestUri = fullHttpRequest.uri(); Map<String, String> queryParameterMappings = this.getQueryParams(requestUri); return queryParameterMappings.toString(); } private Map<String, String> getQueryParams(String uri) { QueryStringDecoder queryDecoder = new QueryStringDecoder(uri, Charsets.toCharset(CharEncoding.UTF_8)); Map<String, List<String>> parameters = queryDecoder.parameters(); Map<String, String> queryParams = new HashMap<>(); for (Map.Entry<String, List<String>> attr : parameters.entrySet()) { for (String attrVal : attr.getValue()) { queryParams.put(attr.getKey(), attrVal); } } return queryParams; } }
我這裡只是簡單得把 URI 的查詢引數的對應關係直接返回給客戶端了。
實際上,獲得了 URI 的查詢引數的對應關係,再結合反射和註解相關的知識,我們很容易實現類似於 Spring Boot 的@RequestParam
註解了。
建議想要學習的小夥伴,可以自己獨立實現一下。不知道如何實現的話,你可以參考我開源的輕量級 HTTP 框架jsoncat(仿 Spring Boot 但不同於 Spring Boot 的一個輕量級的 HTTP 框架)。
POST 請求的處理
@Slf4j public class PostRequestHandler implements RequestHandler { @Override public Object handle(FullHttpRequest fullHttpRequest) { String requestUri = fullHttpRequest.uri(); log.info("request uri :[{}]", requestUri); String contentType = this.getContentType(fullHttpRequest.headers()); if (contentType.equals("application/json")) { return fullHttpRequest.content().toString(Charsets.toCharset(CharEncoding.UTF_8)); } else { throw new IllegalArgumentException("only receive application/json type data"); } } private String getContentType(HttpHeaders headers) { String typeStr = headers.get("Content-Type"); String[] list = typeStr.split(";"); return list[0]; } }
對於 POST 請求的處理,我們這裡只接受處理 Content-Type 為application/json
的資料,如果 POST 請求傳過來的不是application/json
型別的資料,我們就直接丟擲異常。
實際上,我們獲得了客戶端傳來的 json 格式的資料之後,再結合反射和註解相關的知識,我們很容易實現類似於 Spring Boot 的@RequestBody
註解了。
建議想要學習的小夥伴,可以自己獨立實現一下。不知道如何實現的話,你可以參考我開源的輕量級 HTTP 框架jsoncat(仿 Spring Boot 但不同於 Spring Boot 的一個輕量級的 HTTP 框架)。
請求處理工廠類
public class RequestHandlerFactory { public static final Map<HttpMethod, RequestHandler> REQUEST_HANDLERS = new HashMap<>(); static { REQUEST_HANDLERS.put(HttpMethod.GET, new GetRequestHandler()); REQUEST_HANDLERS.put(HttpMethod.POST, new PostRequestHandler()); } public static RequestHandler create(HttpMethod httpMethod) { return REQUEST_HANDLERS.get(httpMethod); } }
我這裡用到了工廠模式,當我們額外處理新的 HTTP Method 方法的時候,直接實現RequestHandler
介面,然後將實現類新增到RequestHandlerFactory
即可。
啟動類
public class HttpServerApplication { public static void main(String[] args) { HttpServer httpServer = new HttpServer(); httpServer.start(); } }
效果
執行HttpServerApplication
的main()
方法,控制檯打印出:
[nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9bb1012a] REGISTERED [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9bb1012a] BIND: 0.0.0.0/0.0.0.0:8080 [nioEventLoopGroup-2-1] INFO io.netty.handler.logging.LoggingHandler - [id: 0x9bb1012a, L:/0:0:0:0:0:0:0:0:8080] ACTIVE [main] INFO server.HttpServer - Netty Http Server started on port 8080.
GET 請求
POST 請求