手寫一個類SpringBoot的HTTP框架:幾十行程式碼基於Netty搭建一個 HTTP Server

JavaGuide發表於2020-10-08

本文已經收錄進 : 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 吧!

  1. Netty 是一個基於 NIO 的 client-server(客戶端伺服器)框架,使用它可以快速簡單地開發網路應用程式。
  2. Netty 極大地簡化並優化了 TCP 和 UDP 套接字伺服器等網路程式設計,並且效能以及安全性等很多方面都要更好。
  3. Netty 支援多種協議 如 FTP,SMTP,HTTP 以及各種二進位制和基於文字的傳統協議。本文所要寫的 HTTP Server 就得益於 Netty 對 HTTP 協議(超文字傳輸協議)的支援。

Netty 應用場景有哪些?

憑藉自己的瞭解,簡單說一下吧!理論上來說,NIO 可以做的事情 ,使用 Netty 都可以做並且更好。

不過,我們還是首先要明確的是 Netty 主要用來做網路通訊

  1. 實現框架的網路通訊模組 : Netty 幾乎滿足任何場景的網路通訊需求,因此,框架的網路通訊模組可以基於 Netty 來做。拿 RPC 框架來說! 我們在分散式系統中,不同服務節點之間經常需要相互呼叫,這個時候就需要 RPC 框架了。不同服務指點的通訊是如何做的呢?那就可以使用 Netty 來做了!比如我呼叫另外一個節點的方法的話,至少是要讓對方知道我呼叫的是哪個類中的哪個方法以及相關引數吧!
  2. 實現一個自己的 HTTP 伺服器 :通過 Netty ,我們可以很方便地使用少量程式碼實現一個簡單的 HTTP 伺服器。Netty 自帶了編解碼器和訊息聚合器,為我們開發節省了很多事!
  3. 實現一個即時通訊系統 : 使用 Netty 我們可以實現一個可以聊天類似微信的即時通訊系統,這方面的開源專案還蠻多的,可以自行去 Github 找一找。
  4. 實現訊息推送系統 :市面上有很多訊息推送系統都是基於 Netty 來做的。
  5. ......

那些開源專案用到了 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 請求進行載入的,整個過程如下圖所示。

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 伺服器會在後臺解析 HTTP 請求報文內容,然後根據報文內容進行處理之後返回 HTTP 響應報文給客戶端。

Netty 編解碼器

如果我們要通過 Netty 處理 HTTP 請求,需要先進行編解碼。所謂編解碼說白了就是在 Netty 傳輸資料所用的 ByteBuf 和 Netty 中針對 HTTP 請求和響應所提供的物件比如 HttpRequestHttpContent之間互相轉換。

Netty 自帶了 4 個常用的編解碼器:

  1. HttpRequestEncoder (HTTP 請求編碼器):將 HttpRequestHttpContent 編碼為 ByteBuf
  2. HttpRequestDecoder (HTTP 請求解碼器):將 ByteBuf 解碼為 HttpRequestHttpContent
  3. HttpResponsetEncoder (HTTP 響應編碼器):將 HttpResponseHttpContent 編碼為 ByteBuf
  4. HttpResponseDecoder(HTTP 響應解碼器):將 ByteBuf 解碼為 HttpResponstHttpContent

網路通訊最終都是通過位元組流進行傳輸的。 ByteBuf 是 Netty 提供的一個位元組容器,其內部是一個位元組陣列。 當我們通過 Netty 傳輸資料的時候,就是通過 ByteBuf 進行的。

HTTP Server 端用於接收 HTTP Request,然後傳送 HTTP Response。因此我們只需要 HttpRequestDecoderHttpResponseEncoder 即可。

我手繪了一張圖,這樣看著應該更容易理解了。

Netty 對 HTTP 訊息的抽象

為了能夠表示 HTTP 中的各種訊息,Netty 設計了抽象了一套完整的 HTTP 訊息結構圖,核心繼承關係如下圖所示。

  1. HttpObject : 整個 HTTP 訊息體系結構的最上層介面。HttpObject 介面下又有 HttpMessageHttpContent兩大核心介面。
  2. HttpMessage: 定義 HTTP 訊息,為HttpRequestHttpResponse提供通用屬性
  3. HttpRequest : HttpRequest對應 HTTP request。通過 HttpRequest 我們可以訪問查詢引數(Query Parameters)和 Cookie。和 Servlet API 不同的是,查詢引數是通過QueryStringEncoderQueryStringDecoder來構造和解析查詢查詢引數。
  4. HttpResponseHttpResponse 對應 HTTP response。和HttpMessage相比,HttpResponse 增加了 status(相應狀態碼) 屬性及其對應的方法。
  5. HttpContent : 分塊傳輸編碼Chunked transfer encoding)是超文字傳輸協議(HTTP)中的一種資料傳輸機制(HTTP/1.1 才有),允許 HTTP 由應用伺服器傳送給客戶端應用( 通常是網頁瀏覽器)的資料可以分成多“塊”(資料量比較大的情況)。我們可以把 HttpContent 看作是這一塊一塊的資料。
  6. LastHttpContent : 標識 HTTP 請求結束,同時包含 HttpHeaders 物件。
  7. FullHttpRequestFullHttpResponseHttpMessageHttpContent 聚合後得到的物件。

HTTP 訊息聚合器

HttpObjectAggregator 是 Netty 提供的 HTTP 訊息聚合器,通過它可以把 HttpMessageHttpContent 聚合成一個 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 物件例項:bossGroupworkerGroup

  • 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 個方法:

  1. channelRead() :服務端接收並處理客戶端傳送的 HTTP 請求呼叫的方法。
  2. exceptionCaught() :處理客戶端傳送的 HTTP 請求發生異常的時候被呼叫。
  3. 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));

我們通過 FullHttpResponseheaders()方法獲取到 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();
    }
}

效果

執行 HttpServerApplicationmain()方法,控制檯列印出:

[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 請求

參考

  1. Netty 學習筆記-http objects

我的開源專案推薦

  1. JavaGuide :「Java學習+面試指南」一份涵蓋大部分Java程式設計師所需要掌握的核心知識。準備 Java 面試,首選 JavaGuide!
  2. guide-rpc-framework :A custom RPC framework implemented by Netty+Kyro+Zookeeper.(一款基於 Netty+Kyro+Zookeeper 實現的自定義 RPC 框架-附詳細實現過程和相關教程)
  3. jsoncat :仿 Spring Boot 但不同於 Spring Boot 的一個輕量級的 HTTP 框架
  4. programmer-advancement :程式設計師應該有的一些好習慣+面試必知事項!
  5. springboot-guide :Not only Spring Boot but also important knowledge of Spring(不只是SpringBoot還有Spring重要知識點)
  6. awesome-java :Collection of awesome Java project on Github(Github 上非常棒的 Java 開源專案集合).
    我是 Guide 哥,一 Java 後端開發,會一點前端,自由的少年。我們下期再見!微信搜“JavaGuide”回覆“面試突擊”領取我整理的 4 本原創PDF

相關文章