带着协议学习 OkHttp

Retrofit 实际上就是高度封装的 OkHttp,每次用到都是到处搜搜就写上,后面发现从协议出发才能真正地弄懂这些 api

1. OkHttp发出请求概览

使用okhttp发起一次请求并获取请求结果,一般而言我们需要使用到五个类

  • OkHttpClient

    Call 的工厂类

  • request

    http 请求的实例

  • Call (OkHttp3)

    Call 是代表已经准备好执行并且可能正在执行的请求

    可以使用 cancel 关闭这个请求。实际上Http请求由于链路的不可靠性,OkHttp协助你实现的请求封装并不是机械地添加上必须的 Content-Length 等 header 直接发出后等待。而是会协助进行一系列的操作

    • 重写请求

      添加上必须的header,例如Content-LengthEncoding,原来的请求如果没有添加Accept-Encoding类型时,OkHttp也会自动添加Accept-Encoding:Gzip,以此希望服务器能返回压缩数据

    • 重写响应

      服务器返回压缩数据时,OkHttp将自动解压并去掉解压前留下的 Content-Length header。

    • 跟踪请求

      请求返回 302 Found 并携带有 资源移动后的 url 时,OkHttp会自动按照新的url进行一次请求

  • Callback

    异步请求中的回调接口

  • Response

    http 请求返回信息的实例,主要包含 http 的 header 和 body

private final OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
    .url("baseUrl")
    .build();
// 同步请求
Response response = client.newCall(request).execute();
if(!respnse.isSuccessful()){
    // To handle the request error
}
// 异步请求
client.newCall(request).enqueue(new Callback(){
    // 重写onFailure()和onResponse()
    // 异步请求的request在onResponse()中可以获取
});

2. http 首部

下面是一个服务器返回的首部示例

304 Not Modified
Access-Control-Allow-Origin: *
Age: 2318192
Cache-Control: public, max-age=315360000
Connection: keep-alive
Date: Mon, 18 Jul 2016 16:06:00 GMT
Server: Apache
Vary: Accept-Encoding
Via: 1.1 3dc30c7222755f86e824b93feb8b5b8c.cloudfront.net (CloudFront)
X-Amz-Cf-Id: TOl0FEm6uI4fgLdrKJx0Vao5hpkKGZULYN2TWD2gAWLtr7vlNjTvZw==
X-Backend-Server: developer6.webapp.scl3.mozilla.com
X-Cache: Hit from cloudfront
X-Cache-Info: cached

大部分的http首部包含的键值对 - name:value 都不会有键的重复,一般而言可以用 HashMap 进行存储,但是http协议允许首部中存在相同的 name,所以OkHttp采用的策略是在需要时使用 Multimap 存储首部,Multimap 是一种允许包含重复的键的键值表。

2.1 操作首部

  • 添加

    对 Request 添加首部

    Request request = new Request.Builder().url("baseUrl")
        // 添加首部
        .addHeader("Uer-Agent", "OkHttp Headers.java")
        // 添加首部
        // 与 addHeader 不同的是,该方法会覆盖掉相同键的首部
        .header("Accept", "application/json; q=0.5")
        .build();
    
  • 获取

    从 Response 获取首部

    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);
            // 获取首部。也可以使用 header() 方法但 header() 只会返回一个首部
            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());
        }
    });
    

3. Post 请求

下面是一个客户端发送的 Post 请求示例

POST https://127.0.0.1:8080 HTTP/1.1
cache-control: no-cache
Postman-Token: 305a878a-5fb7-4ba708476-384fbf15cb47
Content-Type: application/x-www-form-urlencoded
User-Agent: PostmanRuntime/6.4.2
Accept: */*
Host: 127.0.0.1:8080
accept-encoding: gzip, deflate
content-length: 28
Connection: keep-alive

hereIsBody=true

Post 请求包含 header 和 body 两部分,与 get 请求直接在url中传递数据不同的地方在于 post 的数据一般是放在 body 中

3.1 添加 Body

如果 Post 需要携带 Body,需要操作 Request 类,添加可以添加一个 RequestBody 来自定义 body

public static final MediaType MEDIA_TYPE = MediaType.parse("text/plain; charset=utf-8");

Request request = new Request.Builder()
    .url("TargetUrl")
    .post(RequestBody.create(MEDIA_TYPE,"MyBody"))
    .build();

值得注意的是,虽然 http 是超文本协议并且也允许传输文件,但是文件如果直接转换成 string 载入到内存中,不仅会造成卡顿还可能造成OOM。这里 RequestBody.create() 方法有多个重载,支持的body有 String,ByteString,byte[],File

3.2 提交表单

表单内容虽然可以由我们自己生成url,再于RequestBody.create() 时设置MediaType为"application/x-www-form-urlencoded" 来提交,但 OkHttp 也提供了基本的url生成方法,下面是一个示例

RequestBody formBody = new FormBody.Builder()
    .add("userName","admin")
    .add("userLevel","admin")
    .build();
Response response = client.newCall(request).execute();

3.3 提交 multipart/form-data 类型

multipart/form-data也是前后端交互中常见的类型。下面是一个携带有该类型数据的POST请求示例,其中boundary的值没有固定要求,这里设置的是 ----myBoundary----

POST http://127.0.0.1:8080 HTTP/1.1
Content-Type:multipart/form-data; boundary=----myBoundary----

----myBoundary----
Content-Disposition: form-data; name="file"; filename="tmp.png"
Content-Type: image/png

...content of tmp.png...
----myBoundary----
Content-Disposition: form-data; name="city_id"

1
----myBoundary----

如果要提交上述内容,可以模仿下面这个示例

static final MediaType MEDIA_TYPE_PNG = MediaType.parse("image/png");
RequestBody requestBody = new MultipartBody.Builder()
    .setType(MultipartBody.FORM)
    .addFormDataPart("title", "传输测试")
    .addFormDataPart("image", "logo.png",
                    RequestBody.create(MEDIA_TYPE_PNG, new File("pngurl/logo.png")))
    .build();

Request request = new Request.Builder()
    .url("https://127.0.0.1:8080/")
    .post(requestBody)
    .build();

4. 使用 Cache

之前展示的html首部中都包含有 cache-control: 这个响应头,这个响应头允许多种指令,下面是最常见的几种

  • cache-control: no-cache

    资源会被缓存,但是使用前需要询问服务器是否有效,有效服务器返回 HTTP/304 Not Modified,无效服务器返回 HTTP/200 OK

  • cache-control: no-store

    资源不允许被缓存,每次使用都需要请求进行该资源的重新下载

  • cache-control: max-age

    使用秒为单位定义响应资源的有效时间

  • cache-control: public / private

    public 标注的资源可以被 CDN 以及 代理服务器 缓存,而 private 标注的资源只允许 浏览器端缓存

具体的使用可以参考 Prevent unnecessary network requests with the HTTP Cache 这篇文章

OkHttp 开启缓存需要定义一个私有的缓存目录,以及允许缓存文件的大小,下面是一个简单的开启缓存的示例

private val client: OkHttpClient = OkHttpClient.Builder()
    .cache(Cache(
        directory = File(application.cacheDir, "http_cache"),
        maxSize = 50L * 1024L * 1024L // 50 MiB
    ))
    .build()

5. Https 支持

TLS (Transport Layer Security Protocol)
SSL (Secure Sockets Layer)

OkHttp实际上协助开发者兼容了 boringsslOpenSSL 等多种以及多版本的 SSL/TLS 实现库,以便开发者在不了解底层协议的情况下与多种使用不同 SSL/TLS 实现库的服务器进行通信,如果需要配置可以参考以下官方示例

// ConnectionSpec 即连接规格,该类中存储的字段用来大概地确定OkHttp使用的SSL/TLS的版本
// 使用这个类来指定可以让代码不需要跟随协议的更新而更新,而交由OkHttp来实现
OkHttpClient client = new OkHttpClient.Builder()
    .connectionSpec(Arrays.asList(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
    .build();
// 如果需要自定义 ConnectionSpec,参考下面的示例
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();

实际上 SSL/TLS 版本多年来经历了多次更新
SSL3 (1995) -> TLS1.0 (1999) -> TLS1.1(2006) -> TLS1.2 (2008) -> TLS1.3 (2018)
下面两个握手示例,分别为 TLS1.2 和 TLS1.3。TLS1.2的示例来自 RFC 5246 第35面,TLS1.3的示例来自 RFC 8446 第10面

      Client                                               Server

      ClientHello                  -------->
                                                      ServerHello
                                                     Certificate*
                                               ServerKeyExchange*
                                              CertificateRequest*
                                   <--------      ServerHelloDone
      Certificate*
      ClientKeyExchange
      CertificateVerify*
      [ChangeCipherSpec]
      Finished                     -------->
                                               [ChangeCipherSpec]
                                   <--------             Finished
      Application Data             <------->     Application Data
       Client                                           Server

Key  ^ ClientHello
Exch | + key_share*
     | + signature_algorithms*
     | + psk_key_exchange_modes*
     v + pre_shared_key*       -------->
                                                  ServerHello  ^ Key
                                                 + key_share*  | Exch
                                            + pre_shared_key*  v
                                        {EncryptedExtensions}  ^  Server
                                        {CertificateRequest*}  v  Params
                                               {Certificate*}  ^
                                         {CertificateVerify*}  | Auth
                                                   {Finished}  v
                               <--------  [Application Data*]
     ^ {Certificate*}
Auth | {CertificateVerify*}
     v {Finished}              -------->
       [Application Data]      <------->  [Application Data]

              +  Indicates noteworthy extensions sent in the
                 previously noted message.

              *  Indicates optional or situation-dependent
                 messages/extensions that are not always sent.

              {} Indicates messages protected using keys
                 derived from a [sender]_handshake_traffic_secret.

              [] Indicates messages protected using keys
                 derived from [sender]_application_traffic_secret_N.

可以看到使用 TLS1.3 时,可以减少客户端和服务器之间的一次 Hello 握手,网上搜索到的大部分关于 HTTPS 的解释都是对 TLS1.2 进行的。

实际上 Android 4.4.2 (2014.6) 开始才支持 TLS1.2 协议,Android 8.1 (2017.12) 开始支持 TLS 1.3,彼时 TLS1.3 的 RFC 尚处于草案阶段,可见近年来 Android 越来越注重于用户的信息安全。如果需要详细的协议支持表,可以访问 SSL Labs

6. OkHttp 拦截器

拦截器是 OkHttp 非常重要的一个功能拓展,开发者可以用它监听,改写和手动重试请求。我们定义完拦截器后将它添加到对应的 Client 中即可

val myClient = okHttpBuilder
    .addInterceptor(MyAppInterceptor())
    .addNetworkInterceptor(MyNetInterceptor())
    .build()

代码中添加的两种拦截器分别是应用拦截器和网络拦截器,官方文档中这张视图非常好地展示了两种拦截器生效的位置

图中的灰块代表拦截器,和视图一致的是,拦截器是链式调用的,OkHttp会按照拦截器添加的顺序,依次分发内容给对应的拦截器。这意味着如果有用于压缩和加密的两个拦截器,就可以控制压缩和加密的先后顺序。

下面是一个简单的拦截器,记录了所有使用了该拦截器的 Client 收发的信息,该代码样例来自于官方文档

private class MyInterceptor:Interceptor{
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val timeStart = System.nanoTime()
        Log.i(TAG, "intercept: sending request ${request.url()} on ${chain.connection()}\nheader=${request.headers()}")
        val response = chain.proceed(request)
        val timeEnd = System.nanoTime()
        Log.i(TAG, "intercept: received response for ${response.request().url()} ${(timeEnd-timeStart)/0x1e6d}\nheader=${response.headers()}")
        return response
    }
}

拦截器中有个关键的方法调用,即chain.proceed(request),该方法调用时会将拦截到的请求传递到 OkHttp core,从而产生实际的 http 请求

6.1 应用拦截器和网络拦截器的不同之处

之前提到的OkHttp代替我们进行的操作,例如请求压缩文件,自动重定向,自动添加必要首部都在上图中的 OkHttp core 部分,用户拦截器可以只关注于应用表面上发送和收到的数据。而如果要获取到实际的首部和重定向的次数,则需要使用网络拦截器。

我们可以试着将这个日志用拦截器分别添加为应用拦截器和网络拦截器,执行一次请求

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

Request request = new Request.Builder()
    // 这里测试的url来自于wanandroid提供的http url api
    // 如果想本地测试,名称和密码请自行注册
    .url("https://www.wanandroid.com/user/login/?username=myName&password=myPwd")
    .build();

// 请在非 UI 线程中执行下面的阻塞代码
Response response = client.newCall(request).execute();
response.body().close();

得到的内容为

  • 网络拦截器

    2021-07-21 17:32:36.860 9513-9598/com.example.retrofitjava I/RetrofitManager: intercept: sending request https://www.wanandroid.com/user/login/?username=myName&password=myPwd on Connection{www.wanandroid.com:443, proxy=DIRECT hostAddress=www.wanandroid.com/47.104.74.169:443 cipherSuite=TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA protocol=http/1.1}
        header=Content-Length: 0
        Host: www.wanandroid.com
        Connection: Keep-Alive
        Accept-Encoding: gzip
        User-Agent: okhttp/3.14.9
    2021-07-21 17:32:37.234 9513-9598/com.example.retrofitjava I/RetrofitManager: intercept: received response for https://www.wanandroid.com/user/login/?username=myName&password=myPwd 48069
        header=Server: Apache-Coyote/1.1
        Set-Cookie: JSESSIONID=69FCD58ECA50172EC63ECB24F0395132; Path=/; Secure; HttpOnly
        Set-Cookie: loginUserName=2642981383; Expires=Fri, 20-Aug-2021 09:32:36 GMT; Path=/
        Set-Cookie: token_pass=630dd3ec7b0ec6692be5c9d8b90dc6a6; Expires=Fri, 20-Aug-2021 09:32:36 GMT; Path=/
        Set-Cookie: loginUserName_wanandroid_com=myName; Domain=wanandroid.com; Expires=Fri, 20-Aug-2021 09:32:36 GMT; Path=/
        Set-Cookie: token_pass_wanandroid_com=630dd3ec7b0ec6692be5c9d8b90dc6a6; Domain=wanandroid.com; Expires=Fri, 20-Aug-2021 09:32:36 GMT; Path=/
        Content-Type: application/json;charset=UTF-8
        Transfer-Encoding: chunked
        Date: Wed, 21 Jul 2021 09:32:35 GMT
    2021-07-21 17:32:41.076 9513-9556/com.example.retrofitjava I/le.retrofitjav: ProcessProfilingInfo new_methods=1869 is saved saved_to_disk=1 resolve_classes_delay=5000
    
  • 应用拦截器

    可以看到必要的首部实际上都是由 OkHttp core 代替我们添加的

    2021-07-21 17:42:40.339 10721-10805/com.example.retrofitjava I/RetrofitManager: intercept: sending request https://www.wanandroid.com/user/login/?username=myName&password=myPwd on null
        header=
    2021-07-21 17:42:41.074 10721-10805/com.example.retrofitjava I/RetrofitManager: intercept: received response for https://www.wanandroid.com/user/login/?username=myName&password=myPwd 94317
        header=Server: Apache-Coyote/1.1
        Set-Cookie: JSESSIONID=495F705ABEFFFB94DDEA209BA60C6D62; Path=/; Secure; HttpOnly
        Set-Cookie: loginUserName=2642981383; Expires=Fri, 20-Aug-2021 09:42:40 GMT; Path=/
        Set-Cookie: token_pass=630dd3ec7b0ec6692be5c9d8b90dc6a6; Expires=Fri, 20-Aug-2021 09:42:40 GMT; Path=/
        Set-Cookie: loginUserName_wanandroid_com=myName; Domain=wanandroid.com; Expires=Fri, 20-Aug-2021 09:42:40 GMT; Path=/
        Set-Cookie: token_pass_wanandroid_com=630dd3ec7b0ec6692be5c9d8b90dc6a6; Domain=wanandroid.com; Expires=Fri, 20-Aug-2021 09:42:40 GMT; Path=/
        Content-Type: application/json;charset=UTF-8
        Transfer-Encoding: chunked
        Date: Wed, 21 Jul 2021 09:42:39 GMT
    

6.2 通过拦截器更改请求

用户发起的请求封装在 Request 对象中,由于拦截器重写的 intercept() 方法中可以获得到 Chain 从而获得到用户或者上一个拦截器发出的 Request。我们可以在拦截器中重新封装一次 Request 再使用 chain.procceed() 发出。所以拦截器可以添加,移除甚至替换掉 header,也可以更改掉 body。

下面展示的例子中,在原有body存在,并且header中没有标注内容已加密时,对原有请求的body进行压缩

private class ProcessInterceptor:Interceptor{
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        if (originalRequest.body()==null ||
            originalRequest.header("Content-Encoding")!=null){
            return chain.proceed(originalRequest)
        }
        val compressedRequest = originalRequest.newBuilder()
            .header("Content-Encoding","gzip")
            //保持原有request的get或者post请求
            .method(originalRequest.method(), gzip(originalRequest.body()))
            .build()
        return chain.proceed(compressedRequest)
    }
}

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