面试官的题库,让一部分人先拿Offer!
在 Java 和 Kotlin 领域,有许多出色的网络请求框架,比如 OkHttp、Retrofit。而我们今天要实现的 KtHttp,它的灵感来自于 Retrofit。
之所以选择 Retrofit 作为借 鉴的对象,是因为它的底层使用了大量的泛型、注解和反射的技术。
如果你能跟着我一起用泛 型、注解、反射来实现一个简单的网络请求框架,相信你对这几个知识点的认识也会更加透 彻
为了方便你理解,我 们的代码会分为两个版本:
- 1.0 版本,我们会用 Java 思维,以最简单直白的方式来实现 KtHttp 的基础功能——同步式 的 GET 网络请求;
- 2.0 版本,我们会用函数式思维来重构代码
Java思维版本
我们通过 KtHttp 请求了一个服务器的 API,然后在控制台输出了结果。这其 实是我们在开发工作当中十分常见的需求。通过这个 KtHttp,我们就可以在程序当中访问任何 服务器的 API
了描述服务器返回的内容,我们定义了两个数据类
数据类以外,我们还要定义一个用于网络请求的接口
这个接口当中,有两个注解
- GET 注解,代表了这个网络请求应该是 GET 请求,这是HTTP请求的一种方式。GET 注
解当中的“/repo”,代表了 API 的 path,它是和 baseURL 拼接的;
- Field 注解,代表了 GET 请求的参数。Field 注解当中的值也会和 URL 拼接在一起
最后,我们再来看看 KtHttp 是如何使用的
上面的代码有两个注释,我们分别来看。
- 注释①:我们调用 KtHttpV1.create() 方法,传入了 ApiService::class.java,参数的类型是
Class,返回值类型是 ApiService。这就相当于创建了 ApiService 这个接口的实现类的
对象。
- 注释②:我们调用 api.repos() 这个方法,传入了 Kotlin、weekly 这两个参数,代表我们想
查询最近一周最热门的 Kotlin 开源项目
KtHttpV1.create() 是如何创建 ApiService 的实例的呢?
这里是使用动态代理,也就是 JDK 的Proxy。 Proxy 的底层,其实也用到了反射。这里具体实现如下
到这里,我们程序的基本框架也就搭建好了
我们再看看上面的待完成部分 我们要做的是把前面 ApiService中的Field提取出来
其实我们真正需要实现的逻辑,就是想办法把注解当中的值 /repo、lang、since 取出来,然后拼接到 URL 当中去。这里要使用注解提取了
在上面的代码中,一共有 6 个注释,我们一个个看。
- 注释①:method 的类型是反射后的 Method,在我们这个例子当中,它最终会代表被调用
的方法,也就是 ApiService 接口里面的 repos() 这个方法。
- 注释②:args 的类型是对象的数组,在我们的例子当中,它最终会代表方法的参数的值,
也就是“api.repos("Kotlin", "weekly")”当中的"Kotlin"和"weekly"。
- 注释③:method.annotations,代表了我们会取出 repos() 这个方法上面的所有注解,由于
repos() 这个方法上面可能会有多个注解,因此它是数组类型。
- 注释④:我们使用 for 循环,遍历所有的注解,找到 GET 注解。
- 注释⑤:我们找到 GET 注解以后,要取出 @GET(“/repo”) 当中的"/repo",也就是
“annotation.value”。这时候我们只需要用它与 baseURL 进行拼接,就可以得到完整的
URL;
- 注释⑥:return@newProxyInstance,用的是 Lambda 表达式当中的返回语法,在得到完整
的 URL 以后,我们将剩下的逻辑都交给了 invoke() 这个方法。
接下来,我们再来看看 invoke() 当中的“待完成代码”应该怎么写
我们来看看最终的代码
上面的代码一共涉及五个注释,它们都是跟注解与反射这两个知识点相关的。
- 注释①,method.parameterAnnotations,它的作用是取出方法参数当中的所有注解,在我
们这个案例当中,repos() 这个方法当中涉及到两个注解,它们分别是@Field("lang")、
@Field("since")。
- 注释②,由于方法当中可能存在其他注解,因此要筛选出我们想要的 Field 注解。
- 注释③,这里是取出注解当中的值“lang”,以及参数当中对应的值“Kotlin”进行拼接,URL 第
一次拼接参数的时候,要用“?”分隔。
- 注释④,这里是取出注解当中的值“since”,以及参数当中对应的值“weekly”进行拼接,后面
的参数拼接格式,是用“&”分隔。
- 注释⑤,method.genericReturnType 取出 repos() 的返回值类型,也就是 RepoList,最
终,我们用它来解析 JSON。
相信现在,你已经能够体会我们使用 动态代理 + 注解 + 反射 实现这个网络请求框架的原因
了。
函数式思维
如果你理解了 1.0 版本的代码,2.0 版本的程序也就不难实现了。因为这个程序的主要 功能都已经完成了,现在要做的只是:换一种思路重构代码
我们先来看看 KtHttpV1 这个单例的成员变量
okHttpClient、gson 这两个成员是不支持懒加载的,因此我们首先应该让它们支持懒加载
我们直接使用了 by lazy 委托的方式,它简洁的语法可以让我们快速实现懒加载
接下来,我们再来看看 create() 这个方法的定义
create() 会接收一个Class类型的参数。其实,针对这样的情况,我们 完全可以省略掉这个参数。具体做法,是使用我们前面学过的inline,来实现类型实化 ****(Reified Type)。我们常说,Java 的泛型是伪泛型,而这里我们要实现的就是真泛型
正常情况下,泛型参数类型会被擦除,这就是 Java 的泛型被称为“伪泛型”的原因。而通过 使用 inline 和 reified 这两个关键字,我们就能实现类型实化,也就是“真泛型”,进一步,我 们就可以在代码注释①、②的地方,使用“T::class.java”来得到 Class 对象
下面我们再使用高阶函数重构一下 create中的逻辑
在这个方法当中,我们需要读取 method 当中的 GET 注解,解析出它的值,然后与 baseURL 拼接。这里我们完全可以借助 Kotlin 的标准库函数来实现
这段代码的可读性很好,语义也很清晰
好了,create() 方法的重构已经完成,接下来我们来看看 invoke() 方法该如何重构
这段代码读起来也不难,我们一行一行来分析。
- 第一步,我们通过 method.parameterAnnotations,获取方法当中所有的参数注解,在这里
也就是@Field("lang")、@Field("since")。
- 第二步,我们通过 takeIf 来判断,参数注解数组的数量与参数的数量相等,也就是说
@Field("lang")、@Field("since")的数量是 2,那么["Kotlin", "weekly"]的
size 也应该是 2,它必须是一一对应的关系。
- 第三步,我们将@Field("lang")与"Kotlin"进行配对,将@Field("since")
与"weekly"进行配对。这里的 mapIndexed,其实就是 map 的升级版,它本质还是一种映
射的语法,“注解数组类型”映射成了“Pair 数组”,只是多了一个 index 而已。
- 第四步,我们使用 fold 与 parseUrl() 这个方法,拼接出完整的 URL,也就是:
baseurl.com/repo?lang=K…。 这里我们使用了函数引用的语法
“::parseUrl”。而 fold 这个操作符,其实就是高阶函数版的 for 循环。
- 第五步,我们构建出 OkHttp 的 Request 对象,并且将 URL 传入了进去,准备做网络请
求。
- 第六步,我们通过 okHttpClient 发起了网络请求,并且拿到了 String 类型的 JSON 数据。
最后,我们通过 Gson 解析出 JSON 的内容,并且返回 RepoList 对象。
至此,我们 2.0 版本的代码就完成了,完整的代码如下
对应的 1.0版本的代码
可见,1.0 版本、2.0 版本,它们之间可以说是天壤之别。
小结
好了,这节实战就到这里。接下来我们来简单总结一下:
- 在 1.0 版本的代码中,我们灵活利用了动态代理、泛型、注解、反射这几个技术,实现了
KtHttp 的基础功能。
- 动态代理,由于它的底层原理比较复杂,课程当中我是通过 ApiImpl 这个类,来模拟了它动
态生成的 Proxy 类。用这种直观的方式来帮助你理解它存在的意义。
- 泛型方面,我们将其用在了动态代理的 create() 方法上,后面我们还使用了“类型实化”的技
术,也就是 inline + reified 关键字。
- 注解方面,我们首先自定义了两个注解,分别是 GET、Field。其中,@GET 用于标记接口
的方法,它的值是 URL 的 path;@Field 用于标记参数,它的值是参数的 key。
- 反射方面,这个技术点,几乎是贯穿于整个代码实现流程的。我们通过反射的自省能力,去
分析 repos() 方法,从 GET 注解当中取出了“/repo”这个 path,从注解 Field 当中取出了
lang、since,还取出了 repos() 方法的返回值 RepoList,用于 JSON 数据的解析。
- 在 2.0 版本的代码中,我们以函数式的思维重写了 KtHttp 的 内部逻辑。在这个版本当中,我们大量地使用了 Kotlin 标准库里的高阶函数,进一步提升了代码的可读性