OkHttp 网络请求框架介绍与使用说明

前言

需要下载 OkHttp3 请看这里 OkHttp3 jar 包下载

需要下载 Okio 请看这里 Okio jar 包下载

Okio 可以帮助 OkHttp 用于快速 I/O 和调整缓冲区大小。

想要了解 OkHttp 源码请看这里 OkHttp GitHub 主页

配置
  • MAVEN
<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>okhttp</artifactId>
  <version>(insert latest version)</version>
</dependency>
  • GRADLE
compile 'com.squareup.okhttp3:okhttp:(insert latest version)'

implementation("com.squareup.okhttp3:okhttp:3.12.0")
样例
  • GET
OkHttpClient client = new OkHttpClient();

String run(String url) throws IOException {
  Request request = new Request.Builder()
      .url(url)
      .build();

  Response response = client.newCall(request).execute();
  return response.body().string();
}
  • POST
public static final MediaType JSON
    = MediaType.parse("application/json; charset=utf-8");

OkHttpClient client = new OkHttpClient();

String post(String url, String json) throws IOException {
  RequestBody body = RequestBody.create(JSON, json);
  Request request = new Request.Builder()
      .url(url)
      .post(body)
      .build();
  Response response = client.newCall(request).execute();
  return response.body().string();
}

更多详细的使用方案请看第三大点 Recipes

一、Calls

HTTP 客户端的工作是接受你的请求并生成其响应。这在理论上很简单,但在实现时却很复杂。

1.1 Requests — 请求

每个 HTTP 请求都包含一个 URL,一个方法(如 GETPOST)和一个标头列表。除此之外,它还可以包含请求主体:特定内容类型的数据流。

1.2 Responses — 响应

响应使用返回码(例如 200 表示成功或 404 表示未找到),标头以及自己的可选主体来响应请求。

1.3 Rewriting Requests — 重写请求

当你向 OkHttp 提供 HTTP 请求时,你是在高语境中描述请求:“使用这些标头获取此 URL。”为了正确性和效率,OkHttp 在传输之前会重写你的请求。

OkHttp 可能会添加原始请求中不存在的标头,包括 Content-LengthTransfer-EncodingUser-AgentHostConnectionContent-Type。例如,它将为透明的响应压缩添加 Accept-Encoding 标头,除非标头已存在。如果你有 cookie,OkHttp 将添加一个 Cookie 标头。

某些请求将具有缓存响应。当这个缓存的响应不是最新时,OkHttp 可以执行条件 GET 来下载更新后的响应。这需要添加 If-Modified-SinceIf-None-Match 等标头。

1.4 Rewriting Responses — 重写响应

如果使用透明压缩,OkHttp 将删除相应的响应头部 Content-EncodingContent-Length,因为它们不适用于解压缩的响应主体。

如果条件 GET 成功,则根据规范合并来自网络和缓存的响应。

1.5 Follow-up Requests — 后续跟踪请求

当你请求的 URL 已经发生更改,Web 服务器将返回一个响应代码,如 302,指示文档的新 URL。OkHttp 将遵循重定向来获取最终响应。

如果响应发出认证要求,OkHttp 将要求 Authenticator(如果配置了一个)来满足认证。如果验证者提供了凭证,则会使用包含凭证的请求重试连接。

1.6 Retrying Requests — 重试请求

有时连接失败:例如某个池连接失效并断开连接,或者无法访问 Web 服务器本身。如果有其他的可用路由,OkHttp 将重试该请求。

1.7 Calls — 任务

通过重写,重定向,后续跟踪和重试,你的简单请求可能会产生出许多请求和响应。OkHttp 使用 Call 来模拟满足你的请求的任务,但是需要许多中间请求和响应。通常这不会很多!但是,令人欣慰的是,如果你的 URL 被重定向或者故障转移到备用 IP 地址,你的程序将继续有效运行。

Call 以两种方式之一被执行:
  • 同步执行:你的线程将被阻塞,直到响应可读。

  • 异步执行:你将请求排入任一线程,并在响应可读时在另一个线程上进行回调。

可以从任何线程取消任务。这将使任务失败,如果它尚未完成。正在编写请求正文或读取响应正文的代码在其调用被取消时将遇到 IOException 异常。

1.8 Dispatch — 调度

对于同步调用,你需要自己处理线程,并负责管理同时发起的请求数。同时连接太多会浪费资源;太少会损害延迟。

对于异步调用,Dispatcher(调度器) 实现最大同时请求的策略。你可以设置每个网络服务器的最大值(默认值为 5)和总体数(默认值为 64)。

二、Connections

虽然你只提供 URL,但 OkHttp 使用三种类型设计其与 Web 服务器的连接:URL,地址和路由。

2.1 URLs

URLs(如 https://github.com/square/okhttp)是 HTTP 和 Internet 的基础。它们指定了如何访问 Web 资源。

URLs 是抽象的:
  • 它们指定网络呼叫可以是明文(http)或加密(https),但不应使用某种加密算法。它们也没有指定如何验证对等方的证书(HostnameVerifier)或可以信任哪些证书(SSLSocketFactory)。

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

它们也具体定义了:每个 URL 标识一个特定的路径(如 /square/okhttp)和查询(如 ?q=sharks&lang=en)。每个 Web 服务器都托管许多 URL。

2.2 Addresses — 地址

地址特指 Web 服务器(如 github.com)以及连接到该服务器所需的所有静态配置:端口号,HTTPS 设置和首选网络协议(如 HTTP / 2 或 SPDY)。

共享相同地址的 URL 也可以共享相同的底层 TCP 套接字连接。共享连接具有显着的性能优势:更低的延迟,更高的吞吐量(由于 TCP 的慢启动)和节省电池。OkHttp 使用 ConnectionPool,它自动重用 HTTP / 1.x 连接并多路复用 HTTP / 2 和 SPDY 连接。

在 OkHttp 中,地址的某些字段来自 URL(模式,主机名,端口),其余字段来自 OkHttpClient。

2.3 Routes — 路由

路由提供实际连接到 Web 服务器所需的动态信息。包括要尝试的特定 IP 地址(由 DNS 查询发现),要使用的确切代理服务器(如果正在使用 ProxySelector),以及要协商的 TLS 版本(对于 HTTPS 连接)。

对于单个地址可能有很多路由。例如,托管在多个数据中心中的 Web 服务器可能会在其 DNS 响应中生成多个 IP 地址。

2.4 Connections — 连接

当你使用 OkHttp 请求 URL 时,它将起到如下作用:
  1. 使用 URL 和已配置的 OkHttpClient 来创建地址。此地址指定我们将如何连接到 Web 服务器。

  2. 它尝试从连接池中检索与该地址的连接。

  3. 如果它在池中找不到连接,则会选择要尝试的路由。这通常意味着发出 DNS 请求以获取服务器的 IP 地址。然后,如有必要,它会选择 TLS 版本和代理服务器。

  4. 如果它是一条新路由,则通过构建套接字连接,TLS 隧道(通过 HTTP 代理的 HTTPS)或直接用 TLS 连接来连接。它根据需要进行 TLS 握手。

  5. 它发送 HTTP 请求并读取响应。

如果连接出现问题,OkHttp 将选择另一条路由并重试。这允许 OkHttp 在服务器地址无法访问时进行恢复。对于池连接过时或者不支持当前的 TLS 版本的问题也很有帮助。

收到响应后,连接将返回到连接池中,以便重新用于之后的请求。若一段时间闲置后,该连接将从池中剔除。

三、Recipes — 使用方案

以下是一些使用方案,演示了如何使用 OkHttp 解决常见问题。

3.1 Synchronous Get — 同步 Get 操作

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

对于小文档来说,使用 response.body.string() 方法将响应体转换为字符串非常方便有效。但是如果响应体很大(大于 1 MiB),则避免使用 string(),因为它会将整个文档加载到内存中。在这种情况下,应该将响应体转换为流来处理。

  private final OkHttpClient client = new OkHttpClient();

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

    try (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());
    }
  }

3.2 Asynchronous Get — 异步 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 {
        try (ResponseBody responseBody = response.body()) {
          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(responseBody.string());
        }
      }
    });
  }

3.3 Accessing Headers — 获取请求头部 / 响应头部

一般来说,HTTP 标头的形式类似于 Map <String,String>,每个字段都有一个值或没有。但是有一些标头允许有多个值,比如 Guava 的 Multimap。例如,HTTP 响应中提供多个 Vary 标头值是合法且常见的。

在编写请求头部时,可以使用 header(name,valuse) 来设置唯一的 (name,value) 对。如果该命名已经存在值,则在添加新值之前会将旧值删除。使用 addHeader(name,value) 添加头部将不会删除已存在的头部。

读取响应头部时,使用 response.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();

    try (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"));
    }
  }

3.4 Posting a String — 提交字符串

使用 HTTP POST 将请求体发送给服务器。以下示例是将一个 markdown 文档提交到 Web 服务器中,将 markdown 呈现为 HTML。因为整个请求体会被加载到内存中,因此避免使用此 API 提交大型(大于 1 MiB)文档。

  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();

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

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

3.5 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();

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

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

3.6 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();

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

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

3.7 Posting form parameters — 提交表单参数

使用 FormBody.Builder 构建一个像 HTML <form> 标记的请求体。名称和值将使用与 HTML 兼容的表单 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();

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

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

3.8 Posting a multipart request — 提交多部分请求

MultipartBody.Builder 可以构建与 HTML 文件上传表单兼容的复杂请求主体。多部分请求主体的每个部分本身都是一个请求主体,并且可以定义自己的头部,这些头部应该描述自身主体,例如其 Content-Disposition。如果 Content-LengthContent-Type 头部可用,则会自动添加它们。

  /**
   * The imgur client ID for OkHttp recipes. 
   * If you're using imgur for anything other than running
   * these examples, please request your own client ID! https://api.imgur.com/oauth2
   */
  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();

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

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

3.9 Parse a JSON Response With Moshi — 使用 Moshi 解析 JSON 响应

Moshi 是一个方便的 API,用来在 JSON 和 Java 对象之间进行转换。在这里,我们使用它来解码一个来自 GitHub API 的 JSON 响应。

请注意,ResponseBody.charStream() 使用 Content-Type 响应头来选择在解码响应主体时使用哪个 charset。如果没有指定 charset,则默认为 UTF-8

  private final OkHttpClient client = new OkHttpClient();
  private final Moshi moshi = new Moshi.Builder().build();
  private final JsonAdapter<Gist> gistJsonAdapter = moshi.adapter(Gist.class);

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

      Gist gist = gistJsonAdapter.fromJson(response.body().source());

      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;
  }

3.10 Response Caching — 缓存响应

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

让多个缓存同时访问同一缓存目录是错误的。大多数应用程序应该只调用一次 new OkHttpClient(),使用自身的缓存配置它,并在全局使用相同的 OkHttpClient 实例。否则,两个缓存实例将相互影响,破坏响应缓存,并可能导致程序崩溃。

响应缓存使用 HTTP 标头进行所有配置。你可以添加请求头部,如 Cache-Control:max-stale = 3600,OkHttp 的缓存将遵循它们。你的 Web 服务器使用自己的响应标头配置缓存响应的时间,例如 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();

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

      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());
    }

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

      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 Unsatisfiable Request 响应。

3.11 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);

    System.out.printf("%.2f Executing call.%n", (System.nanoTime() - startNanos) / 1e9f);
    try (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);
    }
  }

3.12 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();

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

3.13 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();

    // Copy to customize OkHttp for this request.
    OkHttpClient client1 = client.newBuilder()
        .readTimeout(500, TimeUnit.MILLISECONDS)
        .build();
    try (Response response = client1.newCall(request).execute()) {
      System.out.println("Response 1 succeeded: " + response);
    } catch (IOException e) {
      System.out.println("Response 1 failed: " + e);
    }

    // Copy to customize OkHttp for this request.
    OkHttpClient client2 = client.newBuilder()
        .readTimeout(3000, TimeUnit.MILLISECONDS)
        .build();
    try (Response response = client2.newCall(request).execute()) {
      System.out.println("Response 2 succeeded: " + response);
    } catch (IOException e) {
      System.out.println("Response 2 failed: " + e);
    }
  }

3.14 Handling authentication — 处理身份验证

OkHttp 可以自动重试未经身份验证的请求。如果响应为 401 Not Authorized,则要求 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 {
            if (response.request().header("Authorization") != null) {
              return null; // Give up, we've already attempted to authenticate.
            }

            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();

    try (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;
  }

四、Interceptors — 拦截器

拦截器是一种强大的机制,可以监视,重写和重试任务。下面是一个简单的拦截器,可以记录传出请求和传入响应。

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 使用列表来跟踪拦截器,并按顺序调用拦截器。

Interceptors Diagram

4.1 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。

4.2 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 头部,用于声告对响应压缩的支持。网络拦截器的 Chain(链) 具有非空 Connection(连接),可用于询问用于连接到 Web 服务器的 IP 地址和 TLS 配置。

4.3 Choosing between application and network interceptors — 选择应用程序拦截器还是网络拦截器

每种拦截链都有其相对优点。

4.3.1 Application interceptors — 应用程序拦截器
  • 不需要担心重定向或重试等中间响应。

  • 始终调用一次,即使 HTTP 响应是从缓存提供的。

  • 便于观察应用程序的初始意图,不关注 OkHttp 注入的头部,如 If-None-Match

  • 允许短路而不是调用 Chain.proceed()

  • 允许重试并多次调用 Chain.proceed()

4.3.2 Network Interceptors — 网络拦截器
  • 能够对重定向或重试等中间响应进行操作。

  • 在使网络短路的缓存响应情况下不进行调用。

  • 观察所有通过网络传输的数据。

  • 可以访问携带请求的 Connection(连接)

4.4 Rewriting Requests — 重写请求

拦截器可以添加,删除或替换请求头部。它们还可以转变那些拥有请求主体的请求。例如,你可以使用应用程序拦截器对请求主体进行压缩,只要你已知你要连接的 Web 服务器支持这一操作即可。

/** 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();
      }
    };
  }
}

4.5 Rewriting Responses — 重写响应

对应的,拦截器可以重写响应头以及转变响应体。但是,这通常比重写请求头部更危险,因为它可能违背了 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 服务器上的对应修复时效果最佳!

4.6 Availability — 可用性

OkHttp 的拦截器需要在 OkHttp 2.2 或更高版本上才能使用。不幸的是,拦截器不能与 OkUrlFactory 或基于它构建的库一起使用,包括 Retrofit ≤ 1.8Picasso ≤ 2.4

五、HTTPS

OkHttp 试图平衡两个相互竞争的问题:

  • 连接到尽可能多的主机。包括运行最新版本 boringssl 的高级主机和较少运行旧版本 OpenSSL 的过时主机。

  • 连接的安全性。包括使用证书验证远程 Web 服务器以及使用强密码交换隐私数据。

在协商与 HTTPS 服务器的连接时,OkHttp 需要知道要提供哪些 TLS 版本和密码套件。希望连接最大化的客户端将包括过时的 TLS 版本和弱设计的密码套件。想要安全性最大化的客户端将仅限于最新的 TLS 版本和最强的密码套件。

ConnectionSpec 实现了特定的安全性与连接性决策。

OkHttp 包含四个内置连接规范:
  • RESTRICTED_TLS 是一种安全配置,旨在满足更严格的合规性要求。

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

  • COMPATIBLE_TLS 是一种安全配置,可连接到安全的但非当前的 HTTPS 服务器。

  • CLEARTEXT 是一种不安全的配置,用于 http:// URLs。

这些非严格地遵循 Google 云端策略中设置的模型。

默认情况下,OkHttp 将尝试进行 MODERN_TLS 连接。但是,通过配置客户端的连接规范,如果 MODERN_TLS 配置失败,你可以回退到 COMPATIBLE_TLS 连接。

OkHttpClient client = new OkHttpClient.Builder() 
    .connectionSpecs(Arrays.asList(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
    .build();

每个规范中的 TLS 版本和密码套件都可以随每个版本而变化。例如,在 OkHttp 2.2 中,我们放弃了对 SSL 3.0 的支持以应对 POODLE 攻击。在 OkHttp 2.3 中,我们放弃了对 RC4 的支持。与你的桌面 Web 浏览器一样,保持 OkHttp 处于最新状态是保证安全的最佳方式。

你可以使用一组自定义 TLS 版本和密码套件来构建自己的连接规范。例如,下面的配置仅限于三个备受推崇的密码套件。它的缺点是它需要 Android 5.0+ 和类似的现代 Web 服务器。

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();

5.1 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));
    }
  }

5.2 Customizing Trusted 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.
  }

六、Events

利用事件可以让你捕获应用程序的 HTTP 任务的指标。

使用事件可以监控:
  • 应用程序发起的 HTTP 任务的大小和频率。如果你发起的网络任务太多,或者任务太大,你应该知道它!

  • 这些任务在底层网络上的执行性能。如果网络性能不足,则需要改进网络或减少使用网络。

6.1 EventListener

新建 EventListener 的子类和覆写你感兴趣的事件方法。在没有重定向或重试的成功 HTTP 调用中,以下流程图描述了该事件序列:

Events Diagram

这里有一个事件监听器示例,它使用时间戳来打印每个事件。

class PrintingEventListener extends EventListener {
  private long callStartNanos;

  private void printEvent(String name) {
    long nowNanos = System.nanoTime();
    if (name.equals("callStart")) {
      callStartNanos = nowNanos;
    }
    long elapsedNanos = nowNanos - callStartNanos;
    System.out.printf("%.3f %s%n", elapsedNanos / 1000000000d, name);
  }

  @Override public void callStart(Call call) {
    printEvent("callStart");
  }

  @Override public void callEnd(Call call) {
    printEvent("callEnd");
  }

  @Override public void dnsStart(Call call, String domainName) {
    printEvent("dnsStart");
  }

  @Override public void dnsEnd(Call call, String domainName, List<InetAddress> inetAddressList) {
    printEvent("dnsEnd");
  }

  ...
}

我们发出了一对请求:

Request request = new Request.Builder()
    .url("https://publicobject.com/helloworld.txt")
    .build();

System.out.println("REQUEST 1 (new connection)");
try (Response response = client.newCall(request).execute()) {
  // Consume and discard the response body.
  response.body().source().readByteString();
}

System.out.println("REQUEST 2 (pooled connection)");
try (Response response = client.newCall(request).execute()) {
  // Consume and discard the response body.
  response.body().source().readByteString();
}

监听器打印了相应的事件:

REQUEST 1 (new connection)
0.000 callStart
0.010 dnsStart
0.017 dnsEnd
0.025 connectStart
0.117 secureConnectStart
0.586 secureConnectEnd
0.586 connectEnd
0.587 connectionAcquired
0.588 requestHeadersStart
0.590 requestHeadersEnd
0.591 responseHeadersStart
0.675 responseHeadersEnd
0.676 responseBodyStart
0.679 responseBodyEnd
0.679 connectionReleased
0.680 callEnd
REQUEST 2 (pooled connection)
0.000 callStart
0.001 connectionAcquired
0.001 requestHeadersStart
0.001 requestHeadersEnd
0.002 responseHeadersStart
0.082 responseHeadersEnd
0.082 responseBodyStart
0.082 responseBodyEnd
0.083 connectionReleased
0.083 callEnd

注意:第二个请求没有触发连接事件。它重用了第一个请求的连接,从而显着提高了性能。

6.2 EventListener.Factory

在前面的示例中,我们使用了一个字段 callStartNanos 来跟踪每个事件的花费时间。这很方便,但如果多个任务同时执行,它将无法工作。为了适应这种情况,可以使用 Factory 为每个 Call 创建一个新的 EventListener 实例。这允许每个监听器保持特定于任务的状态。

以下 sample factory(示例工厂) 为每个任务创建唯一 ID,并使用该 ID 区分日志消息中的任务。

class PrintingEventListener extends EventListener {
  public static final Factory FACTORY = new Factory() {
    final AtomicLong nextCallId = new AtomicLong(1L);

    @Override public EventListener create(Call call) {
      long callId = nextCallId.getAndIncrement();
      System.out.printf("%04d %s%n", callId, call.request().url());
      return new PrintingEventListener(callId, System.nanoTime());
    }
  };

  final long callId;
  final long callStartNanos;

  public PrintingEventListener(long callId, long callStartNanos) {
    this.callId = callId;
    this.callStartNanos = callStartNanos;
  }

  private void printEvent(String name) {
    long elapsedNanos = System.nanoTime() - callStartNanos;
    System.out.printf("%04d %.3f %s%n", callId, elapsedNanos / 1000000000d, name);
  }

  @Override public void callStart(Call call) {
    printEvent("callStart");
  }

  @Override public void callEnd(Call call) {
    printEvent("callEnd");
  }

  ...
}

我们可以使用此监听器来竞争一对并发 HTTP 请求:

Request washingtonPostRequest = new Request.Builder()
    .url("https://www.washingtonpost.com/")
    .build();
client.newCall(washingtonPostRequest).enqueue(new Callback() {
  ...
});

Request newYorkTimesRequest = new Request.Builder()
    .url("https://www.nytimes.com/")
    .build();
client.newCall(newYorkTimesRequest).enqueue(new Callback() {
  ...
});

在家庭 WiFi 上进行这场比赛,结果显示,Times(0002)Post(0001) 稍早完成:

0001 https://www.washingtonpost.com/
0001 0.000 callStart
0002 https://www.nytimes.com/
0002 0.000 callStart
0002 0.010 dnsStart
0001 0.013 dnsStart
0001 0.022 dnsEnd
0002 0.019 dnsEnd
0001 0.028 connectStart
0002 0.025 connectStart
0002 0.072 secureConnectStart
0001 0.075 secureConnectStart
0001 0.386 secureConnectEnd
0002 0.390 secureConnectEnd
0002 0.400 connectEnd
0001 0.403 connectEnd
0002 0.401 connectionAcquired
0001 0.404 connectionAcquired
0001 0.406 requestHeadersStart
0002 0.403 requestHeadersStart
0001 0.414 requestHeadersEnd
0002 0.411 requestHeadersEnd
0002 0.412 responseHeadersStart
0001 0.415 responseHeadersStart
0002 0.474 responseHeadersEnd
0002 0.475 responseBodyStart
0001 0.554 responseHeadersEnd
0001 0.555 responseBodyStart
0002 0.554 responseBodyEnd
0002 0.554 connectionReleased
0002 0.554 callEnd
0001 0.624 responseBodyEnd
0001 0.624 connectionReleased
0001 0.624 callEnd

EventListener.Factory 还可以设置为仅捕获一部分任务的指标。以下这个随机捕获 10% 的指标:

class MetricsEventListener extends EventListener {
  private static final Factory FACTORY = new Factory() {
    @Override public EventListener create(Call call) {
      if (Math.random() < 0.10) {
        return new MetricsEventListener(call);
      } else {
        return EventListener.NONE;
      }
    }
  };

  ...
}

6.3 Events with Failures — 失败事件

操作失败时,会调用失败方法。当与服务器建立连接失败时将调用 connectFailed();当 HTTP 调用不断失败时将调用 callFailed()。发生故障时,start event(启动事件)可能没有相应的 end event(结束事件)

Events Diagram

6.4 Events with Retries and Follow-Ups — 重试和后续跟踪事件

OkHttp 具有可恢复性,可以自动从某些连接故障中恢复。在这种情况下,connectFailed() 事件不是终点,而且后面不会跟随 callFailed()。尝试重试时,事件监听器将收到多个相同类型的事件。

单个 HTTP 调用可能需要进行后续请求以处理身份验证,重定向和 HTTP 层超时。在这种情况下,可以尝试多个连接,请求和响应。后续请求是单个任务可能触发相同类型的多个事件的另一个原因。

Events Diagram

6.5 Availability — 可用性

Events 在 OkHttp 3.11 中作为公共 API 提供。未来版本可能会引入新的事件类型;你将需要覆写相应的方法来处理它们。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容