Spring Cloud OkHttp设计原理

Spring Cloud 框架最底层核心的组件就是服务调用方式,一般Spring Cloud框架采用的是HTTP的调用框架,本文将在 Spring Cloud应用场景下,介绍组件OkHttp3的设计原理。

1. Spring Cloud的接口调用工作模式

Spring Cloud接口调用基本工作方式

Spring Cloud作为组合式的分布式微服务解决方案,再服务调用上,至少需要解决如下几个环节:

  • 面向接口的编程形式
    接口调用过程,除了拼装Http请求外,为了提高接口调用的无感性,在这个环节上,目前采用的是Feign工具完成的。至于feign的工作原理,请参考我的另一篇博文:
    Spring Cloud Feign设计原理.
  • 服务负载均衡和选择机制
    作为分布式调用框架,服务消费方需要通过一定的机制知道应当调用某一特定服务提供方实例,Spring Cloud 目前采用的是 Ribbon来完成的。至于Ribbon的工作原理,请参考我的另一篇博文:
    Spring Cloud Ribbon设计原理.
  • 作为http 客户端,向服务器发起Http请求
    Http客户端在Java语言中,目前比较流行的有 Apache HttpClients components,HttpUrlConnection,OkHttp等,OkHttp 在性能、体积各方面表现比较好,采用此框架作为http 客户端是一个不错的选择。本文将深入OkHttp的底层设计原理,通过分析整理出它的最佳打开方式。

2. 什么是OkHttp,它有什么特点?

OkHttp是square公司开发的一个同时支持Http和Http2协议的Java客户端,可用于Android和Java应用中。
OKHttp有如下几个特性:

  • 支持Http1.1、SPDY,和Http2
  • 内部采用连接池机制,能够缓存和复用Tcp/IP连接,减少请求延迟。
  • 支持GZIP格式压缩,减少数据传输大小
  • 对重复请求返回结果进行缓存,减少交互次数
  • OKHttp底层采用DNS反解析,当其中一个实例不可用时,会自动切换至下一个服务,有较好的连接管理能力。
  • OkHttp支持最新的TLS特性(TLS 1.3, ALPN, certificate pinning)
  • 同时支持同步调用和异步调用两种方式

3. Okhttp3的设计原理

本章节将详细介绍OkHttp3底层的设计原理,并结合设计原理,总结在使用过程中应当注意的事项。

3.1 Ohttp3的的基本工作流程

以如下的简单交互代码为例,OkHttp3的简单工作方式如下所示:

        //Step1:初始化连接池
        ConnectionPool connectionPool = new ConnectionPool(50, 5, TimeUnit.MINUTES);
        OkHttpClient.Builder
            builder = new OkHttpClient.Builder().connectionPool(connectionPool);
        //Step2:创建Client
        OkHttpClient client = builder.build();
        //Step3:构造请求
        Request request = new Request.Builder()
            .url("http://www.baidu.com")
            .build();
        //Step4:发送请求
        Response response = client.newCall(request).execute();
        String result = response.body().string();
        System.out.println(result);

根据上述的流程,其内部请求主要主体如下所示:


2019-05-21_231457.png

OkHttp3在请求处理上,采用了拦截器链的模式来处理请求,拦截器链中,负责通过http请求调用服务方,然后将结果返回。

3.2 okHttp3的拦截器链

2019-05-21_233339.png

OkHttp3的核心是拦截器链,通过拦截器链,处理Http请求:

  • RetryAndFollowUpInterceptor,重试和重定向拦截器,主要作用是根据请求的信息,创建StreamAllocationAddress实例;
  • BridgeInterceptor 请求桥接拦截器,主要是处理Http请求的Header头部信息,处理Http请求压缩和解析;
  • CacheInterceptor 缓存拦截器,此拦截器借助于Http协议的客户端缓存定义,模拟浏览器的行为,对接口内容提供缓存机制,提高客户端的性能;
  • ConnectInterceptor 连接拦截器,负责根据配置信息,分配一个Connection实例对象,用于TCP/IP通信。
  • CallServerInterceptor 调用服务端拦截器,该拦截器负责向Server发送Http请求报文,并解析报文。

CallServerInterceptor拦截器底层使用了高性能的okio(okhttp io components)子组件完成请求流的发送和返回流的解析。

3.3 OkHttp3的内部核心架构关系

作为拦截器链的展开,下图展示了OKHttp3的核心部件及其关系:


2019-05-21_232649.png

上述架构图中,有如下几个概念:

  • StreamAllocation 当一个请求发起时,会为该请求创建一个StreamAllocation实例来表示其整个生命周期;
  • Call 该对象封装了对某一个Http请求,类似于command命令模式;
  • RequestResponseCall被执行时,会转换成Request对象, 执行结束之后,通过Response对象返回表示
  • HttpCodec 处理上述的RequestResponse,将数据基于Http协议解析转换
  • Stream 这一层是okio高性能层进行io转换处理,聚焦于SourceSink的处理
  • Address okhttp3对于调用服务的地址封装,比如www.baidu.com则表示的百度服务的Address
  • Route 框架会对Address判断是否DNS解析,如果解析,一个Address可能多个IP,每一个IP被封装成Route
  • RouteSelector 当存在多Route的情况下,需要定义策略选择Route
  • Connection 表示的是Http请求对应的一个占用ConnectionConnection的分配时通过Connnection Pool获取
  • Connection Pool 维护框架的连接池

3.4 OKhttp3的网络连接的抽象

image.png

OKHttp3对网络连接过程中,涉及到的几种概念:

  • 请求URL:OKHttp3 是处理URL请求的HTTP请求的基础,URL的格式遵循标准的HTTP协议。对于某个HTTP服务器而言,会提供多个URL地址链接。URL协议中,基本格式为http(s)://<domain-or-ip>:<port>/path/to/service,其中<domain-or-ip>则表示的是服务器的地址 Adress
  • Address(地址): 即上述的<domain-or-ip>,表示服务的域名或者IP
  • Route (路由) :当URL中的<domain-or-ip>是domain时,表示的是服务的域名,而域名通过DNS解析时,可能会解析出多个IP,也就是说一个Address可以映射到多个Route,一个Route 表示的是一个机器IP,用于建立TCP/IP网络连接
  • Connection:Connection表示的是一个Socket连接通信实例
  • Connection Pool: 对于Connection实例,统一维护在连接池中, OKHttp的连接池比较特殊,详情参考后续章节。

3.5 连接池的工作原理

image.png

在OKHttp3内部使用了双端队列管理连接池,也就是说 连接池没有数量的限制
那既连接数量的限制,OKHttp3是怎么保证队列内存不溢出呢?

3.5.1 连接池的连接清空机制

连接池通过最大闲置连接数(maxIdleConnections)保持存活时间(keepAliveDuration)来控制连接池中连接的数量。
在连接池的内部,会维护一个守护线程,当每次往线程池中添加新的连接时,将会触发异步清理闲置连接任务。


private final Runnable cleanupRunnable = new Runnable() {
    @Override public void run() {
      while (true) {
        //执行清空操作,返回下次执行清空的时间
        long waitNanos = cleanup(System.nanoTime());
        if (waitNanos == -1) return;
        if (waitNanos > 0) {
          long waitMillis = waitNanos / 1000000L;
          waitNanos -= (waitMillis * 1000000L);
          //将当前清理线程睡眠指定的时间片后再唤醒
          synchronized (ConnectionPool.this) {
            try {
              ConnectionPool.this.wait(waitMillis, (int) waitNanos);
            } catch (InterruptedException ignored) {
            }
          }
        }
      }
    }
  };
/**
   * Performs maintenance on this pool, evicting the connection that has been idle the longest if
   * either it has exceeded the keep alive limit or the idle connections limit.
   *
   * <p>Returns the duration in nanos to sleep until the next scheduled call to this method. Returns
   * -1 if no further cleanups are required.
   */
  long cleanup(long now) {
    int inUseConnectionCount = 0;
    int idleConnectionCount = 0;
    RealConnection longestIdleConnection = null;
    long longestIdleDurationNs = Long.MIN_VALUE;

    // Find either a connection to evict, or the time that the next eviction is due.
    synchronized (this) {
      //遍历连接池中的每个连接
      for (Iterator<RealConnection> i = connections.iterator(); i.hasNext(); ) {
        RealConnection connection = i.next();

        // If the connection is in use, keep searching.
        if (pruneAndGetAllocationCount(connection, now) > 0) {
          inUseConnectionCount++;
          continue;
        }

        idleConnectionCount++;
       
        // If the connection is ready to be evicted, we're done.
        long idleDurationNs = now - connection.idleAtNanos;
       //计算连接的累计闲置时间,统计最长的闲置时间 
       if (idleDurationNs > longestIdleDurationNs) {
          longestIdleDurationNs = idleDurationNs;
          longestIdleConnection = connection;
        }
      }
      //如果闲置时间超过了保留限额 或者闲置连接数超过了最大闲置连接数值
      if (longestIdleDurationNs >= this.keepAliveDurationNs
          || idleConnectionCount > this.maxIdleConnections) {
        // We've found a connection to evict. Remove it from the list, then close it below (outside
        // of the synchronized block).
        //从连接池中剔除当前连接
        connections.remove(longestIdleConnection);
      } else if (idleConnectionCount > 0) {
        // 如果未达到限额,返回移除时间点
        // A connection will be ready to evict soon.
        return keepAliveDurationNs - longestIdleDurationNs;
      } else if (inUseConnectionCount > 0) {
        // All connections are in use. It'll be at least the keep alive duration 'til we run again.
        // 都在使用中,没有被清理的,则返回保持存活时间
        return keepAliveDurationNs;
      } else {
        // No connections, idle or in use.
        cleanupRunning = false;
        return -1;
      }
    }

    closeQuietly(longestIdleConnection.socket());

    // Cleanup again immediately.
    return 0;
  }

默认情况下:

  • 最大闲置连接数(maxIdleConnections):5
  • 保持存活时间(keepAliveDuration):5(mins)

连接池(Connection Pool)的工作原理

  1. 当某一个Http请求结束后,对应的Connection实例将会标识成idle状态,然后连接池会立马判断当前连接池中的处于idle状态的Connection实例是否已经超过 maxIdleConnections 阈值,如果超过,则此Connection实例 将会被释放,即对应的TCP/ IP Socket通信也会被关闭。
  2. 连接池内部有一个异步线程,会检查连接池中处于idle实例的时长,如果Connection实例时长超过了keepAliveDuration,则此Connection实例将会被剔除,即对应的TCP/ IP Socket通信也会被关闭。
3.5.2 连接池使用注意事项

对于瞬时并发很高的情况下,okhttp连接池中的TCP/IP连接将会冲的很高,可能和并发数量基本一致。但是,当http请求处理完成之后,连接池会根据maxIdleConnections来保留Connection实例数量。maxIdleConnections的设置,应当根据实际场景请求频次来定,才能发挥最大的性能。

假设我们的连接池配置是默认配置,即:最大闲置连接数(maxIdleConnections):5,保持存活时间(keepAliveDuration):5(mins);
当前瞬时并发有100个线程同时请求,那么,在okhttp内创建100个 tcp/ip连接,假设这100个线程在1s内全部完成,那么连接池内只有5tcp/ip连接,其余的都将释放;在下一波50个并发请求过来时,连接池只有5个可以复用,剩下的95个将会重新创建tcp/ip连接,对于这种并发能力较高的场景下,最大闲置连接数(maxIdleConnections)的设置就不太合适,这样连接池的利用率只有5 /50 *100% = 10%,所以这种模式下,okhttp的性能并不高。
所以,综上所述,可以简单地衡量连接池的指标:

连接池的利用率 = maxIdleConnections / 系统平均并发数
说明:根据上述公式可以看出,利用率越高, maxIdleConnections系统平均并发数 这两个值就越接近,即:maxIdleConnections 应当尽可能和系统平均并发数相等。

3.6 spring cloud对连接池的设置

Spring cloud在对这个初始化的过程比较开放,默认的大小是200,具体的指定关系和其实现关系。

package org.springframework.cloud.commons.httpclient;

import okhttp3.ConnectionPool;

import java.util.concurrent.TimeUnit;

/**
 * Default implementation of {@link OkHttpClientConnectionPoolFactory}.
 * @author Ryan Baxter
 */
public class DefaultOkHttpClientConnectionPoolFactory implements OkHttpClientConnectionPoolFactory {

    @Override
    public ConnectionPool create(int maxIdleConnections, long keepAliveDuration, TimeUnit timeUnit) {
        return new ConnectionPool(maxIdleConnections, keepAliveDuration, timeUnit);
    }
}

在设置上,共有两个地方可以指定连接参数:

  • 基于ribbon的 maxTotalConnections值,默认为 :200;
  • 基于feign的 getMaxConnections 值,默认为:200
3.6.1 基于ribbon和okhttp的配置(ribbon.okhttp.enabled开启配置):
@Configuration
@ConditionalOnProperty("ribbon.okhttp.enabled") //开启参数
@ConditionalOnClass(name = "okhttp3.OkHttpClient")
public class OkHttpRibbonConfiguration {
    @RibbonClientName
    private String name = "client";

    @Configuration
    protected static class OkHttpClientConfiguration {
        private OkHttpClient httpClient;

        @Bean
        @ConditionalOnMissingBean(ConnectionPool.class)
        public ConnectionPool httpClientConnectionPool(IClientConfig config,
                                                       OkHttpClientConnectionPoolFactory connectionPoolFactory) {
            RibbonProperties ribbon = RibbonProperties.from(config);
                        //使用了ribbon的 maxTotalConnections作为idle数量,ribbon默认值为200
            int maxTotalConnections = ribbon.maxTotalConnections();
            long timeToLive = ribbon.poolKeepAliveTime();
            TimeUnit ttlUnit = ribbon.getPoolKeepAliveTimeUnits();
            return connectionPoolFactory.create(maxTotalConnections, timeToLive, ttlUnit);
        }

        @Bean
        @ConditionalOnMissingBean(OkHttpClient.class)
        public OkHttpClient client(OkHttpClientFactory httpClientFactory,
                                   ConnectionPool connectionPool, IClientConfig config) {
            RibbonProperties ribbon = RibbonProperties.from(config);
            this.httpClient = httpClientFactory.createBuilder(false)
                    .connectTimeout(ribbon.connectTimeout(), TimeUnit.MILLISECONDS)
                    .readTimeout(ribbon.readTimeout(), TimeUnit.MILLISECONDS)
                    .followRedirects(ribbon.isFollowRedirects())
                    .connectionPool(connectionPool)
                    .build();
            return this.httpClient;
        }
    }
}
3.6.2 基于feign的OKHttp配置(feign.okhttp.enabled参数开启)
@Configuration
    @ConditionalOnClass(OkHttpClient.class)
    @ConditionalOnMissingClass("com.netflix.loadbalancer.ILoadBalancer")
    @ConditionalOnMissingBean(okhttp3.OkHttpClient.class)
    @ConditionalOnProperty(value = "feign.okhttp.enabled")
    protected static class OkHttpFeignConfiguration {

        private okhttp3.OkHttpClient okHttpClient;

        @Bean
        @ConditionalOnMissingBean(ConnectionPool.class)
        public ConnectionPool httpClientConnectionPool(FeignHttpClientProperties httpClientProperties,
                                                       OkHttpClientConnectionPoolFactory connectionPoolFactory) {
            Integer maxTotalConnections = httpClientProperties.getMaxConnections();
            Long timeToLive = httpClientProperties.getTimeToLive();
            TimeUnit ttlUnit = httpClientProperties.getTimeToLiveUnit();
            return connectionPoolFactory.create(maxTotalConnections, timeToLive, ttlUnit);
        }

        @Bean
        public okhttp3.OkHttpClient client(OkHttpClientFactory httpClientFactory,
                                           ConnectionPool connectionPool, FeignHttpClientProperties httpClientProperties) {
            Boolean followRedirects = httpClientProperties.isFollowRedirects();
            Integer connectTimeout = httpClientProperties.getConnectionTimeout();
            Boolean disableSslValidation = httpClientProperties.isDisableSslValidation();
            this.okHttpClient = httpClientFactory.createBuilder(disableSslValidation).
                    connectTimeout(connectTimeout, TimeUnit.MILLISECONDS).
                    followRedirects(followRedirects).
                    connectionPool(connectionPool).build();
            return this.okHttpClient;
        }

        @PreDestroy
        public void destroy() {
            if(okHttpClient != null) {
                okHttpClient.dispatcher().executorService().shutdown();
                okHttpClient.connectionPool().evictAll();
            }
        }

        @Bean
        @ConditionalOnMissingBean(Client.class)
        public Client feignClient(okhttp3.OkHttpClient client) {
            return new OkHttpClient(client);
        }
    }

4.总结

以上关于OkHttp3的论述,仅代表个人观点,作者水平有限,如有错误,欢迎批评指正。


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

推荐阅读更多精彩内容

  • 前言 用OkHttp很久了,也看了很多人写的源码分析,在这里结合自己的感悟,记录一下对OkHttp源码理解的几点心...
    Java小铺阅读 1,433评论 0 13
  • 这篇文章主要讲 Android 网络请求时所使用到的各个请求库的关系,以及 OkHttp3 的介绍。(如理解有误,...
    小庄bb阅读 1,084评论 0 4
  • 用OkHttp很久了,也看了很多人写的源码分析,在这里结合自己的感悟,记录一下对OkHttp源码理解的几点心得。 ...
    蓝灰_q阅读 4,166评论 4 34
  • 4月8日,我们开始游学了。这是一次五天四晚的游学,成都双飞。宽窄巷子,三星堆古遗址,杜甫草堂,川剧博物馆,川剧表演...
    北辰_9e51阅读 297评论 2 1
  • 惯性思维: 1、成功者走的是这条路,我往这走肯定也能行。【忽略了背景,时机】 2、那条路好像风景更美,这条路坑坑洼...
    馥郁青竹阅读 318评论 0 1