如何进行网络框架的学习和设计

2020 年 11 月 23 日

如何进行网络框架的学习和设计

1 前言


对框架的认知,分为三层:


第一层:会用 。了解它的功能,知道怎么调用。


第二层:知道实现 。通过研究源码,知道它怎么实现的。


第三层:理解为什么 。为什么要有这个框架?如果没有这个框架,我们如何实现它的功能以及实现的成本有多高?框架内部为什么这么设计?有没有更好的设计?


要认知到第三层,最好的方式就是先抛弃这个框架,撸起袖子自己干,从零设计一个这样的框架,最后回过头来,再去思考这个框架存在的意义,以及它内部的设计。


比如网络框架,它的基本功能是调用服务器的接口获取数据,在 APP 开发中,这个功能不需要依赖任何额外框架就可以轻松实现,但是在实际项目中,能满足这个基本功能是远远不够的,还有很多其他的事情需要考虑,比如线程调度、数据加工、易用性、可扩展性等。


下面我们先从零设计一个网络框架,然后再去对照 okhttp 和 retrofit 的源码,思考它们存在的意义以及它们的设计,最后我们再挖掘下 okhttp 和 retrofit 预留给我们的扩展能力,举例一些常见的应用场景。更进一步,我们再思考下它还有哪些不足,以及如何改进。


2 从零设计网络框架


第一步:请求网络


请求一个接口,我们要知道接口的 url,接口的方法,接口需要的数据,把这些封装起来,就是 Request 类;


服务器响应后,要返回结果和数据给我们,这个数据的格式和内容约定,就是 Response 类;


最后,我们需要一个请求器,去向后端服务发起请求,把 Request 转换成 Response。



第二步:线程调度


上面的设计,功能上没有问题,但实际的项目中没法用,因为请求器的任务,是个耗时操作,并且会多个任务并发执行,所以需要用线程池来做线程的复用和调度。



第三级:Request 和 Response 的加工


上面的设计,能够满足并发的网络请求了。但实际的项目中,Request 并不是只携带业务数据就可以了,还要携带很多通用的数据,比如设备信息、用户信息、验签数据等;还可能会对数据进行加工,比如加密。相应的,Response 并不是直接抛给业务处理就可以,它可能需要经过一些加工才能给业务,比如解密、转换成 Bean 等。如下图所示:



这些逻辑都是通用的,不应该留给业务去重复实现。


抽象下整个流程如下图所示:Request 从业务点出发,经过层层加工,到达请求器,然后请求器将 Request 转换成 Response,然后 Response 又经过层层加工,最后传给了业务。



我们可以设计一个加工列表,列表中的每一个元素就是一个加工器,可分为 Request 加工器和 Response 加工器,分别对 Request 和 Response 对象进行加工。然后让网络请求的过程遍历这个加工器列表。


但这样的设计有点啰嗦,并且每个加工器只能单向加工 Request 或者 Response。


有没有更优雅的方式?


在上面的抽象流程图中,以请求器为中心,两边是不是对称的?即使不对称,也可以在另一边配一个空的加工器来让整个链条中心对称。


如果以请求器为中心,将这个链条对折,如下图:



然后将上下对称的两个加工器,合并成一个:



合并起来的加工器,就不再是单向的了,它已经自成一体了,可以将一个 Request,转换成一个 Response,也就是说每一个合并起来的加工器,都变成了一个独立的请求器。


这种模式,就是责任链模式,每个合并起来的加工器,就是一个 Chain。


每一个 Chain 都是完整的,它可以自己决定如何将 Request 转换成 Response:


  1. 调用下一个Chain去转换。

  2. 自己制造一个Response返回,中断链条的传递。

  3. 实际上只有最后一个Chain(上面的Chain3),才是真正发送了请求到服务器的,但丝毫不妨碍前面的每个Chain都可以自己伪造一个Reponse直接返回,不再继续调用下一个Chain。

  4. 下游的实现细节,对上游完全透明,不管后面有多少个Chain,不管每个Chain中具体做什么事情,对前面的Chain来说,都是透明的。



3 okhttp 的实现


上面讲的是如何一步步设计出一个网络框架。现在我们解析下 okhttp 的设计。


注:下面的流程只按照异步调用的流程解析,同步调用的流程比较简单先省略掉。



  • 从业务出发,先构建一个Request(用Builder模式),主要包含url、method、header、body等。

  • 通过OkhttpClient的实例方法newCall(request),将request封装成一个RealCall实例。request是对请求信息的静态描述,可以多次使用;而ReaCall是对一次请求的动态封装,只能被执行一次,然后就作废了。

  • RealCall.enqueue,实际上是RealCall包装一个它的内部类AsyncCall的实例,然后将这个实例发送给Dispatcher持有的线程池, AsyncCall是实现了Runnable接口的。

  • 线程池通过线程调度,在异步线程中执行AsyncCall的run方法。

  • AsyncCall的run方法中,会调用getResponseWithInterceptorChain方法去获取Response。

  • getResponseWithInterceptorChain方法, 会动态组建拦截器列表,然后通过Chain来启动这个责任链。拦截器的顺序依次是:

  • 自定义的interceptors,这种拦截器,不要求必须执行chain.proceed来继续调用下一个拦截器,可以使责任链短路。如果要mock数据,就可以在这里插入拦截器。

  • 内置的拦截器,这些拦截器就不一一展开了。

  • 自定义的networkInterceptors,这种拦截器,必须并且只能调用一次chain.proceed, 不能使责任链短路。

  • CallServerInterceptor, 这个是真正去请求服务端的拦截器, 它必须自己将Request转换为Response,无法调用下一个链去完成了,因为它是责任链的最后一个链。


getResponseWithInterceptorChain 得到 Response 之后,会通过 callback 进行回调,但是这个 callback.onResponse 方法,仍然是在后台线程中调用的,所以业务还无法直接拿到这个 Response。


okhttp 是一个完整的网络框架,已经为业务方做了很多事情,包括网络请求、线程调度、数据加工等。


但是,okhttp 还有 3 个问题没有解决:


  1. 用url、method、header、body构建Request的过程,比较繁琐,实际代码示例如下:


HttpUrl httpUrl = new HttpUrl.Builder()   .scheme("http")   .host("localhost")   .port(3000)   .addPathSegment("generalPage")   .addQueryParameter("QueryKey1", "QueryValue1")   .addQueryParameter("QueryKey2", "QueryValue2")   .addQueryParameter("QueryKey3", "QueryValue3")   .addQueryParameter("QueryKey4", "QueryValue4")   .addQueryParameter("QueryKey5", "QueryValue5")   .addQueryParameter("QueryKey6", "QueryValue6")   .addQueryParameter("QueryKey7", "QueryValue7")   .build();
Request request = new Request.Builder() .addHeader("HeaderKey", "HeaderValue") .url(httpUrl) .build();
复制代码


  1. 时序图中的start节点和end节点,是在两个线程中的。业务方调用okhttp发送request是在主线程,而okhttp回调response给业务方,却在后台线程中。所以业务方还要再callback.onResponse中做线程的切换。


//主线程Call call = client.newCall(request);call.enqueue(new Callback() { @Override public void onResponse(@NotNull Call call, @NotNull Response response) throws IOException {   //后台线程 }
@Override public void onFailure(@NotNull Call call, @NotNull IOException e) { //后台线程 }});
复制代码


  1. 得到的Reponse,是个原始数据,还不是业务能认识的Bean对象,这就得让每个业务自己做反序列化,将原始数据(比如JSON),转换成一个Bean对象。


上面 3 个问题,正好就是 retrofit 存在的意义,retrofit 以极其优雅的方式解决了这些问题。


4 retrofit 的实现


4.1 将接口描述转换为 Call 对象


上面说过,okhttp 构造一个 Request 的代码比较繁琐,接口名、参数名都需要一个个设置。然后再用 Request 构造一个 Call 对象做请求。这个过程,在 retrofit 中做了极其优雅的封装,使用起来非常简洁。


@GET("/generalPage")Call<DemoBean> getMyData(@Query("ID") String id, @Query("type") String type);
DemoAPI demoAPI = retrofit.create(DemoAPI.class);Call<DemoBean> call = demoAPI.getMyData(id, type);
复制代码


只需要上图所示的几行代码,就可以将一个接口描述,转换为一个 Call 对象,隐藏了 Request 的构造过程。下图是 retrofit 实现这个功能的设计。



  • 业务声明一个interface方法,利用retrofit提供的注解来描述服务的接口方法、接口名、接口参数等信息。

  • 将上面的interface(比如DemoAPI)的class传给retrofit的create方法,它会用动态代理,在内存中new一个DemoAPI的实现类实例。

  • 业务拿到这个DemoAPI的实现类实例后,调用它的方法去获取call对象。DemoAPI的方法我们并没有实现,但是会被动态代理的InvocationHandler的方法拦截,所以实际是调到InvocationHandler的invoke方法中了。

  • InvocationHandler的invoke方法,调用的是ServiceMethod的invoke方法,该方法会new出一个OKHttpCall(是retrofit框架中的类,跟okhttp无关)实例,然后通过CallAdapter,将OkHttpCall,封装成ExecutorCallbackCall. OkHttpCall和ExecutorCallbackCall都是retrofit的Call接口的实现类,ExecutorCallbackCall实际是OkHttpCall的一个静态代理类,它内部持有一个OkHttpCall的实例,并且多了一个callbackExecutor,这是一个做线程切换的handler(跟线程池无关)。如果直接使用OKHttpCall的话,Response回调后仍然是在后台线程的,并没有做切换,这个下面会再讲。

  • 业务得到ExecutorCallbackCall对象之后,就可以发起网络请求了。


4.2 调用 okhttp 的流程



  • 接着上面,业务拿到ExecutorCallbackCall之后,调用它的enqueue方法发起网络请求,此时传入一个retrofit的callback A对象。

  • ExecutorCallbackCall.enqueue,内部又调用了它代理的OkhttpCall对象的enqueue方法,此时传入一个retrofit的callback B对象。

  • OkhttpCall的enqueue方法,最终调起了okhttp, 先new一个okhttp的RealCall,然后调用它的enqueue方法,此时传入的是okhttp的callback。

  • okhttp完成网络请求后,通过callback回调response给retrofit的OkHttpCall,然后OkHttpCall将response的原始数据,进行解析,比如Json数据会转换成数据Bean。

  • 然后OkhttpCall通过刚才的callback B对象,回调给ExecutorCallbackCall。

  • ExecutorCallbackCall拿到response之后,再通过它的callbackExecutor,切换到主线程,然后通过callback A对象回调给业务。


5 基于 okhttp&retrofit 做扩展


修改源码做扩展容易,但是这种方式不利于以后的升级。我们要利用 okhttp&retrofit 给我们预留的已有接口做扩展。


5.1 拦截器扩展


okhttp 最常用的扩展方式就是插入拦截器,这个不再展开了。需要说明的是,拦截器不止可以做数据加工,还可以做业务逻辑,比如预加载、鉴权等。


5.2 retrofit callAdapter 和 converter 扩展


retrofit 可以扩展 CallAdapter,从而在接口描述转换为 Call 的过程中插入自己的逻辑;也可以扩展 Converter,从而在数据解析的过程中插入自己的逻辑,比如加密解密就可以在这里做。


5.3 retrofit 注解扩展


还有一种联合起来的扩展形式,就是在 retrofit 的接口描述方法上添加自定义注解和值,然后在 okhttp 的拦截器中接收这个注解和值,然后做相应的处理。注解负责定义事件和数据,拦截器负责针对事件做相应的处理逻辑。


那么问题来了,retrofit 接口描述方法上的注解,经过 CallAdapter 转换成 Call,就已经全部丢失了,Call 被执行后,到达 okhttp 的拦截器后,只剩下一个 Request 对象了,怎么拿到 retrofit 那边的注解和参数呢?



老版本的 retrofit 是没有提供这种能力的,只能自己想办法,比如靠一个单例传递注解和参数。但是自从 retrofit 2.5, 提供了一个方法:Invocation 类.



retrofit 的 OkHttpCall, 在 new 一个 Request 的时候,会给 Request 添加一个 tag:


return requestBuilder.get().tag(Invocation.class, new Invocation(method, argumentList)).build();
复制代码


这个 tag 的 value 是一个 Invocation 对象,里面包含了接口描述方法,以及参数列表。


在 okhttp 的拦截器中,只要把 Request 对象的这个 tag 取出来,就有你需要的一切了。


Invocation invocation = request.tag(Invocation.class);//TODO invocation.method().getAnnotation();//TODO invocation.arguments();
复制代码


这种扩展方式可以做很多事情,比如为每个接口单独定义超时时间、为接口做单独的 mock 等。


5.4 一种基于路有拦截器和 okhttp 拦截器的预加载框架


APP 的一个页面打开时,主要有两个耗时任务:


  1. 页面初始化,包括加载布局文件、解析布局、创建UI元素对象等;

  2. 网络数据请求, 通过接口请求获取服务端的数据到本地,然后渲染到页面的UI元素上。


其中,2 是一个异步任务,完成后会通过回调去通知 UI 元素进行数据渲染,但是如果 UI 元素还没创建完成,也就是任务 1 还没有完全完成的时候,如果任务 2 的回调通知 UI 进行数据渲染,就会导致页面奔溃。


所以任务 2 必须依赖任务 1 完成之后才能执行,常规实现下,1 和 2 是串行执行的,所以一个页面从打开到最终展示完毕,所耗的总时间 S = S1 + S1.


总结起来有两个要做的事情:


  1. 在现有框架下将任务1和任务2改为并行执行;

  2. 管理任务1和任务2的状态,使得任务2的回调发生在任务1完全完成之后。


第一个问题的方案就是在开始页面初始化任务的同时,启动网络请求的任务。这个时机,最早可以放到路由中心,因为路由中心是第一个知道目标页面要被打开的意图的。



第二个问题的解决方案是,当网络数据返回的时候,通过网络拦截器,中断数据对 UI 元素的回调,然后查看 UI 元素的状态,如果初始化已经完成,则继续回调,不然则暂存数据,对 UI 初始化任务进行监听,等初始化完全完成之后,再进行回调。



6 同步责任链改异步


虽然 retrofit+okhttp 的框架已经非常成熟了,但是在某些业务场景下,还是会发现它无能为力的地方。


比如 okhttp 的拦截器是个同步执行的责任链,如果想在某个拦截器里进行异步交互的操作,完成后再从拦截器里的那个点继续执行责任链,这个是很难实现的。


okhttp 的责任链可以被中断,但是不能被暂停再重启。


举个例子,当服务器返回某个固定的 code 后,APP 要在拦截器中调起一个人脸识别的操作,人脸识别结束后,再从拦截器中暂定的点重发请求继续执行。人脸识别是用户操作的,返回时间是不确定的,不可能把当前链的执行线程一直给 wait 住。


要想解决这个问题,可以将 okhttp 的同步责任链改为异步回调的责任链模式。


okhttp 的 proceed 方法是这样的:


Response proceed(Request request) throws IOException {      //...       return response;}
复制代码


我们只要把同步返回改成异步回调就可以实现上面的业务场景。


public void intercept(Chain chain) throws IOException {     //...     chain.callback.onResponse(response);}
复制代码


7 写在最后


Okhttp 和 Retrofit 相对 APP 的开发同学来说,算是比较成熟的一个基础库了,网上也有很多相关的资料和文章。如何学、还需不需要学、以及如何检验掌握程度,都是一些职场新人可能会遇到的困惑。笔者能给出的建议是:永远保持一颗好奇心、坚持学以致用、在新的业务场景中不断检验和迭代认知。大家如果有其他建议和想法,欢迎在留言区交流!


本文转载自公众号贝壳产品技术(ID:beikeTC)。


原文链接


如何进行网络框架的学习和设计


2020 年 11 月 23 日 14:07847

评论

发布
暂无评论
发现更多内容

设计模式【1.3】-- 为什么饿汉式单例是线程安全的?

秦怀杂货店

单例模式

架构师训练营第十周作业

李日盛

Mybatis【10】-- Mybatis属性名和查询字段名不同怎么做?

秦怀杂货店

mybatis

讨论话题 进程通信方式和锁关系

王传义

高并发

架构师训练营第十周作业二

韩儿

dubbo服务框架图&时序图

Mars

模块分解总结

Mars

设计模式【1.2】-- 枚举式单例有那么好用么?

秦怀杂货店

设计模式

Mybatis【11】-- Mybatis Mapper动态代理怎么写?

秦怀杂货店

mybatis mybatis源码

工具词典:PARA方法论

lidaobing

PKM 28天写作营 Tiago Forte PARA

第五周总结

ty

LeetCode题解:42. 接雨水,栈,JavaScript,详细注释

Lee Chen

算法 LeetCode 前端进阶训练营

Hadoop编程实战:HDFS API编程

罗小龙

Java hdfs 编程 大数据技术 实践

架构师训练营第 10 周学习总结

菜青虫

极客大学架构师训练营

作业-第10周

arcyao

10张图带你入门分布式链路追踪系统原理

爱笑的架构师

七日更

北漂码农的我,把在大城市过成了屯子一样舒服,哈哈哈哈哈!

小傅哥

小傅哥 技术人 打工人 七日更 落户

MGR集群相关简介

Simon

MySQL 七日更

WLAN网络规划和优化的必备知识点

网络技术平台

架构相关5

FreeOcean

shark defi鲨鱼智能合约系统软件APP开发

开發I852946OIIO

系统开发

JVM笔记【1】-- 运行时数据区

秦怀杂货店

JVM JVM笔记

架构2期 - 第十周作业(1)

浮生一梦

极客大学架构师训练营 第十周 2组

XRP瑞波币软件系统开发|XRP瑞波币APP开发

开發I852946OIIO

系统开发

架构师训练营第 10 周课后练习

菜青虫

极客大学架构师训练营

生产环境全链路压测建设历程 21:某快递 A 股上市公司的生产压测案例之彩蛋2前言

数列科技杨德华

全链路压测 七日更

架构师训练营:通达同城快递架构设计文档

9527

架构师训练营第十周作业一

韩儿

JDK、JRE、JVM,是什么关系?

小傅哥

jdk JVM 小傅哥 七日更 jre

关于微服务架构

落朽

设计模式【2】-- 简单工厂模式了解一下?

秦怀杂货店

设计模式 工厂模式 工厂方法模式

如何进行网络框架的学习和设计-InfoQ