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閱讀 227,748評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,165評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,595評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,633評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,435評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,943評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,035評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,175評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,713評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,599評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,788評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,303評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,034評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,412評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,664評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,408評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,747評論 2 370

推薦閱讀更多精彩內容