OkHttp:Java 平臺上的新一代 HTTP 客戶端

在 Java 程序中經常需要用到 HTTP 客戶端來發送 HTTP 請求並對所得到的響應進行處理。比如屏幕抓取(screen scraping)程序通過 HTTP 客戶端來訪問網站並解析所得到的 HTTP 文檔。在 Java 服務端程序中也可能需要使用 HTTP 客戶端來與第三方 REST 服務進行集成。隨着微服務(microservices)的流行,HTTP 成爲不同服務之間的標準集成方式。HTTP 客戶端的重要性也日益顯著。在 Java 平臺上,Java 標準庫提供了 HttpURLConnection 類來支持 HTTP 通訊。不過 HttpURLConnection 本身的 API 不夠友好,所提供的功能也有限。大部分 Java 程序都選擇使用 Apache 的開源項目 HttpClient 作爲 HTTP 客戶端。Apache HttpClient 庫的功能強大,使用率也很高,基本上是 Java 平臺中事實上的標準 HTTP 客戶端。本文介紹的是由 Square 公司開發的 OkHttp,是一個專注於性能和易用性的 HTTP 客戶端。

OkHttp 簡介

OkHttp 庫的設計和實現的首要目標是高效。這也是選擇 OkHttp 的重要理由之一。OkHttp 提供了對最新的 HTTP 協議版本 HTTP/2 和 SPDY 的支持,這使得對同一個主機發出的所有請求都可以共享相同的套接字連接。如果 HTTP/2 和 SPDY 不可用,OkHttp 會使用連接池來複用連接以提高效率。OkHttp 提供了對 GZIP 的默認支持來降低傳輸內容的大小。OkHttp 也提供了對 HTTP 響應的緩存機制,可以避免不必要的網絡請求。當網絡出現問題時,OkHttp 會自動重試一個主機的多個 IP 地址。

在 Java 程序中使用 OkHttp 非常簡單,只需要在 Maven 的 POM 文件中添加代碼清單 1 中的依賴即可。目前 OkHttp 的最新版本是 2.5.0。

清單 1. OkHttp 的 Maven 依賴聲明
<dependency>
 <groupId>com.squareup.okhttp</groupId>
 <artifactId>okhttp</artifactId>
 <version>2.5.0</version>
</dependency>

HTTP 連接

雖然在使用 OkHttp 發送 HTTP 請求時只需要提供 URL 即可,OkHttp 在實現中需要綜合考慮 3 種不同的要素來確定與 HTTP 服務器之間實際建立的 HTTP 連接。這樣做的目的是爲了達到最佳的性能。

首先第一個考慮的要素是 URL 本身。URL 給出了要訪問的資源的路徑。比如 URL http://www.baidu.com 所對應的是百度首頁的 HTTP 文檔。在 URL 中比較重要的部分是訪問時使用的模式,即 HTTP 還是 HTTPS。這會確定 OkHttp 所建立的是明文的 HTTP 連接,還是加密的 HTTPS 連接。

第二個要素是 HTTP 服務器的地址,如 baidu.com。每個地址都有對應的配置,包括端口號,HTTPS 連接設置和網絡傳輸協議。同一個地址上的 URL 可以共享同一個底層 TCP 套接字連接。通過共享連接可以有顯著的性能提升。OkHttp 提供了一個連接池來複用連接。

第三個要素是連接 HTTP 服務器時使用的路由。路由包括具體連接的 IP 地址(通過 DNS 查詢來發現)和所使用的代理服務器。對於 HTTPS 連接還包括通訊協商時使用的 TLS 版本。對於同一個地址,可能有多個不同的路由。OkHttp 在遇到訪問錯誤時會自動嘗試備選路由。

當通過 OkHttp 來請求某個 URL 時,OkHttp 首先從 URL 中得到地址信息,再從連接池中根據地址來獲取連接。如果在連接池中沒有找到連接,則選擇一個路由來嘗試連接。嘗試連接需要通過 DNS 查詢來得到服務器的 IP 地址,也會用到代理服務器和 TLS 版本等信息。當實際的連接建立之後,OkHttp 發送 HTTP 請求並獲取響應。當連接出現問題時,OkHttp 會自動選擇另外的路由進行嘗試。這使得 OkHttp 可以自動處理可能出現的網絡問題。當成功獲取到 HTTP 請求的響應之後,當前的連接會被放回到連接池中,提供給後續的請求來複用。連接池會定期把閒置的連接關閉以釋放資源。

請求,響應與調用

HTTP 客戶端所要執行的任務很簡單,接受 HTTP 請求並返回響應。每個 HTTP 請求包括 URL,HTTP 方法(如 GET 或 POST),HTTP 頭和請求的主體內容等。HTTP 請求的響應則包含狀態代碼(如 200 或 500),HTTP 頭和響應的主體內容等。雖然請求和響應的交互模式很簡單,但在實現中仍然有很多細節要考慮。OkHttp 會對收到的請求進行一定的處理,比如增加額外的 HTTP 頭。同樣的,OkHttp 也可能在返回響應之前對響應做一些處理。例如,OkHttp 可以啓用 GZIP 支持。在發送實際的請求時,OkHttp 會加上 HTTP 頭 Accept-Encoding。在接收到服務器的響應之後,OkHttp 會先做解壓縮處理,再把結果返回。如果 HTTP 響應的狀態代碼是重定向相關的,OkHttp 會自動重定向到指定的 URL 來進一步處理。OkHttp 也會處理用戶認證相關的響應。

OkHttp 使用調用(Call)來對發送 HTTP 請求和獲取響應的過程進行抽象。代碼清單 2 中給出了使用 OkHttp 發送 HTTP 請求的基本示例。首先創建一個 OkHttpClient 類的對象,該對象是使用 OkHttp 的入口。接着要創建的是表示 HTTP 請求的 Request 對象。通過 Request.Builder 這個構建幫助類可以快速的創建出 Request 對象。這裏指定了 Request 的 url 爲 http://www.baidu.com。接着通過 OkHttpClient 的 newCall 方法來從 Request 對象中創建一個 Call 對象,再調用 execute 方法來執行該調用,所得到的結果是表示 HTTP 響應的 Response 對象。通過 Response 對象中的不同方法可以訪問響應的不同內容。如 headers 方法來獲取 HTTP 頭,body 方法來獲取到表示響應主體內容的 ResponseBody 對象。

清單 2. OkHttp 最基本的 HTTP 請求
public class SyncGet {
   public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) {
        throw new IOException("服務器端錯誤: " + response);
    }

    Headers responseHeaders = response.headers();
    for (int i = 0; i < responseHeaders.size(); i++) {
        System.out.println(responseHeaders.name(i) + ": " + responseHeaders.value(i));
    }

    System.out.println(response.body().string());
   }
}

HTTP 頭處理

HTTP 頭是 HTTP 請求和響應中的重要組成部分。在創建 HTTP 請求時需要設置一些 HTTP 頭。在得到 HTTP 的響應之後,也會需要對其中包含的 HTTP 頭進行解析。從代碼的角度來說,HTTP 頭的數據結構是 Map<String, List<String>>類型。也就是說,對於每個 HTTP 頭,可能有多個值。但是大部分 HTTP 頭都只有一個值,只有少部分 HTTP 頭允許多個值。OkHttp 採用了簡單的方式來區分這兩種類型,使得對 HTTP 頭的使用更加簡單。

在設置 HTTP 頭時,使用 header(name, value) 方法來設置 HTTP 頭的唯一值。對同一個 HTTP 頭,多次調用該方法會覆蓋之前設置的值。使用 addHeader(name, value) 方法來爲 HTTP 頭添加新的值。在讀取 HTTP 頭時,使用 header(name) 方法來讀取 HTTP 頭的最近出現的值。如果該 HTTP 頭只有單個值,則返回該值;如果有多個值,則返回最後一個值。使用 headers(name) 方法來讀取 HTTP 頭的所有值。

代碼清單 3 中使用 header 方法設置了 User-Agent 頭的值,並添加了一個 Accept 頭的值。在進行解析時,通過 header 方法來獲取 Server 頭的單個值,通過 headers 方法來獲取 Set-Cookie 頭的所有值。

清單 3. HTTP 頭設置和讀取的示例
public class Headers {
   public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .header("User-Agent", "My super agent")
            .addHeader("Accept", "text/html")
            .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) {
        throw new IOException("服務器端錯誤: " + response);
    }

    System.out.println(response.header("Server"));
    System.out.println(response.headers("Set-Cookie"));
   }
}

POST 請求

HTTP POST 和 PUT 請求可以包含要提交的內容。只需要在創建 Request 對象時,通過 post 和 put 方法來指定要提交的內容即可。代碼清單 4中通過 RequestBody 的 create 方法來創建媒體類型爲 text/plain 的內容並提交。

清單 4. HTTP POST 請求的基本示例
public class PostString {
   public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();
    MediaType MEDIA_TYPE_TEXT = MediaType.parse("text/plain");
    String postBody = "Hello World";

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .post(RequestBody.create(MEDIA_TYPE_TEXT, postBody))
            .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) {
        throw new IOException("服務器端錯誤: " + response);
    }

    System.out.println(response.body().string());
   }
}

以 String 類型提交內容只適用於內容比較小的情況。當請求內容較大時,應該使用流來提交。代碼清單 5 中給了使用流方式來提交內容的示例。這裏創建了 RequestBody 的一個匿名子類。該子類的 contentType 方法需要返回內容的媒體類型,而 writeTo 方法的參數是一個 BufferedSink 對象。我們需要做的就是把請求的內容寫入到 BufferedSink 對象即可。

清單 5. 使用流方法提交 HTTP POST 請求的示例
public class PostStream {
   public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();
    final MediaType MEDIA_TYPE_TEXT = MediaType.parse("text/plain");
    final String postBody = "Hello World";

    RequestBody requestBody = new RequestBody() {
        @Override
        public MediaType contentType() {
            return MEDIA_TYPE_TEXT;
        }

        @Override
        public void writeTo(BufferedSink sink) throws IOException {
            sink.writeUtf8(postBody);
        }

 @Override
        public long contentLength() throws IOException {
            return postBody.length();
        }
    };

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .post(requestBody)
            .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) {
        throw new IOException("服務器端錯誤: " + response);
    }

    System.out.println(response.body().string());
   }
}

如果所要提交的內容來自本地文件,則不需要額外的流操作,只需要通過 RequestBody 的 create 方法,並把 File 類型的對象作爲參數傳入即可。

如果需要模擬 HTML 中的表單提交,可以通過 FormEncodingBuilder 來創建請求內容,見代碼清單 6

清單 6. 表單提交示例
RequestBody formBody = new FormEncodingBuilder()
            .add("query", "Hello")
            .build();

如果需要模擬 HTML 中的文件上傳功能,可以通過 MultipartBuilder 來創建 multipart 請求內容。代碼清單 7 中的 multipart 請求添加了一個表單屬性 title 和一個文件 file。

清單 7. 提交 multipart 請求的示例
MediaType MEDIA_TYPE_TEXT = MediaType.parse("text/plain");
RequestBody requestBody = new MultipartBuilder()
    .type(MultipartBuilder.FORM)
    .addPart(
            Headers.of("Content-Disposition", "form-data; name=\"title\""),
            RequestBody.create(null, "測試文檔"))
    .addPart(
            Headers.of("Content-Disposition", "form-data; name=\"file\""),
            RequestBody.create(MEDIA_TYPE_TEXT, new File("input.txt")))
    .build();

響應緩存

OkHttp 可以對 HTTP 響應的內容在磁盤上進行緩存。在進行 HTTP 請求時,如果該請求的響應已經被緩存而且沒有過期,OkHttp 會直接使用緩存中的響應內容,而不需要真正的發出 HTTP 請求到遠程服務器。在創建緩存時需要指定一個磁盤目錄和緩存的大小。在代碼清單 8 中,創建出 Cache 對象之後,通過 OkHttpClient 的 setCache 進行設置。通過 Response 對象的 cacheResponse 和 networkResponse 方法可以得到緩存的響應和從實際的 HTTP 請求得到的響應。如果該請求的響應來自實際的網絡請求,則 cacheResponse 方法的返回值爲 null;如果響應來自緩存,則 networkResponse 的返回值爲 null。OkHttp 在進行緩存時會遵循 HTTP 協議的要求,因此可以通過標準的 HTTP 頭 Cache-Control 來控制響應的緩存時間。

清單 8. 設置響應緩存的示例
public class CacheResponse {
   public static void main(String[] args) throws IOException {
    int cacheSize = 100 * 1024 * 1024;
    File cacheDirectory = Files.createTempDirectory("cache").toFile();
    Cache cache = new Cache(cacheDirectory, cacheSize);
    OkHttpClient client = new OkHttpClient();
    client.setCache(cache);

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) {
        throw new IOException("服務器端錯誤: " + response);
    }

    System.out.println(response.cacheResponse());
    System.out.println(response.networkResponse());
   }
}

用戶認證

OkHttp 提供了對用戶認證的支持。當 HTTP 響應的狀態代碼是 401 時,OkHttp 會從設置的 Authenticator 對象中獲取到新的 Request 對象並再次嘗試發出請求。Authenticator 接口中的 authenticate 方法用來提供進行認證的 Request 對象,authenticateProxy 方法用來提供對代理服務器進行認證的 Request 對象。代碼清單 9 中新的 Request 對象中添加了 HTTP 基本認證的 Authorization 頭。

清單 9. 用戶認證的示例
OkHttpClient client = new OkHttpClient();
client.setAuthenticator(new Authenticator() {
public Request authenticate(Proxy proxy, Response response) throws IOException {
    String credential = Credentials.basic("user", "password");
    return response.request().newBuilder()
            .header("Authorization", credential)
            .build();
}

public Request authenticateProxy(Proxy proxy, Response response) 
throws IOException {
    return null;
}
});

異步調用

OkHttp 除了支持常用的同步 HTTP 請求之外,還支持異步 HTTP 請求調用。在使用同步調用時,當前線程會被阻塞,直到 HTTP 請求完成。當同時發出多個 HTTP 請求時,同步調用的性能會比較差。這個時候通過異步調用可以提高整體的性能。

代碼清單 10 給出了異步調用的示例。在通過 newCall 方法創建一個新的 Call 對象之後,不是通過 execute 方法來同步執行,而是通過 enqueue 方法來添加到執行隊列中。在調用 enqueue 方法時需要提供一個 Callback 接口的實現。在 Callback 接口實現中,通過 onResponse 和 onFailure 方法來處理響應和進行錯誤處理。

清單 10. 異步調用的示例
public class AsyncGet {
   public static void main(String[] args) throws IOException {
    OkHttpClient client = new OkHttpClient();

    Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .build();

    client.newCall(request).enqueue(new Callback() {
        public void onFailure(Request request, IOException e) {
            e.printStackTrace();
        }

        public void onResponse(Response response) throws IOException {
            if (!response.isSuccessful()) {
                throw new IOException("服務器端錯誤: " + response);
            }

            System.out.println(response.body().string());
        }
});
   }
}

取消調用

當一個 HTTP 調用執行之後,可以通過 Call 接口的 cancel 方法來取消該請求。當一個調用被取消之後,等待該請求的響應的代碼會收到 IOException。同步和異步調用都可以被取消。如果需要同時取消多個請求,可以在創建請求時通過 RequestBuilder 的 tag 方法來爲請求添加一個標籤。在需要時可以通過 OkHttpClient 的 cancel 方法來取消擁有一個標籤的所有請求。代碼清單 11 中的所有請求都設置了標籤 website,可以通過 cancel 方法來全部取消。

清單 11. 取消 HTTP 請求的示例
public class CancelRequest {
private OkHttpClient client = new OkHttpClient();
private String tag = "website";

public void sendAndCancel() {
    sendRequests(Lists.newArrayList(
            "http://www.baidu.com",
            "http://www.163.com",
            "http://www.sina.com.cn"));
    client.cancel(this.tag);
}

public void sendRequests(List<String> urls) {
    urls.forEach(url -> {
        client.newCall(new Request.Builder()
                    .url(url)
                    .tag(this.tag)
                    .build())
                .enqueue(new SimpleCallback());
    });
}

private static class SimpleCallback implements Callback {

    public void onFailure(Request request, IOException e) {
        e.printStackTrace();
    }

    public void onResponse(Response response) throws IOException {
        System.out.println(response.body().string());
    }
}

public static void main(String[] args) throws IOException {
    new CancelRequest().sendAndCancel();
}
}

攔截器

攔截器是 OkHttp 提供的對 HTTP 請求和響應進行統一處理的強大機制。攔截器在實現和使用上類似於 Servlet 規範中的過濾器。多個攔截器可以鏈接起來,形成一個鏈條。攔截器會按照在鏈條上的順序依次執行。 攔截器在執行時,可以先對請求的 Request 對象進行修改;再得到響應的 Response 對象之後,可以進行修改之後再返回。

代碼清單 12 中的攔截器 LoggingInterceptor 用來記錄 HTTP 請求和響應的相關信息。Interceptor 接口只包含一個方法 intercept,其參數是 Chain 對象。Chain 對象表示的是當前的攔截器鏈條。通過 Chain 的 request 方法可以獲取到當前的 Request 對象。在使用完 Request 對象之後,通過 Chain 對象的 proceed 方法來繼續攔截器鏈條的執行。當執行完成之後,可以對得到的 Response 對象進行額外的處理。

清單 12. 記錄請求和響應信息的攔截器
public class LoggingInterceptor implements Interceptor {
public Response intercept(Chain chain) throws IOException {
    Request request = chain.request();

    long t1 = System.nanoTime();
    System.out.println(String.format("發送請求: [%s] %s%n%s",
            request.url(), chain.connection(), request.headers()));

    Response response = chain.proceed(request);

    long t2 = System.nanoTime();
    System.out.println(String.format("接收響應: [%s] %.1fms%n%s",
            response.request().url(), (t2 - t1) / 1e6d, response.headers()));

    return response;
}
}

OkHttp 中的攔截器分成應用和網絡攔截器兩種。應用攔截器對於每個 HTTP 響應都只會調用一次,可以通過不調用 Chain.proceed 方法來終止請求,也可以通過多次調用 Chain.proceed 方法來進行重試。網絡攔截器對於調用執行中的自動重定向和重試所產生的響應也會被調用,而如果響應來自緩存,則不會被調用。應用和網絡攔截器的添加方式見代碼清單 13

清單 13. 添加應用和網絡攔截器
client.interceptors().add(new LoggingInterceptor()); //添加應用攔截器

client.networkInterceptors().add(new LoggingInterceptor()); //添加網絡攔截器

小結

OkHttp 作爲一個簡潔高效的 HTTP 客戶端,可以在 Java 和 Android 程序中使用。相對於 Apache HttpClient 來說,OkHttp 的性能更好,其 API 設計也更加簡單實用。本文對 OkHttp 進行了詳細的介紹,包括同步和異步調用、HTTP GET 和 POST 請求處理、用戶認證、響應緩存和攔截器等。對於新開發的 Java 應用,推薦使用 OkHttp 作爲 HTTP 客戶端。

發佈了93 篇原創文章 · 獲贊 13 · 訪問量 30萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章