本文已經收錄進 : 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 ,我們可以很方便地使用少量代碼實現一個簡單的 HTTP 服務器。Netty 自帶了編解碼器和消息聚合器,為我們開發節省了很多事!
- 實現一個即時通訊系統 : 使用 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
: 用於處理客戶端的 TCP 連接請求。workerGroup
: 負責每一條連接的具體讀寫數據的處理邏輯,真正負責 I/O 讀寫操作,交由對應的 Handler 處理。
舉個例子:我們把公司的老板當做 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()
:服務端接收並處理客戶端發送的 HTTP 請求調用的方法。exceptionCaught()
:處理客戶端發送的 HTTP 請求發生異常的時候被調用。channelReadComplete()
: 服務端消費完客戶端發送的 HTTP 請求之后調用的方法。
另外,客戶端 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 :請求從服務器獲取特定資源。舉個例子:
GET /classes
(獲取所有班級) - POST :在服務器上創建一個新的資源。舉個例子:
POST /classes
(創建班級) - PUT :更新服務器上的資源(客戶端提供更新后的整個資源)。舉個例子:
PUT /classes/12
(更新編號為 12 的班級) - DELETE :從服務器刪除特定的資源。舉個例子:
DELETE /classes/12
(刪除編號為 12 的班級) - PATCH :更新服務器上的資源(客戶端提供更改的屬性,可以看做作是部分更新),使用的比較少,這里就不舉例子了。
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 請求
參考
我的開源項目推薦
- JavaGuide :「Java學習+面試指南」一份涵蓋大部分Java程序員所需要掌握的核心知識。准備 Java 面試,首選 JavaGuide!
- guide-rpc-framework :A custom RPC framework implemented by Netty+Kyro+Zookeeper.(一款基於 Netty+Kyro+Zookeeper 實現的自定義 RPC 框架-附詳細實現過程和相關教程)
- jsoncat :仿 Spring Boot 但不同於 Spring Boot 的一個輕量級的 HTTP 框架
- programmer-advancement :程序員應該有的一些好習慣+面試必知事項!
- springboot-guide :Not only Spring Boot but also important knowledge of Spring(不只是SpringBoot還有Spring重要知識點)
- awesome-java :Collection of awesome Java project on Github(Github 上非常棒的 Java 開源項目集合).
我是 Guide 哥,一 Java 后端開發,會一點前端,自由的少年。我們下期再見!微信搜“JavaGuide”回復“面試突擊”領取我整理的 4 本原創PDF