手寫一個類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 吧!

  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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM