网络编程总结(八):OkHttp 官方文档

什么是 OkHttp

HTTP 是现代网络应用经常使用到的,这关系到我们如何去交换数据以及多媒体信息,通过 Http 去更加有效的加载数据并且节省宽带。

OkHttp 是一种 HTTP 客户端,它默认为我们提供一下行为:

  • 在 Http 2.0 中,允许所有指向相同地址的请求共享同一个 Socket。

  • 使用连接池减少请求延迟(如果是 HTTP 2.0 则无效)。

  • 通过 GZIP 压缩数据,减少数据体积。

  • 请求响应进行缓存,避免重复请求。

当网络出现问题时,OkHttp 会从很多常见的连接问题中自动恢复。如果您的服务器有多个地址,当第一个 IP 连接失败时,OkHttp 会自动尝试下一个 IP。OkHttp 启用 TLS 模式启用新连接,如果握手失败,则退回到 TLS 1.0。

使用 OkHttp 很容易,它的请求/响应 API 设计具有流畅的构造器和不变性,它支持同步调用和带回调的异步调用。

OkHttp 从 Android 2.3 开始支持,对于 Java 最低要求版本是 1.7。

官方网站:http://square.github.io/okhttp/

github:https://github.com/square/okhttp

API: http://square.github.io/okhttp/3.x/okhttp/

准备工作

可以下载 jar 包 : https://search.maven.org/remote_content?g=com.squareup.okhttp3&a=okhttp&v=LATEST

如果是从 Gradle 构建,需添加依赖:

compile 'com.squareup.okhttp3:okhttp:3.6.0'

使用 OkHttp

Http 客户端的工作是接收你的请求(request)并产生响应(response),理论上十分简单,但在使用过程中却很棘手。

请求(request)

每个 Http 请求都包含一个 URL、一个请求方法(GET、POST、PUT等)和一个请求头列表。请求还可以含有一个请求体(body):一个特定内容类型的数据流。

响应(response)

响应用一个响应码、响应头和其自己的可选主体来响应请求。

重写请求(Rewriting Requests)

当你使用 OkHttp 发出一个请求的时候,您有一个更高级别的需求:“使用这些请求头来获取我的 URL”,为了保证效率和正确,OkHttp 会在发送之前重写您的请求。

OkHttp 可以添加原始请求中缺少的消息头,包括Content-LengthTransfer-EncodingUser-AgentHostConnection、和Content-Type,除非请求头已经存在压缩响应,否则它还将添加一个 Accept-Encoding 请求头。如果你有cookies,OkHttp还将添加一个Cookie请求头。

一些请求会有一个缓存的响应。当这个缓存的响应不是最新的时候,OkHttp会发送一个有条件的GET来下载更新的响应,如果它比缓存还新。它将会添加需要的请求头,如 IF-Modified-SinceIf-None-Match

重写响应(Rewriting Requests)

如果使用的是透明压缩,OkHttp会删除相应的响应头 Content-EncodingContent-Length,因为它们不适用于解压的响应体(body)。

如果一个条件 GET 是成功的,在指定的规范下,响应来自于网络和缓存的合并。

后续请求(Follow-up Requests)

当你的请求的URL已经改变,Web服务器将返回一个响应码:302,以表明该文档的新的URL。OkHttp将按照重定向检索最终响应。

如果响应发出授权质询,OkHttp将会要求身份验证(如果已经配置好)来满足质询。如果身份验证提供凭证,请求将会带着凭证进行重试。

重试请求(Retrying Requests)

有些时候连接失败:连接池过时或者已经断开连接,或者无法访问 WEB 服务器,OkHttp 将使用不同的路由来重试请求(如果有的话)。

调用(Calls)

通过重写、重定向、后续请求、和重试请求,简单的请求可能会产生许多请求和响应。OkHttp 使用 Call 来满足您请求的任务,令人欣慰的是,如果 URL 已经重定向或者切换到备用 IP,您的代码会继续运行。

有以下两种方式可选:

  • 同步:将会阻塞线程,直到获取到响应。

  • 异步:将请求假如到任意线程,当获取到响应时在另一个线程上调用。

可以在任意线程中取消调用。如果请求尚未完成,取消调用则调用失败。当调用已经取消,编辑请求主体或者读取响应主体会出现 IOException。

分派(Dispatch)

对于同步调用来说,自己负责管理自己的请求和线程,如果并发连接太多,则会浪费资源;并发太少,则会产生延迟。

对于异步调用来说,分派实现了最大的并发策略。你可以设置每个 WEB 服务器的最大并发值(默认为 5)和整体并发值(默认 64)。

连接(Connections)

虽然你只提供一个 URL,OkHttp 会使用 URL、Address 和 Route 来与您的网络服务器进行连接。

URL

URL(例如 https://github.com/square/okhttp) 是互联网的基础。除了是网络上通用和分散的命名方案,他们还指定了如何访问网络资源。

URL是抽象的:

  • 他们指定的调用可能是明文的(http)也可能是加密的(https),但是不使用任何加密算法。他们也不知道如何验证对等的证书或者哪些证书可以被信任。

  • 它们不指定是否应使用特定的代理服务器或如何使用该代理服务器进行身份验证。

URL 也是具体的:每个 URL 标识一个特定的路径(比如 /square/okhttp),或者标识一个查询(sharks&lang=en),每个服务器都可以托管很多 URL。

地址(Address)

Address 指定一个网络服务器(譬如 githu.com)和连接到该服务器所需的静态配置:端口号、HTTPS 设置和首选网络协议(如:HTTP 2.0 或者 SPDY)。

共享相同地址的 URL 也可以共享相同底层 TCO Socket,共享连接具有显著的性能优势:更低的延迟、更高的吞吐量和省电。OkHttp 使用连接池,自动重用 Http 1.x 连接并复用 HTTP 2.0 和SPDY 进行连接。

在 OkHttp 中,Address 的一些字段来自于 URL(格式、主机名、端口),其余的的来自于 OkHttpClient。

路由(Routes)

Route 提供实际链接到 WEB 服务器所需要的动态信息。例如尝试特定的 IP(如通过 DNS 查询发现的)、使用确切的代理服务器(使用 ProxySelector)以及要协商的 TLS 版本(用于 HTTPS 连接)。

一个地址可能有许多 Route,例如托管在多个数据中心的 WEB 服务器可以在其 DNS 响应中产生多个 IP 地址。

连接(Connections)

当你使用 OkHttp 请求一个 URL 时,它会做:

  1. 它使用 URL 和配置好的 OkHttpClient 创建一个地址,这个地址指定如何连接到
    网络服务器。

  2. 尝试从连接池中检索是否存在该地址的连接。

  3. 如果连接池中没有找到该地址的连接,则尝试选择一条线路,这通常意味着一个 DNS 请求,以获取服务器的地址。然后选择一个TLS版本和在必要时使用代理服务器.

  4. 如果这是一个新路由,它从建立直接的 Socket 连接、 TLS 隧道或者直接 TLS连接冲选取一个进行连接。根据需要进行 TLS 握手。

  5. 最后发送 Http 请求并读取响应。

如果连接出现问题,OkHttp 将选择另一个路由,然后重试。这允许OkHttp当服务器地址的子设备无法访问时重新访问.在某个依赖的连接失效或者尝试TLS版本是不支持的时候同样非常有用。

只有在接收到响应后,连接将会返回到连接池中,因此它可以被之后的请求重用。经过一段时间不活动的连接会被连接池清除。

OkHttp 使用方法

我们写了一些方法,演示如果结果 OkHttp 常见问题。阅读他们来了解如何使用 OkHttp。

同步 GET(Synchronous Get)

下载一个文件,打印其头部,并将其响应正文作为字符串打印出来。

响应主题上的 string() 方法对于小文档是方便高效的。但是如果相应提大于 1M,请尽量避免使用 string() 方法,因为它会将整个文档加载到内存中。这种情况下,更倾向于用流来处理。

private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/helloworld.txt")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + 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());
  }

异步 GET(Synchronous Get)

在工作线程中下载一个文件,并在响应可读时回调。回调被创建意味着响应头消息准备好了。读取响应主体可能会阻塞线程。OkHttp 目前不提供异步 API 来接收响应的实体部分。

private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/helloworld.txt")
        .build();

    client.newCall(request).enqueue(new Callback() {
      @Override public void onFailure(Call call, IOException e) {
        e.printStackTrace();
      }

      @Override public void onResponse(Call call, Response response) throws IOException {
        if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

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

访问头信息(Accessing Headers)

典型的 HTTP 头想一个 Map<String,String>,每个字段都有一个值(也有可能是空)。但是有些头允许有多个值,例如 Guava 的 Multimap。例如,HTTP 响应提供多个不同的头信息是常见的,OkHttp API 视图使得这两种情况下都方便使用。

当写入请求头时,使用 header(name,value) 为 value 设置位移的 name,如果 values 已经存在,他们将被删除然后添加新的 value。使用 addHeader(name,value) 添加头,则不会删除已经存在的。

当读取响应头时,使用 header(name) 最后一次出现的命名值。通常这也是唯一的。如果没有值,header(name) 将返回 null,要将所有字段的值作为列表读取,请使用 headers(name)。

要想访问所有头信息,可以使用支持索引访问的 Headers 类。

private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://api.github.com/repos/square/okhttp/issues")
        .header("User-Agent", "OkHttp Headers.java")
        .addHeader("Accept", "application/json; q=0.5")
        .addHeader("Accept", "application/vnd.github.v3+json")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

    System.out.println("Server: " + response.header("Server"));
    System.out.println("Date: " + response.header("Date"));
    System.out.println("Vary: " + response.headers("Vary"));
  }

上传字符串(Posting a String)

使用 HTTP POST 将请求正文发送到服务器,本例中将 markdown 文档发布到将 markdown 标记为 HTML 的 WEB 服务器中。由于整个请求主体同时在内存中,因为请避免使用此 API 发布大于 1M 的文档。

public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    String postBody = ""
        + "Releases\n"
        + "--------\n"
        + "\n"
        + " * _1.0_ May 6, 2013\n"
        + " * _1.1_ June 15, 2013\n"
        + " * _1.2_ August 11, 2013\n";

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(RequestBody.create(MEDIA_TYPE_MARKDOWN, postBody))
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

上传流(Post Streaming)

这里我们将请求正文作为流。请求正文内容正在生成,因为它正在写入。此示例直接接入 OKio 缓冲接收器。可能您更喜欢 OutputStream,您可以从 BufferedSink.outputStream() 中获取。

public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    RequestBody requestBody = new RequestBody() {
      @Override public MediaType contentType() {
        return MEDIA_TYPE_MARKDOWN;
      }

      @Override public void writeTo(BufferedSink sink) throws IOException {
        sink.writeUtf8("Numbers\n");
        sink.writeUtf8("-------\n");
        for (int i = 2; i <= 997; i++) {
          sink.writeUtf8(String.format(" * %s = %s\n", i, factor(i)));
        }
      }

      private String factor(int n) {
        for (int i = 2; i < n; i++) {
          int x = n / i;
          if (x * i == n) return factor(x) + " × " + i;
        }
        return Integer.toString(n);
      }
    };

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(requestBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

上传一个文件(Posting a File)

将文件作为请求实体是很容易的

  public static final MediaType MEDIA_TYPE_MARKDOWN
      = MediaType.parse("text/x-markdown; charset=utf-8");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    File file = new File("README.md");

    Request request = new Request.Builder()
        .url("https://api.github.com/markdown/raw")
        .post(RequestBody.create(MEDIA_TYPE_MARKDOWN, file))
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

上传表单(Posting form parameters)

使用 FormBody.Builder 构建一个像 HTML 的 <form> 标签一样的请求体。名称和值将使用 HTML-compatible 的 URL 编码进行编码。

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    RequestBody formBody = new FormBody.Builder()
        .add("search", "Jurassic Park")
        .build();
    Request request = new Request.Builder()
        .url("https://en.wikipedia.org/w/index.php")
        .post(formBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

上传多部分的请求(Posting a multipart request)

MultipartBody.Builder 可以构建与 HTML 文件上传表单兼容的复杂请求体。multipart request 的每个部分本身都是请求体,并且可以定义自己的头信息。如果存在,这些头信息应该描述该部分的主体,例如 Content-Desposition、Content-Length 和 Content-Type 标头如果可用,则会自动添加。

  private static final String IMGUR_CLIENT_ID = "...";
  private static final MediaType MEDIA_TYPE_PNG = MediaType.parse("image/png");

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    // Use the imgur image upload API as documented at https://api.imgur.com/endpoints/image
    RequestBody requestBody = new MultipartBody.Builder()
        .setType(MultipartBody.FORM)
        .addFormDataPart("title", "Square Logo")
        .addFormDataPart("image", "logo-square.png",
            RequestBody.create(MEDIA_TYPE_PNG, new File("website/static/logo-square.png")))
        .build();

    Request request = new Request.Builder()
        .header("Authorization", "Client-ID " + IMGUR_CLIENT_ID)
        .url("https://api.imgur.com/3/image")
        .post(requestBody)
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

使用 Gson 解析一个 JSON 响应(Parse a JSON Response With Gson)

Gson 是一个方便在 JSON 和 Java 对象之间相互转换的 API,这里我们用它来解析一个来自 Github API 的 JSON 响应。

注意:ResponseBody.charStream() 使用 Content-Type 响应头来设置在解码响应正文要使用的字符集。如果没有指定,则默认使用 UTF-8。

  private final OkHttpClient client = new OkHttpClient();
  private final Gson gson = new Gson();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://api.github.com/gists/c2a7c39532239ff261be")
        .build();
    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

    Gist gist = gson.fromJson(response.body().charStream(), Gist.class);
    for (Map.Entry<String, GistFile> entry : gist.files.entrySet()) {
      System.out.println(entry.getKey());
      System.out.println(entry.getValue().content);
    }
  }

  static class Gist {
    Map<String, GistFile> files;
  }

  static class GistFile {
    String content;
  }

响应缓存(Response Caching)

要想缓存响应,你需要一个可以读取和写入的缓存目录,并限制缓存的大小。缓存目录应该是私有的,不受信任的程序不能读取其缓存的内容。

使用多个缓存同时访问同一个缓存目录是错误的。大多数的应用程序应该只调用 new OkHttpClient() 一次,配置他们的缓存,并在所有地方使用相同的 OkHttpClient 实例。否则这两个缓存实例会相互干扰,影响缓存,并有可能导致程序崩溃。

响应缓存为所有配置使用 HTTP 头信息。你可以添加请求头信息如:Cache-Control:max-stale=3600 这样 OkHttp 的缓存就会遵守他们的规定。你的网络服务器配置响应能缓存多久自己的响应头信息,像 Cache-Control: max-age=9600。有缓存头强制缓存的响应,强制网络响应,或强制使用条件GET验证的网络响应。

  private final OkHttpClient client;

  public CacheResponse(File cacheDirectory) throws Exception {
    int cacheSize = 10 * 1024 * 1024; // 10 MiB
    Cache cache = new Cache(cacheDirectory, cacheSize);

    client = new OkHttpClient.Builder()
        .cache(cache)
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/helloworld.txt")
        .build();

    Response response1 = client.newCall(request).execute();
    if (!response1.isSuccessful()) throw new IOException("Unexpected code " + response1);

    String response1Body = response1.body().string();
    System.out.println("Response 1 response:          " + response1);
    System.out.println("Response 1 cache response:    " + response1.cacheResponse());
    System.out.println("Response 1 network response:  " + response1.networkResponse());

    Response response2 = client.newCall(request).execute();
    if (!response2.isSuccessful()) throw new IOException("Unexpected code " + response2);

    String response2Body = response2.body().string();
    System.out.println("Response 2 response:          " + response2);
    System.out.println("Response 2 cache response:    " + response2.cacheResponse());
    System.out.println("Response 2 network response:  " + response2.networkResponse());

    System.out.println("Response 2 equals Response 1? " + response1Body.equals(response2Body));
  }

要防止一个响应使用缓存,只获取网络响应,使用 CacheControl.FORCE_NETWORK ,要防止响应使用网络,请使用 CacheControl.FORCE_CACHE 。警告:如果使用 FORCE_CACHE 并且响应需要网络,OkHttp 将返回一个 504 的请求响应。

取消一个调用(Canceling a Call)

调用 Call.cancel() 来立即停止一个正在进行的调用。如果当前线程正在写请求或者读响应,将会抛出一个 IOException,当调用不再是必要的时候使用这个方式来保护网络。例如当你的用户导航离开应用的时候,同步和异步的调用就可以取消。

  private final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://httpbin.org/delay/2") // This URL is served with a 2 second delay.
        .build();

    final long startNanos = System.nanoTime();
    final Call call = client.newCall(request);

    // Schedule a job to cancel the call in 1 second.
    executor.schedule(new Runnable() {
      @Override public void run() {
        System.out.printf("%.2f Canceling call.%n", (System.nanoTime() - startNanos) / 1e9f);
        call.cancel();
        System.out.printf("%.2f Canceled call.%n", (System.nanoTime() - startNanos) / 1e9f);
      }
    }, 1, TimeUnit.SECONDS);

    try {
      System.out.printf("%.2f Executing call.%n", (System.nanoTime() - startNanos) / 1e9f);
      Response response = call.execute();
      System.out.printf("%.2f Call was expected to fail, but completed: %s%n",
          (System.nanoTime() - startNanos) / 1e9f, response);
    } catch (IOException e) {
      System.out.printf("%.2f Call failed as expected: %s%n",
          (System.nanoTime() - startNanos) / 1e9f, e);
    }
  }

超时(Timeouts)

当访问遥不可及时,使用超时来使调用失败。超时在网络划分中可以是由于客户端连接问题,服务器可用性的问题,或两者之间的任何东西。OkHttp支持连接,读取和写入超时。

  private final OkHttpClient client;

  public ConfigureTimeouts() throws Exception {
    client = new OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://httpbin.org/delay/2") // This URL is served with a 2 second delay.
        .build();

    Response response = client.newCall(request).execute();
    System.out.println("Response completed: " + response);
  }

每个调用的设置(Per-call Configuration)

所有的 HTTP 客户端配置都存在于 OkHttpClient 中,包括代理设置、超时和缓存。当需要更改当个调用的配置时,请调用 OkHttpClient.newBuilder() 方法。这将返回与原始客户端共享统一连接池、调度程序和配置的构建起。在下面的示例中,我们使用 500 毫秒超时发出一个请求,另一个请求使用 3000 毫秒超时。

  private final OkHttpClient client = new OkHttpClient();

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://httpbin.org/delay/1") // This URL is served with a 1 second delay.
        .build();

    try {
      // Copy to customize OkHttp for this request.
      OkHttpClient copy = client.newBuilder()
          .readTimeout(500, TimeUnit.MILLISECONDS)
          .build();

      Response response = copy.newCall(request).execute();
      System.out.println("Response 1 succeeded: " + response);
    } catch (IOException e) {
      System.out.println("Response 1 failed: " + e);
    }

    try {
      // Copy to customize OkHttp for this request.
      OkHttpClient copy = client.newBuilder()
          .readTimeout(3000, TimeUnit.MILLISECONDS)
          .build();

      Response response = copy.newCall(request).execute();
      System.out.println("Response 2 succeeded: " + response);
    } catch (IOException e) {
      System.out.println("Response 2 failed: " + e);
    }
  }

处理身份验证(Handling authentication)

OkHttp 可以自动重试未经身份验证的请求。当响应码为 401 未授权时,一个 Authenticator 被要求提供凭据。应该构建包含缺少凭据的新请求。如果没有可用的证书,则返回 null 以跳过重试。

使用 Response.challenges() 来获得任何身份验证的方案和领域的挑战。当完成一个基本的挑战,使用 Credentials.basic(username, password) 来编码请求头信息。

  private final OkHttpClient client;

  public Authenticate() {
    client = new OkHttpClient.Builder()
        .authenticator(new Authenticator() {
          @Override public Request authenticate(Route route, Response response) throws IOException {
            System.out.println("Authenticating for response: " + response);
            System.out.println("Challenges: " + response.challenges());
            String credential = Credentials.basic("jesse", "password1");
            return response.request().newBuilder()
                .header("Authorization", credential)
                .build();
          }
        })
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("http://publicobject.com/secrets/hellosecret.txt")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

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

为避免当验证不工作而导致的许多重试,你可以返回 null 表示放弃。例如,当这些确切的证书已经尝试访问过时,你可能想跳过重试:

  if (credential.equals(response.request().header("Authorization"))) {
    return null; // If we already failed with these credentials, don't retry.
   }

当你设置一个应用程序定义的限制时你也可以跳过重试:

  if (responseCount(response) >= 3) {
    return null; // If we've failed 3 times, give up.
  }

上面的代码依赖于 responseCount() 方法:

  private int responseCount(Response response) {
    int result = 1;
    while ((response = response.priorResponse()) != null) {
      result++;
    }
    return result;
  }

拦截器(Intercaptors)

拦截器是一个强大的机制,可以监控、重写和重试调用。这里有一个简单的拦截器记录外发的请求和传入的响应的例子。

class LoggingInterceptor implements Interceptor {
  @Override public Response intercept(Interceptor.Chain chain) throws IOException {
    Request request = chain.request();

    long t1 = System.nanoTime();
    logger.info(String.format("Sending request %s on %s%n%s",
        request.url(), chain.connection(), request.headers()));

    Response response = chain.proceed(request);

    long t2 = System.nanoTime();
    logger.info(String.format("Received response for %s in %.1fms%n%s",
        response.request().url(), (t2 - t1) / 1e6d, response.headers()));

    return response;
  }
}

调用 chain.proceed(request) 是每个拦截器的关键部分。这个简单的方法是所有 HTTP 工作的地方,用来产生满足请求的响应。

多个拦截器可以链接,假如你有一个压缩拦截器和一个校验和拦截器,你需要决定数据是先压缩再校验和,还是先校验和然后再压缩。OkHttp 使用列表追踪拦截器,拦截器按照列表中的顺序被调用。

应用拦截器(Application Interceptors)

拦截器可以被注册为应用拦截器或者网络拦截器。我们使用上面定义的 LoggingInterceptor 来显示二者之间的差异。

通过在 OkHttpClient.Builder 上调用 addInterceptor 来注册应用拦截器:

OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(new LoggingInterceptor())
    .build();

Request request = new Request.Builder()
    .url("http://www.publicobject.com/helloworld.txt")
    .header("User-Agent", "OkHttp Example")
    .build();

Response response = client.newCall(request).execute();
response.body().close();

URL http://www.publicobject.com/helloworld.txt 被重定向到 https://publicobject.com/helloworld.txt,OkHttp 会自动跟随该重定向。我们的应用程序拦截器被调用一次,并且从 chain.proceed() 返回重定向的响应:

INFO: Sending request http://www.publicobject.com/helloworld.txt on null
User-Agent: OkHttp Example

INFO: Received response for https://publicobject.com/helloworld.txt in 1179.7ms
Server: nginx/1.4.6 (Ubuntu)
Content-Type: text/plain
Content-Length: 1759
Connection: keep-alive

我们可以看到调用被重定向了,因为 response.request().url() 不同于 request.url() 两个日志语句打印出两个不同的 URL。

网络拦截器(Network Interceptors)

注册网络拦截器的方法也很类似,调用 addNetworkInterceptor() 而不是 addInterceptor()

OkHttpClient client = new OkHttpClient.Builder()
    .addNetworkInterceptor(new LoggingInterceptor())
    .build();

Request request = new Request.Builder()
    .url("http://www.publicobject.com/helloworld.txt")
    .header("User-Agent", "OkHttp Example")
    .build();

Response response = client.newCall(request).execute();
response.body().close();

这行这段代码,拦截器会运行两次,一次是初始化请求到 http://www.publicobject.com/helloworld.txt,另一次是重定向到 https://publicobject.com/helloworld.txt

INFO: Sending request http://www.publicobject.com/helloworld.txt on Connection{www.publicobject.com:80, proxy=DIRECT hostAddress=54.187.32.157 cipherSuite=none protocol=http/1.1}
User-Agent: OkHttp Example
Host: www.publicobject.com
Connection: Keep-Alive
Accept-Encoding: gzip

INFO: Received response for http://www.publicobject.com/helloworld.txt in 115.6ms
Server: nginx/1.4.6 (Ubuntu)
Content-Type: text/html
Content-Length: 193
Connection: keep-alive
Location: https://publicobject.com/helloworld.txt

INFO: Sending request https://publicobject.com/helloworld.txt on Connection{publicobject.com:443, proxy=DIRECT hostAddress=54.187.32.157 cipherSuite=TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA protocol=http/1.1}
User-Agent: OkHttp Example
Host: publicobject.com
Connection: Keep-Alive
Accept-Encoding: gzip

INFO: Received response for https://publicobject.com/helloworld.txt in 80.9ms
Server: nginx/1.4.6 (Ubuntu)
Content-Type: text/plain
Content-Length: 1759
Connection: keep-alive

网络请求还包含很多数据,例如 OkHttp 添加 Accept-Encoding:gzip 头以表示对响应压缩的支持。网络拦截器有一个非空连接,可用于查询 IP 地址和连接到网络服务器的 TLS 配置。

选择应用拦截器和网络拦截器(Choosing between application and network interceptors)

每个拦截器都有自己的优点:

应用拦截器:

  • 不用担心像重定向和重试这样的中间响应。

  • 从会被调用一次,即使 HTTP 响应是从缓存中获取。

  • 遵循应用程序的初衷,不关心 OkHttp 注入的头信息,例如 if-None-Match。

  • 允许短路而不调用 Chain.proceed(),即中止调用。

  • 允许重试,可以多次调用 Chain.proceed()

网络拦截器:

  • 能够操作例如重定向和重试这样的中间响应。

  • 当网络短路时不调用缓存响应。

  • 只观察网络传输的数据。

  • 携带请求访问链接。

重写请求(Rewriting Requests)

拦截器可以添加、删除或者替换请求头。他们还可以修改请求携带的主体。例如,如果你链接到一个支持压缩的网络服务器,你可以使用一个应用拦截器来添加请求压缩请求体。

/** This interceptor compresses the HTTP request body. Many webservers can't handle this! */
final class GzipRequestInterceptor implements Interceptor {
  @Override public Response intercept(Interceptor.Chain chain) throws IOException {
    Request originalRequest = chain.request();
    if (originalRequest.body() == null || originalRequest.header("Content-Encoding") != null) {
      return chain.proceed(originalRequest);
    }

    Request compressedRequest = originalRequest.newBuilder()
        .header("Content-Encoding", "gzip")
        .method(originalRequest.method(), gzip(originalRequest.body()))
        .build();
    return chain.proceed(compressedRequest);
  }

  private RequestBody gzip(final RequestBody body) {
    return new RequestBody() {
      @Override public MediaType contentType() {
        return body.contentType();
      }

      @Override public long contentLength() {
        return -1; // We don't know the compressed length in advance!
      }

      @Override public void writeTo(BufferedSink sink) throws IOException {
        BufferedSink gzipSink = Okio.buffer(new GzipSink(sink));
        body.writeTo(gzipSink);
        gzipSink.close();
      }
    };
  }
}

重写响应(Rewriting Response)

和重写请求相对称的,拦截器也可以用些响应头并转换响应体。这通过比重写请求头要危险,因为可能会和 WEB 服务器的初衷相违背。

如果你正在处理一个棘手的问题,并准备处理结果,重写响应头是一种强大的解决问题的方法。例如,你可以修复配置错误的 Cache-Control 响应头信息,来启用更好的响应缓存。

/** Dangerous interceptor that rewrites the server's cache-control header. */
private static final Interceptor REWRITE_CACHE_CONTROL_INTERCEPTOR = new Interceptor() {
  @Override public Response intercept(Interceptor.Chain chain) throws IOException {
    Response originalResponse = chain.proceed(chain.request());
    return originalResponse.newBuilder()
        .header("Cache-Control", "max-age=60")
        .build();
  }
};

通常,这种方法在修复 WEB 服务器的时候效果最好。

可用性(Availability)

OkHttp 拦截器需要 OkHttp 2.2 或以上版本支持。不幸的是,拦截器不能和 OkUrlFactory 或者建立在其之上的库同时工作,包括 Retrofit ≤ 1.8和 Picasso ≤ 2.4。

HTTPS

OkHttp 视图平衡以下两者的关系:

  • 尽可能多的主机连接。这包括运行最新的 boringssl 和运行过时旧版本的 OpSSL 的主机。

  • 连接安全。这包括使用证书和强密码来交换隐私数据来验证远程 WEB 服务器。

当判断和 HTTPS 服务器连接时,OkHttp 需要知道提供哪些 TLS 版本和密码套件。想要最大程度的提高连接性能的客户端将包括过时的 TLS 版本和弱密码套件。要求一个希望最大化安全性能严谨的客户端将仅限于最新的 TLS 版本和强密码套件。

具体的安全和连接是由 ConnectionSpec 接口决定,OkHttp 提供三个内容的连接规范:

  • MODERN_TLS 是一种连接到现代 HTTPS 服务器的安全配置。

  • COMPATIBLE_TLS 是一种连接到安全但不是现代 HTTPS 服务器的安全配置。

  • CLEARTEXT 是用于链接到 http://urls 的不安全配置

默认情况下,OkHttp 将尝试 MODERN_TLS 连接,如果连接失败,则退回到 COMPATIBLE_TLS连接。

每个规范中的 TLS 版本和密码套件会随着发型版本改变。例如在 OkHttp 2.2 中我们为 POODLE 攻击向下兼容支持 SSL3.0。我们在 OkHttp 2.3 向下兼容支持 RC4。与桌面浏览器相同,保持安全最好的方法就是保持最新的 OkHttp。

你可以使用一组自定义的 TLS 版本和密码套件来构建你自己的连接规范。例如,这个配置仅限于三个主要的密码组合.它的缺点是它需要Android 5.0+和相似的现有网络服务器。

ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)  
    .tlsVersions(TlsVersion.TLS_1_2)
    .cipherSuites(
          CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
          CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
          CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256)
    .build();

OkHttpClient client = new OkHttpClient.Builder() 
    .connectionSpecs(Collections.singletonList(spec))
    .build();

证书锁定(Certificate Pinning)

默认情况下,OkHttp 信任主机平台的证书办法机构。这一策略最大限度的提高了连接性能,但它容易受到像 2011 DigiNotar attack 这样的证书颁发机构攻击。它还假定您的 HTTPS 服务器证书由颁发机构颁发的。

使用 CertificatePinner 来限制哪些证书和哪些证书颁发机构是受信任的。证书锁定将增加安全性,但会限制服务器更新自己的 TLS 证书。在没有你的服务器的TLS管理员的同意下,不要使用证书锁定。

  public CertificatePinning() {
    client = new OkHttpClient.Builder()
        .certificatePinner(new CertificatePinner.Builder()
            .add("publicobject.com", "sha256/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=")
            .build())
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://publicobject.com/robots.txt")
        .build();

    Response response = client.newCall(request).execute();
    if (!response.isSuccessful()) throw new IOException("Unexpected code " + response);

    for (Certificate certificate : response.handshake().peerCertificates()) {
      System.out.println(CertificatePinner.pin(certificate));
    }
  }

定制被新人的证书(Customizing Tusted Certificates)

完整的代码示例展示了如何用你自己的设置替换主机平台的认证中心.不要在没有通知使用你的服务器的TLS管理员的情况下定制证书!

  private final OkHttpClient client;

  public CustomTrust() {
    SSLContext sslContext = sslContextForTrustedCertificates(trustedCertificatesInputStream());
    client = new OkHttpClient.Builder()
        .sslSocketFactory(sslContext.getSocketFactory())
        .build();
  }

  public void run() throws Exception {
    Request request = new Request.Builder()
        .url("https://publicobject.com/helloworld.txt")
        .build();

    Response response = client.newCall(request).execute();
    System.out.println(response.body().string());
  }

  private InputStream trustedCertificatesInputStream() {
    ... // Full source omitted. See sample.
  }

  public SSLContext sslContextForTrustedCertificates(InputStream in) {
    ... // Full source omitted. See sample.
  }
Copyright© 2020-2022 li-xyz 冀ICP备2022001112号-1