HTTP Client 學習筆記 (一) 初遇篇

北冥有隻魚發表於2022-05-22
因為經常呼叫第三方介面,HttpClient是我經常使用的框架之一,這裡打算系統的學習一下,同時呼應《HTTP協議學習筆記(一) 初遇篇》,一邊是理論,一邊是實踐。同時也是在JDK8停留很久了,打算學習一下新版本的JDK特性,我注意到JDK 11也有一個HTTP Client,本篇我們的關注點構建HTTP請求,發出請求,然後解析響應。這篇文章也換一種風格。

從一個任務開始說起

我們故事的主人公叫小陳,目前還是一個實習生, 剛進公司安排的第一個需求是定時任務調第三方的介面去拉取資料到指定的表裡,小陳想到之前的部落格有教Apache HttpClient示例的, 於是寫出瞭如下拉資料的程式碼:

  @Override
 public void collectData() {
        StringEntity stringEntity = new StringEntity("", ContentType.APPLICATION_JSON);
        HttpUriRequest request = RequestBuilder.post("").addHeader("請求頭", "請求尾").addParameter("key", "value").setEntity(stringEntity).build();
        CloseableHttpClient httpClient = HttpClients.createDefault();
        try {
            CloseableHttpResponse response = httpClient.execute(request);
            // 拿到響應
            String responseString = EntityUtils.toString(response.getEntity());
            // 假設拿到了需要呼叫對面介面的次數
            for (int i = 0; i < Integer.parseInt(responseString) ; i++) {
                 httpClient = HttpClients.createDefault();
                 response = httpClient.execute(request);
                 // 做對應的業務處理
            }
        } catch (IOException e) {
            // 日誌暫時省略。
        }
   }

小陳覺得自己完成了這個需求,就去找領導看看程式碼,畢竟是實習生嘛,公司還是要把控一下程式碼質量的, 領導看了之後,產生了如下對話:

領導: 小陳啊, HTTP協議是基於應用層的哪個協議啊?

小陳心想這個我熟,這個我面試的背過,我狠下了一番功夫在三次握手和四次握手上, 於是答到: HTTP 是基於TCP的。

領導接著問: 那麼這個HttpClient是怎麼使用TCP協議的呀?

小陳心裡在偷偷的樂,還好我關注了公眾號愛喝湯的技術少年,看了公眾號的愛喝湯的技術少年《TCP學習筆記(一) 初遇篇》、《計算機網路引論》,於是

答到: TCP/IP協議已經駐留在現代作業系統中,在Java中,這個Apache HttpClient主要通過呼叫Java提供Socket相關的類,來實現呼叫作業系統的TCP/IP協議族,

需要使用網路通訊的時候,最終的呼叫到作業系統,作業系統會為該程式建立一個Socket(套接字),作業系統會為該套接字分配相關的資源(儲存空間,頻寬等)

領導,說到這裡,我必須畫個圖來彰顯我對網路相關的水平:

HttpClient 呼叫

領導笑了笑說道: 那你在迴圈裡產生HttpClient,有沒有這種可能,迴圈次數過多的時候會大量佔用作業系統的Socket資源呢?你看看不斷的建立CloseableHttpClient物件,會發生些什麼?

小陳想了想:是的,那我在迴圈外面用?

領導接著又說: 那有看過Apache HttpClient的文件嗎? 這個CloseableHttpClient有沒有可能是執行緒安全的呢?

小陳恍然大悟說道:那我把它加進IOC容器裡,這樣我們整個系統就都可以使用這個HttpClient了,節省資源。

領導接著說: 那還有別的問題沒有了啊,好好想想哦。

小陳說: 我想不到了誒。

領導說道:通訊過程有沒有可能失敗啊,假設某個時刻,網路比較擁堵,那HttpClient有沒有可能失敗啊?

小陳想了想,好像會,又問道: 那我們加重試? 但是該怎麼加比較優雅啊? 我目前想到的就是呼叫的時候用try catch,如果catch到異常了,然後for迴圈重試次數。

領導笑了笑說道: 有沒有這樣一種可能,這個Apache HttpClient帶有重試器啊,你下去查一下,然後改一下我們再看看?

小陳說:好的。

首先小陳開啟了CloseableHttpClient的原始碼:

CloseableHttpClient

看來領導說的沒毛病,這個類看來是執行緒安全的。想到領導說到了這個Apache的HttpClient文件, 於是開啟了百度,在搜尋框上輸入了Apache HttpClient:

Apache HttpClient Document

教程

retry的重試器

retry示例

小陳覺得都被滿足了, 於是寫出瞭如下的程式碼:

@Configuration
public class HttpClientConfiguration {
    @Bean
    public CloseableHttpClient closeableHttpClient(){
        HttpRequestRetryHandler myRetryHandler = new HttpRequestRetryHandler() {
            @Override
            public boolean retryRequest(
                    IOException exception,
                    int executionCount,
                    HttpContext context) {
                if (executionCount >= 5) {
                    // Do not retry if over max retry count
                    return false;
                }
                if (exception instanceof InterruptedIOException) {
                    // Timeout
                    return false;
                }
                if (exception instanceof UnknownHostException) {
                    // Unknown host
                    return false;
                }
                if (exception instanceof ConnectTimeoutException) {
                    // Connection refused
                    return false;
                }
                if (exception instanceof SSLException) {
                    // SSL handshake exception
                    return false;
                }
                HttpClientContext clientContext = HttpClientContext.adapt(context);
                HttpRequest request = clientContext.getRequest();
                // idempotent 冪等
                boolean idempotent = !(request instanceof HttpEntityEnclosingRequest);
                if (idempotent) {
                    // Retry if the request is considered idempotent
                    return true;
                }
                return false;
            }

        };
        CloseableHttpClient httpClient = HttpClients.custom().setRetryHandler(myRetryHandler).build();
        return httpClient;
    }
}
 @Override
 public void collectData() {
        StringEntity stringEntity = new StringEntity("", ContentType.APPLICATION_JSON);
        HttpUriRequest request = RequestBuilder.post("").addHeader("請求頭key", "請求value").addParameter("key", "value").setEntity(stringEntity).build();
        try {
            CloseableHttpResponse response = closeableHttpClient.execute(request);
            // 拿到響應
            String responseString = EntityUtils.toString(response.getEntity());
            // 假設拿到了需要呼叫對面介面的次數
            for (int i = 0; i < Integer.parseInt(responseString); i++) {
                closeableHttpClient = HttpClients.createDefault();
                response = closeableHttpClient.execute(request);
                // 做對應的業務處理
            }
        } catch (IOException e) {

        }
   }

然後把領導叫了過來,問道:大佬,再審一下我的程式碼唄。

領導點了點頭,說道: 重試,都做好了,還可以。那下一個問題,呼叫次數過多的情況下,這個CloseableHttpClient會為每一個HttpClient連線開闢一個TCP連線嗎? 如果是的話是不是有點奢侈了啊,要不再看看原始碼。

小陳想了想說道: 是HTTP1.1的keep-alive嗎?

領導笑道: 是的,其實還有一個問題,如果請求了介面,請求了四五千次,那這個for迴圈是不是有點費時間了啊?

小陳恍然大悟: 那這裡我開個執行緒池來做一下,HttpClient做一個連線池,做到像資料庫連線池那樣複用?

領導點了點頭,說道:那你再改一下吧。

於是小陳再次來到了Apache Httpclient 官方網站:

keep-alive策略

呼叫示例

看到了這裡長連線的問題算是解決了,那連線池呢,天吶這不會讓我自己寫一個連線池吧,小陳想Apache HttpClient 裡面肯定也有於是就接著翻文件:

連線管理

連線池池化

然後最終的程式就被改成了這個樣子:

@Configuration
public class HttpClientConfiguration {
    @Bean
    public CloseableHttpClient closeableHttpClient(){
        HttpRequestRetryHandler myRetryHandler = new HttpRequestRetryHandler() {

            @Override
            public boolean retryRequest(
                    IOException exception,
                    int executionCount,
                    HttpContext context) {
                // 返回true 就代表重試
                if (executionCount >= 5) {
                    // Do not retry if over max retry count
                    return false;
                }
                if (exception instanceof InterruptedIOException) {
                    // Timeout
                    return false;
                }
                if (exception instanceof UnknownHostException) {
                    // Unknown host
                    return false;
                }
                if (exception instanceof ConnectTimeoutException) {
                    // Connection refused
                    return false;
                }
                if (exception instanceof SSLException) {
                    // SSL handshake exception
                    return false;
                }
                HttpClientContext clientContext = HttpClientContext.adapt(context);
                HttpRequest request = clientContext.getRequest();
                boolean idempotent = !(request instanceof HttpEntityEnclosingRequest);
                if (idempotent) {
                    // Retry if the request is considered idempotent
                    return true;
                }
                return false;
            }

        };
        PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
        // Increase max total connection to 200
        cm.setMaxTotal(200);
        // Increase default max connection per route to 20
        cm.setDefaultMaxPerRoute(20);
        // Increase max connections for localhost:80 to 50
        HttpHost localhost = new HttpHost("locahost", 80);
        cm.setMaxPerRoute(new HttpRoute(localhost), 50);
        CloseableHttpClient httpClient = HttpClients.custom().setRetryHandler(myRetryHandler).setConnectionManager(cm).build();
        return httpClient;
    }
}

關於多次請求,小陳打算做個執行緒池來併發處理請求, 於是程式最終就變成了下面這個樣子:

@Override
 public void collectData() {
        StringEntity stringEntity = new StringEntity("", ContentType.APPLICATION_JSON);
        HttpUriRequest request = RequestBuilder.post("").addHeader("請求頭", "請求尾").addParameter("key", "value").setEntity(stringEntity).build();
        try {
            CloseableHttpResponse response = closeableHttpClient.execute(request);
            // 拿到響應
            String responseString = EntityUtils.toString(response.getEntity());
            // 假設拿到了需要呼叫對面介面的次數
            for (int i = 0; i < Integer.parseInt(responseString); i++) {
                POOL_EXECUTOR.submit(()->{
                    try {
                        CloseableHttpResponse threadResponseString = closeableHttpClient.execute(request);
                    } catch (IOException e) {
                        
                    }
                });
               
                // 做對應的業務處理
            }
        } catch (IOException e) {

        }
  }

於是小陳再次找到了領導,請他再次審閱自己的程式碼,領導看了看,點了點頭:還可以,勉強通過了,這次的任務算你完成,那再去了解一下JDK 11中的HttpClient吧,這次你的任務是對JDK 11中新的HttpClient有一個大致瞭解,基本會用即可,主要的目的是為了讓你看下不同HttpClient的實現。

JDK 11的HTTP Client

小陳得到領導分配的任務,首先在百度搜尋open jdk, open jdk會有對JDK 11新特性的說明:

open jdk

JDK11的說明

JDK 11

HTTP Client的說明

下面是對JDK 11 對這個特性的說明

The existing HttpURLConnection API and its implementation have numerous problems:

  • The base URLConnection API was designed with multiple protocols in mind, nearly all of which are now defunct (ftp, gopher, etc.).
  • The API predates HTTP/1.1 and is too abstract.
  • It is hard to use, with many undocumented behaviors.
  • It works in blocking mode only (i.e., one thread per request/response).
  • It is very hard to maintain.

已有的HttpURLConnection API實現上存在許多問題:

  • URLConnection 為多種協議所設計,但是當初的那些協議大多都不存在了
  • 這個介面早於HTTP Client, 但是太抽象了。
  • 很難用,並且有些行為沒有沒被註釋到
  • 只有阻塞模式(為每對請求和響應一個執行緒)

小陳看完了這個說明,心中的第一個想法就是,我該怎麼用JDK 11的HttpClient.

HttpClient基本示例

JDK 11是這麼介紹新的HttpClient的:

The HTTP Client was added in Java 11. It can be used to request HTTP resources over the network. It supports HTTP/1.1 and HTTP/2, both synchronous and asynchronous programming models, handles request and response bodies as reactive-streams, and follows the familiar builder pattern.

這個新實現的HTTP Client在Java 11被引入,可以在網路中用作請求HTTP 資源,支援HTTP1.1、HTTP/2, 同步和非同步模式。處理請求和響應流支援響應流模式,也能用熟悉方式構建。

  • 示例一解讀:
 public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("http://openjdk.java.net/"))
                .build();
        // 這是一個非同步請求
        // 處理響應將響應當作一個字串,
        // 返回結果CompletableFuture物件,不知道CompletableFuture
         // 可以去翻一下我之前寫的《Java多執行緒學習筆記(六) 長樂未央篇》
         // thenApply 收到執行緒的時候 消費HttpResponse的資料,最終被
         // thenAccept所處理,join同Thread.join方法一樣,
         //CompletableFuture鏈式處理資料完畢才會走到下一行
        client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .thenAccept(System.out::println).join();
    }
}

上面不是說支援HTTP/1.1 和 HTTP/2嗎,那我該如何使用, 請求引數該如何新增呢?

// 通過Version欄位指定
HttpClient client = HttpClient.newBuilder()
      .version(Version.HTTP_2)

Http請求的構建主要藉助於 HttpRequest.newBuilder()來構建,newBuilder最終指向了HttpRequestBuilderImpl,我們來看HttpRequestBuilderImpl這個類能幫助我們構建什麼引數:

HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create("http://openjdk.java.net/")) // URI 是地址
      .timeout(Duration.ofMinutes(1)) // 超時時間
      .header("Content-Type", "application/json") // 請求頭
      .POST(BodyPublishers.ofFile(Paths.get("file.json"))) // 引數主要通過BodyPublishers來構建
      .build()

看到這裡小陳在想,JDK 11的HttpClient為什麼沒有Apache HttpClient的構建請求時的addParameter嗎?查閱諸多資料都沒找到paramsPushlisher這個操作,但看起來是需要我們自己去拼接在URL上。構建請求體HttpClient給我們提供了BodyPublishers來進行構建:

HttpRequest.BodyPublishers::ofByteArray(byte[]) // 向服務端傳送位元組陣列
HttpRequest.BodyPublishers::ofByteArrays(Iterable)
HttpRequest.BodyPublishers::ofFile(Path) // 傳送檔案
HttpRequest.BodyPublishers::ofString(String) // 傳送String 
HttpRequest.BodyPublishers::ofInputStream(Supplier<InputStream>) // 傳送流

傳送POST請求:

// HttpResponse.BodyHandlers 用來處理響應體中的資料,ofString,將響應體中的資料處理成String
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
thenAccept(System.out::println).join();

到這裡還沒有發現JDK 11的HttpClient是怎麼管理連線的, 在StackOverFlow上也有人問這個問題, 這是JDK的預設策略。 但是有的請求如果不需要keep-alive呢,這似乎就得通過改整個JVM的屬性來實現,還有該如何實現重試,在這裡也沒找到。重試在某些場景下還是很重要的,如果用JDK 11自帶的還需要自己再包裝一下。在這個JEP下面也談到了Apache HttpClient:

A number of existing HTTP client APIs and implementations exist, e.g., Jetty and the Apache HttpClient. Both of these are both rather heavy-weight in terms of the numbers of packages and classes, and they don't take advantage of newer language features such as lambda expressions.
已經存在了一些HTTP Client庫,Jetty和Apache HttpClient,就程式碼量來說這兩個都相當的龐大,他們也沒有用到JDK的新特性比如Lamda表示式。

寫在最後

本篇基本介紹了Apache HttpClient 和 JDK 11 的Httpclient中的基本使用,目前大致來看似乎Apache HttpClient的完整度更高一些,但是JDK 11的實現也有亮點,像是對響應的封裝也十分讓人心動。

參考資料

相關文章